Initial commit

This commit is contained in:
scorpion21211-cmd
2026-10-03 23:29:41 -04:00
commit e24bbbda5d
10586 changed files with 1030090 additions and 0 deletions
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 Godot AI contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+53
View File
@@ -0,0 +1,53 @@
# Godot AI
Connect AI assistants to a live Godot editor via the [Model Context Protocol](https://modelcontextprotocol.io/introduction) (MCP).
Godot AI bridges Claude Code, Codex, Antigravity, DeepSeek Harness, and other MCP clients with your editor — inspect scenes, create nodes, modify properties, run tests, search project files, and more, all from a prompt.
## Quick Start
1. Copy `addons/godot_ai/` into your project's `addons/` folder
2. Enable the plugin: **Project > Project Settings > Plugins > Godot AI**
3. Pick your MCP client in the **Godot AI** dock and press **Configure**
The plugin auto-starts the MCP server and connects over WebSocket. No manual configuration required.
## Requirements
- Godot 4.5+ (4.7+ recommended)
- [uv](https://docs.astral.sh/uv/) (used to install the Python server)
<details>
<summary>Install uv</summary>
**macOS / Linux:**
```bash
curl -LsSf https://astral.sh/uv/install.sh | sh
```
**Windows (PowerShell):**
```powershell
powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex"
```
**Homebrew (macOS / Linux):**
```bash
brew install uv
```
**pipx:**
```bash
pipx install uv
```
See the [uv install docs](https://docs.astral.sh/uv/getting-started/installation/) for more options.
</details>
- An MCP client ([Claude Code](https://docs.anthropic.com/en/docs/claude-code) | [Codex](https://openai.com/index/codex/) | [Antigravity](https://www.antigravity.dev/))
## Documentation
Full documentation, contributing guide, and source code: [github.com/hi-godot/godot-ai](https://github.com/hi-godot/godot-ai)
## License
[MIT](LICENSE)
File diff suppressed because it is too large Load Diff
@@ -0,0 +1 @@
uid://1kiy8hqyymyj
+206
View File
@@ -0,0 +1,206 @@
@tool
class_name McpAtomicWrite
extends RefCounted
## Write text to a file via temp + rename so a crash mid-write never leaves
## the user's MCP config truncated. Creates the parent dir if needed and
## keeps a one-shot `.backup` of the prior file.
##
## On filesystems where rename-over-existing fails (Windows under AV / lock
## pressure, some SMB shares), falls back to overwrite-copy plus a
## backup-restore on failure. The original file is never removed before the
## new bytes are verified on disk — if both the rename and the copy fail,
## the user's prior config is restored from the `.backup` snapshot. See
## issue #297 finding #10 for the data-loss scenario this guards against.
static func write(path: String, content: String) -> bool:
# If the target is a symlink (stow/chezmoi-managed dotfiles), rename-over
# would replace the LINK with a regular file, silently detaching the
# config from the user's dotfile repo (#534). Resolve the link chain and
# write to the real target so the symlink survives.
path = _resolve_symlink_target(path)
var dir_path := path.get_base_dir()
if not DirAccess.dir_exists_absolute(dir_path):
if DirAccess.make_dir_recursive_absolute(dir_path) != OK:
return false
# Decide the permission mode the final file (and its backup) must carry
# BEFORE we replace anything. A rewrite must preserve the prior file's
# mode: the Claude CLI creates ~/.claude.json as 0600 (it holds OAuth
# creds + history), and a naive FileAccess write + DirAccess copy would
# silently relax that to the umask default (0644) and leak it on shared
# machines. A brand-new config defaults to owner-only 0600 since these
# files routinely carry tokens. On platforms without POSIX permissions
# (Windows) the get/set calls no-op and this logic is inert. See #297
# finding TC-1.
var had_original := FileAccess.file_exists(path)
var target_mode := _resolve_target_mode(path, had_original)
# Suffix the temp name with this process's PID so two editors writing the
# same config concurrently (both clicking Configure) can't interleave
# bytes on a shared fixed ".tmp" path (#534). Each process stages its own
# temp file; the final rename remains the atomic commit point.
var tmp_path := "%s.tmp.%d" % [path, OS.get_process_id()]
var file := FileAccess.open(tmp_path, FileAccess.WRITE)
if file == null:
return false
# Lock the temp inode down BEFORE writing any bytes. FileAccess.open creates
# it at the umask default (often 0644); chmod'ing the still-empty file first
# means the config contents are never on disk under a world-readable mode in
# the create->chmod gap. rename preserves the inode mode, so the swapped-in
# file lands correct and is never briefly world-readable under the target name.
_apply_mode(tmp_path, target_mode)
file.store_string(content)
# Push Godot's internal buffer out to the OS before the rename. Godot
# exposes no fsync, so the bytes aren't guaranteed durable on the physical
# disk until the OS flushes its own cache — a power loss in that window can
# still lose the data. But flush() ensures the rename can't be ordered ahead
# of the write at the application layer, which is the failure this guards.
file.flush()
file.close()
# Re-assert the mode on the closed inode. The pre-write chmod above closes
# the world-readable window; this second apply is the authoritative one
# (a chmod issued while the FileAccess handle is still open doesn't reliably
# stick inside the editor) and guarantees the final mode before the rename,
# which preserves it.
_apply_mode(tmp_path, target_mode)
# Verify the staged temp landed intact before committing it anywhere. The
# copy-fallback path below already guards this (`_written_size_matches` at
# the rename-fallback check); the rename path was the one gap — under
# disk-full/quota the temp can be silently truncated, and an unverified
# rename would swap a truncated file over the live target while the
# caller is told the write succeeded (#687).
if not _written_size_matches(tmp_path, content):
DirAccess.remove_absolute(tmp_path)
return false
# Best-effort: snapshot the prior file before we touch the target so we
# can restore on a failed swap. The backup is also kept on success as a
# one-shot rollback aid for the user — give it the same (preserved) mode
# so a 0600 config's backup isn't itself a world-readable copy.
#
# copy_absolute creates the backup at the umask default and we can only
# chmod it afterward, so there's a sub-millisecond window where the backup
# carries default perms. Accepted: it duplicates bytes already sitting at
# `path` (which the caller created 0600) inside the user's own config dir,
# and Godot exposes no API to create the copy pre-chmod'd. Not worth
# reimplementing copy by hand to shave that window.
var backup_path := path + ".backup"
var backup_made := false
if had_original:
DirAccess.remove_absolute(backup_path)
if DirAccess.copy_absolute(path, backup_path) == OK:
backup_made = true
_apply_mode(backup_path, target_mode)
if DirAccess.rename_absolute(tmp_path, path) == OK:
return true
# Rename-over-existing rejected (Windows + AV / lock timing, some SMB
# shares). Use overwrite-copy as the recovery path: copy_absolute never
# removes the original before writing the new bytes, so a failure here
# leaves the user's prior config in place rather than nuking it.
if DirAccess.copy_absolute(tmp_path, path) == OK and _written_size_matches(path, content):
# copy_absolute creates the destination with the default mode, so
# re-apply the preserved/owner-only mode after the copy lands.
_apply_mode(path, target_mode)
DirAccess.remove_absolute(tmp_path)
return true
# Copy didn't land cleanly. Restore the destination to its pre-call state.
if backup_made:
# Restore the snapshot we took before the swap. `copy_absolute`
# overwrites the destination, so we don't pre-remove `path` — the
# pre-remove created a window where `path` was gone if the
# subsequent copy itself failed. If the restore copy fails now the
# user's prior bytes are still in `.backup` for manual recovery
# and the false return value tells the caller the swap didn't
# complete.
DirAccess.copy_absolute(backup_path, path)
_apply_mode(path, target_mode)
elif not had_original and FileAccess.file_exists(path):
# No prior file existed but copy_absolute landed partial bytes at
# `path`. Remove them so the failure leaves nothing on disk rather
# than a truncated/invalid new file. The `file_exists` guard keeps
# us off non-file destinations (a path that points at a directory
# yields `had_original=false` too, but we must not try to delete
# the directory). Issue #297 PR review.
DirAccess.remove_absolute(path)
# (If `had_original` is true but the snapshot couldn't be taken, the
# original on disk is whatever copy_absolute managed to write before
# failing. This is a best-effort path — the false return value tells the
# caller the swap didn't complete; recovery beyond that requires a
# backup we couldn't take.)
DirAccess.remove_absolute(tmp_path)
return false
## Follow a symlink chain at `path` and return the final real target, so the
## temp+rename lands on the linked-to file instead of replacing the link.
##
## Best-effort by design: DirAccess.is_link()/read_link() are only implemented
## on platforms with POSIX symlinks (Linux/macOS; on Windows and other
## platforms is_link() returns false), and opening the parent dir can fail for
## exotic paths. In every "can't tell" case we return `path` unchanged, which
## is exactly the pre-#534 behavior — never worse, symlink-preserving where
## the engine lets us detect one.
static func _resolve_symlink_target(path: String) -> String:
var resolved := path
# Bounded hops so a symlink cycle can't loop us forever.
for _hop in 8:
var base_dir := resolved.get_base_dir()
var da := DirAccess.open(base_dir)
if da == null or not da.is_link(resolved):
return resolved
var target := da.read_link(resolved)
if target.is_empty():
return resolved
if target.is_relative_path():
target = base_dir.path_join(target)
resolved = target.simplify_path()
return resolved
static func _resolve_target_mode(path: String, had_original: bool) -> int:
# Preserve the prior file's POSIX mode on a rewrite; default a brand-new
# config (or any case we can't read a mode for) to owner read+write (0600).
#
# get_unix_permissions returns 0 both on Windows (no POSIX perms) and for a
# genuine 0000 file. Treating 0 as "use the 0600 floor" is deliberate, not a
# missed case: these are config files the plugin must read and write, 0000 is
# unusable, and re-applying 0000 would lock the owner out next run. 0600 is
# still owner-only so this never widens access. (A genuinely-0000 file can't
# reach a rewrite through the config strategies anyway — their read-first
# guard fails to open it and refuses the write before we get here.)
if had_original:
var existing := FileAccess.get_unix_permissions(path)
if existing > 0:
return existing
return FileAccess.UNIX_READ_OWNER | FileAccess.UNIX_WRITE_OWNER
static func _apply_mode(path: String, mode: int) -> void:
# Best-effort. set_unix_permissions returns ERR_UNAVAILABLE on platforms
# without POSIX permissions (Windows); that's expected and ignored so the
# write still works there. mode <= 0 should never happen (resolve always
# returns >0) but is guarded so a future caller can't chmod a file to nothing.
if mode <= 0:
return
var err := FileAccess.set_unix_permissions(path, mode)
# Surface a real chmod failure (not the Windows no-op) so permission
# hardening on a sensitive config doesn't fail completely silently.
if err != OK and err != ERR_UNAVAILABLE:
push_warning("MCP | could not set permissions on %s (error %d)" % [path, err])
static func _written_size_matches(path: String, content: String) -> bool:
# `store_string` writes UTF-8 bytes with no BOM and no newline translation,
# so the byte length on disk must match `to_utf8_buffer().size()` exactly.
var f := FileAccess.open(path, FileAccess.READ)
if f == null:
return false
var on_disk := f.get_length()
f.close()
return on_disk == content.to_utf8_buffer().size()
@@ -0,0 +1 @@
uid://6fkb5uau0r4h
+451
View File
@@ -0,0 +1,451 @@
@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" | "dsh"
# JSON / TOML clients ------------------------------------------------------
## {"darwin": "~/...", "windows": "$APPDATA/...", "linux": "$XDG_CONFIG_HOME/..."}
## Keys may also use "unix" as a shorthand for darwin+linux.
var path_template: Dictionary = {}
## Optional ordered path candidates by platform. Each value is an Array of
## templates; one `*` may appear in a directory segment so packaged-app roots
## can be discovered without hardcoding publisher hashes.
##
## Resolution contract:
## 1. Existing files win in descriptor order, except that a unique wildcard
## match is authoritative even before its config leaf exists. A matching
## package root therefore creates inside that package rather than writing
## a fallback path that may become invisible after copy-on-write. When
## that private leaf is new, Configure seeds it from the first later
## existing candidate so read-through content is not shadowed.
## 2. If no file or wildcard package match exists, the first non-wildcard
## template is the deterministic create target.
## 3. Multiple matches within any wildcard group are ambiguous and fail
## closed instead of choosing an arbitrary package.
##
## Exact-file and config-home environment overrides still have higher
## priority. When this map has no entry for the current platform,
## `path_template` remains the fallback.
var config_path_candidates: Dictionary = {}
## Optional global JSON config files merged by the client from lowest to highest
## precedence. Unlike `config_path_candidates`, every existing file participates;
## Configure updates the effective last definition, status verifies it, and
## Remove clears every global definition transactionally.
var config_merge_path_templates: Dictionary = {}
## Project-relative tiers that may override the global files. Their root is the
## external client's working directory, which the Godot process cannot know.
## The strategy checks plausible roots and fails closed instead of mutating them.
var config_merge_project_paths: PackedStringArray = PackedStringArray()
## De-duplicate persistent path-ambiguity warnings across recurring status
## refreshes. The actionable message still returns on every resolution; only
## the editor-console echo is single-shot until the ambiguity clears/changes.
var _last_config_path_warning := ""
var _config_path_warning_mutex := Mutex.new()
## Path inside the config object where the per-server map lives.
## Cursor / Claude Desktop / most others: ["mcpServers"]
## VS Code: ["servers"]
## OpenCode: ["mcp"]
var server_key_path: PackedStringArray = PackedStringArray()
## Alternative server-map paths accepted by the client, in precedence order
## after `server_key_path`. JSON strategy operations select the first non-null
## existing path so Configure, status, and Remove agree with the client parser.
var server_key_path_aliases: Array[PackedStringArray] = []
## Field inside the entry dict that holds our server URL.
## "url" by default; some clients use "serverUrl" or "httpUrl".
var entry_url_field: String = "url"
## Required entry fields — written on every Configure AND verified by the
## default verifier. Use this for transport pins (e.g. `type:
## "streamable-http"`) where a missing/wrong value breaks negotiation: a
## legacy entry without the pin fails verification and surfaces as drift.
##
## DO NOT put user-mutable state here (auto-approval lists, `disabled`
## flags, opt-in toggles). Verifying those treats every user customisation
## as drift, and Configure-All-Mismatched then silently overwrites them
## back to defaults — see the `entry_initial_fields` doc below.
var entry_extra_fields: Dictionary = {}
## Default fields written ONLY when the entry doesn't yet exist. Reconfigure
## preserves whatever the user (or the client itself) has set; the verifier
## ignores these keys entirely. Use for opt-in flags and user-state arrays —
## e.g. Roo / Cline / Kilo `alwaysAllow` / `autoApprove` lists, `disabled:
## false`, `isActive: true`. The pre-#229 behaviour was equivalent: per-
## client `entry_builder` lambdas seeded these as defaults but the
## per-client `verify_entry` lambdas only checked transport pins, so a
## user-customised array was `CONFIGURED`, not drift. Splitting the field
## restores that contract under the data-only descriptor model.
var entry_initial_fields: Dictionary = {}
## Client-owned stdio launch shape. Each strategy renders the shape in its
## config language:
##
## - FLAT — `command` string + `args` array as sibling keys. JSON and YAML
## strategies. A client whose docs require a type discriminator next to the
## flat keys (VS Code's `type: "stdio"`, Claude Code's fallback file) stays
## FLAT and declares it via `command_transport_key` / `command_transport_value`,
## so TYPED_FLAT remains reserved vocabulary.
## - COMMAND_ARRAY — the launch argv carried as one array. In the JSON
## strategy the entry's `command` field IS that array (OpenCode's
## `"command": ["uvx", …]`). In the TOML strategy the launcher renders as a
## `command = "…"` line plus an `args = […]` array (Codex, Grok) — the name
## refers to the argv-as-TOML-array body it emits.
## - NESTED_COMMAND — command/args nested inside a sub-object. Reserved; no
## current client needs it and strategies reject it with an actionable error.
##
## CLI-registered clients (`config_type == "cli"`) express the launch through
## `cli_register_template` tokens instead; their `command_shape` governs the
## JSON-fallback file rendering (Claude Code, #463).
##
## Values are data-only shared vocabulary; keeping them data-only avoids
## reintroducing the descriptor Callable race from #229.
enum CommandShape { NONE, FLAT, TYPED_FLAT, COMMAND_ARRAY, NESTED_COMMAND }
var command_shape: CommandShape = CommandShape.NONE
## Whether manual instructions may offer the client's native URL transport as
## an alternative to its command shape. This is capability metadata, not a
## consequence of `command_shape`: Codex supports a URL block, while Claude
## Desktop's local `claude_desktop_config.json` entries are stdio-only.
var command_supports_url_fallback: bool = false
## Optional discriminator required by a client's command transport shape
## (for example `type = "stdio"`). Empty means command+args are sufficient.
var command_transport_key: String = ""
var command_transport_value: Variant = null
## Whether this client's Windows stdio entry must launch through the
## GUI-subsystem pythonw bootstrap (#827). The bootstrap exists for clients
## that run console-subsystem MCP commands in a visible terminal (Codex);
## Electron-family spawners hide child consoles themselves, and at least one
## (Antigravity) hangs tool calls when handed a GUI-subsystem executable
## (#863). Set false to write the plain console launcher on Windows.
var needs_consoleless_launcher: bool = true
## Keys from the legacy transport that Configure must delete. Codex removes
## `url`, because Codex rejects a server entry containing both URL and stdio
## launch fields.
var command_legacy_keys: PackedStringArray = PackedStringArray()
## Keys inside a preserved JSON `env` object that belonged to a legacy launch
## shape and must be removed during migration. Other environment values remain
## user-owned and survive Configure. Currently consumed by the JSON strategy.
var command_env_legacy_keys: PackedStringArray = PackedStringArray()
## Defaults seeded only for a new entry. Reconfigure preserves user values.
## Codex uses this for enabled/startup/tool timeout defaults.
var command_initial_fields: Dictionary = {}
## Declarative documentation of fields owned by the user and timeout fields
## supported by this client. Strategies preserve these values and tests pin
## the descriptor contract; no control flow lives on the descriptor.
var command_user_fields: PackedStringArray = PackedStringArray()
var command_timeout_fields: PackedStringArray = PackedStringArray()
## Paths whose existence implies the user has this client installed.
## Used purely for the dock's "installed" badge. `is_installed()` additionally
## checks `resolved_config_path()`, so a config relocated via an environment
## override is detected without listing it here.
var detect_paths: PackedStringArray = PackedStringArray()
# Config-path env overrides --------------------------------------------------
## Some clients name the exact config file in an environment variable
## (OpenCode: `$OPENCODE_CONFIG`). When the variable is set and non-empty, it
## wins over directory-valued `config_home_env` and `path_template`. Relative
## values fail closed because the editor and client may have different working
## directories; auto-configuration cannot safely assume they resolve alike.
var config_file_env: String = ""
## Some clients honor an env var that relocates their entire config home
## (Codex: `$CODEX_HOME/config.toml`; Claude Code: `$CLAUDE_CONFIG_DIR/.claude.json`).
## When `config_home_env` names an env var that is set and non-empty,
## `resolved_config_path()` returns `<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()
## Optional scope-aware status template with a `{name}` token, for descriptors
## that also take `{scope}` in their register template. `cli_status_args` lists
## whatever the CLI resolves for the current directory and cannot say WHICH
## scope an entry came from, so a leftover user-scope entry reads as CONFIGURED
## while the selected project scope is empty — the dock then shows green over
## exactly the "loaded in every workspace" state the scope setting exists to
## end (#872). This template must print a `Scope: <user|project|local> …` line
## (`claude mcp get <name>` does) and exit non-zero when the server is absent.
## Only consulted when the selected scope isn't the one `path_template` reads.
var cli_scope_status_template: PackedStringArray = PackedStringArray()
# Codex / TOML clients -----------------------------------------------------
## Dotted TOML path under which our entry lives, e.g. ["mcp_servers", "godot-ai"].
## Strategies build the [section."name"] header from this.
var toml_section_path: PackedStringArray = PackedStringArray()
var toml_legacy_section_aliases: PackedStringArray = PackedStringArray()
## Lines (without the [header]) emitted under the section, with `{url}`
## tokens. Substituted at call time.
var toml_body_template: PackedStringArray = PackedStringArray()
## Resolved absolute config path for this client on the current OS. Exact-file
## overrides win first, followed by directory-valued `config_home_env`, then
## ordered candidates / `path_template`. Ignoring either override can write a
## file the client never reads and false-succeed.
func resolved_config_path() -> String:
return str(resolved_config_path_details().get("path", ""))
## Detailed sibling used by status/configure/remove so safe resolution
## failures reach the dock instead of collapsing into NOT_CONFIGURED. `error`
## is empty for ordinary unsupported/missing path mappings to preserve the
## long-standing status behavior for clients not installed on this platform.
func resolved_config_path_details() -> Dictionary:
## Reflected reads: after an in-session self-update, an instance created
## before the update can answer Nil for vars the update added, and the
## typed calls below would each hard-error (Nil -> Dictionary, #850's
## per-row error wall). Fail with the
## one repair message instead; the registry's coherence probe drives the
## same text on the status path.
var candidates: Variant = get("config_path_candidates")
var template: Variant = get("path_template")
var file_env: Variant = get("config_file_env")
if not (candidates is Dictionary) or not (template is Dictionary) or not (file_env is String):
return {"path": "", "error": McpClientRegistry.RESTART_TO_FINISH_UPDATE}
var file_override := config_file_override_details()
if not str(file_override.get("path", "")).is_empty() or not str(file_override.get("error", "")).is_empty():
_clear_config_path_warning()
return file_override
var override := config_home_override()
if not override.is_empty():
_clear_config_path_warning()
return {"path": override, "error": ""}
var candidate_key := McpPathTemplate.platform_key(candidates)
if not candidate_key.is_empty():
return _resolve_ordered_config_path_candidates(candidates[candidate_key])
_clear_config_path_warning()
return {"path": McpPathTemplate.resolve(template), "error": ""}
## The exact-file env override plus any fail-closed diagnostic. Empty path and
## error means no override applies (no mapping, unset, or blank env var).
func config_file_override_details() -> Dictionary:
if config_file_env.is_empty():
return {"path": "", "error": ""}
## env_lookup, not OS.get_environment: this can run on dock workers (#691).
var raw_path := McpPathTemplate.env_lookup(config_file_env).strip_edges()
if raw_path.is_empty():
return {"path": "", "error": ""}
var expanded := McpPathTemplate.expand(raw_path)
if not expanded.is_absolute_path():
return {
"path": "",
"error": "%s's $%s override must be an absolute config-file path; got %s" % [
display_name, config_file_env, raw_path,
],
}
if DirAccess.dir_exists_absolute(expanded):
return {
"path": "",
"error": "%s's $%s override must point to a config file, not a directory: %s" % [
display_name, config_file_env, expanded,
],
}
return {"path": expanded, "error": ""}
func _resolve_ordered_config_path_candidates(templates: Variant) -> Dictionary:
if not (templates is Array or templates is PackedStringArray):
_clear_config_path_warning()
return {"path": "", "error": ""}
var ordered_templates: Array = []
for template_variant in templates:
ordered_templates.append(str(template_variant))
var fallback_create_path := ""
for index in range(ordered_templates.size()):
var template := str(ordered_templates[index])
var group := McpPathTemplate.expand_path_candidates(template)
if group.size() > 1:
var message := (
"%s has multiple matching config package paths for %s: %s. "
+ "Remove the stale package installation or edit the intended config manually."
) % [display_name, template, ", ".join(group)]
_warn_config_path_once(message)
return {"path": "", "error": message}
if group.is_empty():
continue
var path := String(group[0])
if FileAccess.file_exists(path):
_clear_config_path_warning()
return {"path": path, "error": ""}
# A wildcard only resolves when its package directory exists. Treat that
# installation evidence as authoritative and create its private config
# directly instead of relying on copy-on-write read-through. Preserve
# anything currently visible through read-through by naming the first
# later existing candidate as a one-time seed source.
if template.contains("*"):
var seed_path := _first_existing_later_candidate(ordered_templates, index + 1)
_clear_config_path_warning()
return {"path": path, "error": "", "seed_path": seed_path}
if fallback_create_path.is_empty():
fallback_create_path = path
_clear_config_path_warning()
return {"path": fallback_create_path, "error": ""}
func _first_existing_later_candidate(templates: Array, start_index: int) -> String:
for index in range(start_index, templates.size()):
var group := McpPathTemplate.expand_path_candidates(str(templates[index]))
# A seed is optional. Never choose among an ambiguous later wildcard;
# the authoritative target was already resolved by the earlier group.
if group.size() != 1:
continue
var path := String(group[0])
if FileAccess.file_exists(path):
return path
return ""
func _warn_config_path_once(message: String) -> void:
_config_path_warning_mutex.lock()
var should_warn := message != _last_config_path_warning
_last_config_path_warning = message
_config_path_warning_mutex.unlock()
if should_warn:
push_warning(message)
func _clear_config_path_warning() -> void:
_config_path_warning_mutex.lock()
_last_config_path_warning = ""
_config_path_warning_mutex.unlock()
## The env-var-relocated config path, or "" when no override applies
## (no mapping declared, env var unset, or env var empty/whitespace).
func config_home_override() -> String:
if config_home_env.is_empty() or config_home_env_subpath.is_empty():
return ""
## env_lookup, not OS.get_environment: this runs on dock worker threads,
## which must not race the spawn window's setenv/unsetenv (#691).
var home := McpPathTemplate.env_lookup(config_home_env).strip_edges()
if home.is_empty():
return ""
# Expand a leading ~ so `CODEX_HOME=~/codex-alt` behaves like the shell.
return McpPathTemplate.expand(home).path_join(config_home_env_subpath)
## True when a CLI client also declares where its config file lives, so it can
## fall back to writing that file directly when the CLI binary isn't on PATH.
## #463: Claude Code installed only as a VS Code / Cursor extension exposes no
## `claude` binary, but `claude mcp add --scope user` just writes `mcpServers`
## into ~/.claude.json — so we can produce the same entry ourselves.
func has_json_fallback() -> bool:
return config_type == "cli" and not path_template.is_empty() and not server_key_path.is_empty()
## True if the user appears to have this client installed locally.
func is_installed() -> bool:
if config_type == "cli":
if not McpCliFinder.find(_array_from_packed(cli_names)).is_empty():
return true
# CLI not on PATH. A cli client with a JSON fallback (Claude Code as a
# VS Code/Cursor extension, #463) still counts as installed if its
# fallback config file already exists.
if has_json_fallback():
var cfg := resolved_config_path()
return not cfg.is_empty() and FileAccess.file_exists(cfg)
return false
for p in detect_paths:
for resolved in McpPathTemplate.expand_path_candidates(p):
if FileAccess.file_exists(resolved) or DirAccess.dir_exists_absolute(resolved):
return true
# Fall back to "config file already exists" — usually means installed at some point.
var cfg := resolved_config_path()
return not cfg.is_empty() and FileAccess.file_exists(cfg)
static func _array_from_packed(packed: PackedStringArray) -> Array[String]:
var out: Array[String] = []
for s in packed:
out.append(s)
return out
## Slice a PackedStringArray into a new PackedStringArray over [from, to).
## Used by `_toml_strategy` and `_manual_command` to peel the section path
## apart for `[a.b."c"]` header rendering.
static func _packed_slice(packed: PackedStringArray, from: int, to: int) -> PackedStringArray:
var out := PackedStringArray()
for i in range(from, to):
out.append(packed[i])
return out
+1
View File
@@ -0,0 +1 @@
uid://cyowqr1x12ilg
+169
View File
@@ -0,0 +1,169 @@
@tool
class_name McpCliExec
extends RefCounted
## Wall-clock-bounded CLI invocation. Every dock shell-out to a per-client
## CLI (`claude mcp list`, `claude mcp add ...`, etc.) goes through here so
## a hung subprocess can't trap the calling thread forever.
##
## Without the timeout, a contended `claude mcp list` has been observed to
## hang for 6+ minutes (issues #238, #239) — wedging the dock's status
## refresh worker, and on the Configure / Remove paths the editor main
## thread itself.
##
## Why poll/kill instead of `OS.execute(..., true)`: GDScript can't
## interrupt a blocking `OS.execute`, so a hung CLI takes its caller's
## thread with it. `OS.execute_with_pipe` returns immediately with a PID;
## we drive the wait ourselves and `OS.kill` the orphan if budget
## expires. CLI registry commands have bounded output (a few hundred
## bytes), so we don't bother draining the pipe during the poll loop —
## the kernel buffer absorbs it.
##
## Returns a Dictionary with:
## exit_code: process exit code (0 = success). -1 on timeout / spawn failure.
## stdout: captured stdout text. May be partial on timeout.
## stderr: captured stderr text. May be partial on timeout. Empty when
## `capture_stderr` is false.
## output: stdout + (newline + stderr if non-empty). Convenience for
## the common case of "show whatever the CLI said when it
## failed" — `claude mcp add` writes its real diagnostics to
## stderr, so callers that only read `stdout` would surface
## a generic "exit code 1" instead.
## timed_out: true if we killed the process at the wall-clock budget.
## spawn_failed: true if `OS.execute_with_pipe` didn't return a usable PID.
const DEFAULT_TIMEOUT_MS := 8000
const _POLL_INTERVAL_MS := 50
const _KILL_GRACE_MS := 500
static func run(
exe: String,
args: Array,
timeout_ms: int = DEFAULT_TIMEOUT_MS,
capture_stderr: bool = true
) -> Dictionary:
if exe.is_empty():
return _spawn_failed_result()
return _run_piped(exe, args, timeout_ms, capture_stderr)
static func _run_piped(
exe: String,
args: Array,
timeout_ms: int,
capture_stderr: bool,
) -> Dictionary:
var spawn_exe := exe
var spawn_args := args
if OS.get_name() == "Windows":
var lower := exe.to_lower()
if lower.ends_with(".cmd") or lower.ends_with(".bat"):
## CreateProcessW can't launch `.cmd` / `.bat` scripts on its
## own — they're cmd.exe input, not PE binaries. Without this
## wrap, the moment `McpCliFinder` resolves a Node-style shim
## (npm's `claude.cmd`, pnpm's wrappers, …) the next
## `OS.execute_with_pipe` surfaces "Could not create child
## process: <path> ..." in Godot's output log (#251). Passing
## `exe` as a separate argv element keeps spaces in the path
## quoted by Godot's standard quoter — no manual escaping.
spawn_exe = "cmd.exe"
spawn_args = ["/c", exe]
spawn_args.append_array(args)
var info := OS.execute_with_pipe(spawn_exe, spawn_args)
if info.is_empty():
return _spawn_failed_result()
var pid: int = int(info.get("pid", -1))
var stdio: Variant = info.get("stdio", null)
var stderr_pipe: Variant = info.get("stderr", null)
if pid <= 0:
_close_pipes(stdio, stderr_pipe)
return _spawn_failed_result()
var deadline := Time.get_ticks_msec() + maxi(timeout_ms, _POLL_INTERVAL_MS)
while OS.is_process_running(pid):
if Time.get_ticks_msec() >= deadline:
## Kill before draining: a pipe read can block while the child is
## still alive. Once it exits, drain any buffered partial output.
OS.kill(pid)
var kill_deadline := Time.get_ticks_msec() + _KILL_GRACE_MS
while OS.is_process_running(pid) and Time.get_ticks_msec() < kill_deadline:
OS.delay_msec(_POLL_INTERVAL_MS)
var partial_stdout := ""
var partial_stderr := ""
if not OS.is_process_running(pid):
partial_stdout = _drain_pipe(stdio)
partial_stderr = _drain_pipe(stderr_pipe) if capture_stderr else ""
_close_pipes(stdio, stderr_pipe)
return {
"exit_code": -1,
"stdout": partial_stdout,
"stderr": partial_stderr,
"output": _join_streams(partial_stdout, partial_stderr),
"timed_out": true,
"spawn_failed": false,
}
OS.delay_msec(_POLL_INTERVAL_MS)
var stdout := _drain_pipe(stdio)
var stderr_text := _drain_pipe(stderr_pipe) if capture_stderr else ""
_close_pipes(stdio, stderr_pipe)
return {
"exit_code": OS.get_process_exit_code(pid),
"stdout": stdout,
"stderr": stderr_text,
"output": _join_streams(stdout, stderr_text),
"timed_out": false,
"spawn_failed": false,
}
static func _spawn_failed_result() -> Dictionary:
return {
"exit_code": -1,
"stdout": "",
"stderr": "",
"output": "",
"timed_out": false,
"spawn_failed": true,
}
static func _drain_pipe(pipe: Variant) -> String:
if not (pipe is FileAccess):
return ""
var f := pipe as FileAccess
var bytes := PackedByteArray()
var max_bytes := 1 << 20 # 1 MiB, far above expected client CLI output.
while bytes.size() < max_bytes:
var chunk := f.get_buffer(mini(4096, max_bytes - bytes.size()))
if chunk.is_empty():
break
bytes.append_array(chunk)
if f.eof_reached():
break
return bytes.get_string_from_utf8()
static func _join_streams(stdout: String, stderr_text: String) -> String:
## Most CLIs write their actionable diagnostics to one stream or the
## other, never both — so concatenation gives "the message" without
## the caller having to guess which key to read. Newline-separate so
## callers that grep don't see two lines run together.
if stderr_text.is_empty():
return stdout
if stdout.is_empty():
return stderr_text
return "%s\n%s" % [stdout, stderr_text]
static func _close_pipes(stdio: Variant, stderr_pipe: Variant) -> void:
if stdio is FileAccess:
(stdio as FileAccess).close()
if stderr_pipe is FileAccess:
(stderr_pipe as FileAccess).close()
+1
View File
@@ -0,0 +1 @@
uid://dhoe3ypkhm12v
+181
View File
@@ -0,0 +1,181 @@
@tool
class_name McpCliFinder
extends RefCounted
## Generic three-tier CLI resolution for clients whose binary lives somewhere
## a GUI-launched Godot's minimal PATH won't see:
## 1. Well-known install locations (~/.local/bin, /opt/homebrew/bin, ...)
## 2. Login shell lookup (`bash -lc 'command -v <exe>'`) — picks up .zshrc / .bashrc
## 3. Plain `which` / `where` against the inherited PATH
## Caches per-exe so repeated dock refreshes don't fork a shell every frame.
##
## Thread safety: `find()` runs on action-worker threads
## (`_run_client_action_worker` in `mcp_dock.gd`), and `invalidate()` runs on
## the main thread (manual Refresh path). Godot `Dictionary` is not safe for
## concurrent mutation, so `_cache` / `_searched` access is guarded by
## `_mutex`. The mutex is held only across dictionary read/write — the slow
## `_resolve()` path (FileAccess + bounded subprocess lookup) runs unlocked, so a
## main-thread `invalidate()` can never block on a worker's subprocess.
## Two workers racing the same exe both call `_resolve()` and both write
## back the same answer; that's wasted work, not corruption.
static var _mutex: Mutex = Mutex.new()
static var _cache: Dictionary = {} # exe_name -> resolved path (or "")
static var _searched: Dictionary = {}
const _LOOKUP_TIMEOUT_MS := 3000
## Find any of the supplied exe names; returns the first hit.
## On Windows pass the .exe variant in `exe_names` if relevant.
static func find(exe_names: Array[String]) -> String:
for name in exe_names:
var hit := _find_one(name)
if not hit.is_empty():
return hit
return ""
## Drop cache for one exe (call after the user installs / reinstalls).
static func invalidate(exe_name: String = "") -> void:
_mutex.lock()
if exe_name.is_empty():
_cache.clear()
_searched.clear()
else:
_cache.erase(exe_name)
_searched.erase(exe_name)
_mutex.unlock()
static func _find_one(exe_name: String) -> String:
_mutex.lock()
var already_searched: bool = _searched.get(exe_name, false)
var cached: String = _cache.get(exe_name, "")
_mutex.unlock()
if already_searched:
return cached
# `_resolve()` does FileAccess + bounded subprocess lookup (forks
# `bash -lc` / `which`), which can take 100ms-1s. Holding the mutex across that
# would let a concurrent `invalidate()` on the main thread freeze the
# editor for the duration of the subprocess — which defeats the whole
# point of running CLI lookup off the main thread.
var hit := _resolve(exe_name)
_mutex.lock()
_cache[exe_name] = hit
_searched[exe_name] = true
_mutex.unlock()
return hit
static func _resolve(exe_name: String) -> String:
var is_windows := OS.get_name() == "Windows"
# 1. Well-known locations
for dir in _well_known_dirs():
var full := dir.path_join(exe_name)
if FileAccess.file_exists(full):
return full
# 2. Login shell lookup (Unix only)
if not is_windows:
## env_lookup, not OS.get_environment: CLI resolution runs on dock
## worker threads (configure/remove actions) and must not race the
## spawn window's setenv/unsetenv (#691).
var shell := McpPathTemplate.env_lookup("SHELL")
if shell.is_empty():
shell = "/bin/bash"
var stripped := exe_name.trim_suffix(".exe")
var login_result := McpCliExec.run(shell, ["-lc", "command -v %s" % stripped], _LOOKUP_TIMEOUT_MS, false)
if int(login_result.get("exit_code", -1)) == 0:
var login_found: String = str(login_result.get("stdout", "")).strip_edges()
if not login_found.is_empty() and FileAccess.file_exists(login_found):
return login_found
# 3. which / where with inherited PATH
var lookup := "where" if is_windows else "which"
var result := McpCliExec.run(lookup, [exe_name], _LOOKUP_TIMEOUT_MS, false)
if int(result.get("exit_code", -1)) == 0:
var output := str(result.get("stdout", ""))
var lines := PackedStringArray(output.split("\n"))
var found := _pick_best_path(lines) if is_windows else lines[0].strip_edges()
if not found.is_empty():
return found
return ""
## Executable extensions Windows' CreateProcessW can launch from a path
## (after the cmd.exe wrap in `_cli_exec.gd`). Order is preference: `.exe`
## is a native PE binary; `.cmd` / `.bat` go through the shell; `.com` is
## the legacy COM-format executable that some shims still ship.
const _WINDOWS_EXEC_EXTS := [".exe", ".cmd", ".bat", ".com"]
## Pick the best path from `where` output on Windows.
##
## npm-installed Node CLIs ship as BOTH `<dir>/<name>` (a POSIX bash shim
## for WSL / Git Bash users) AND `<dir>/<name>.cmd` (the actual Windows
## wrapper). `where <name>` lists both. CreateProcessW — the underlying
## syscall behind `OS.execute_with_pipe` — refuses to launch the
## extensionless POSIX shim, surfacing as
## `ERROR: Could not create child process: "...\claude" mcp list`
## in Godot's output log (#251). Picking a path with a real executable
## extension dodges that entirely.
##
## Extension scan is the OUTER loop so the order in `_WINDOWS_EXEC_EXTS`
## drives preference — `.exe` wins over `.cmd` even when the `.cmd` shows
## up first in `where` output (one fewer process per shell-out). Falls
## back to the first non-empty line when no entry has a recognised
## extension, so we never come up empty when `where` returned *something*.
static func _pick_best_path(lines: PackedStringArray) -> String:
var stripped := PackedStringArray()
for raw in lines:
var line := raw.strip_edges()
if not line.is_empty():
stripped.append(line)
if stripped.is_empty():
return ""
for ext in _WINDOWS_EXEC_EXTS:
for candidate in stripped:
if candidate.to_lower().ends_with(ext):
return candidate
return stripped[0]
static func _well_known_dirs() -> Array[String]:
## env_lookup, not OS.get_environment — see _resolve()'s worker-thread
## note (#691).
var home := McpPathTemplate.env_lookup("HOME")
if home.is_empty():
home = McpPathTemplate.env_lookup("USERPROFILE")
match OS.get_name():
"macOS":
return [
home.path_join(".local/bin"),
home.path_join(".claude/local"),
home.path_join(".cargo/bin"),
"/opt/homebrew/bin",
"/usr/local/bin",
]
"Windows":
var local := McpPathTemplate.env_lookup("LOCALAPPDATA")
var prog := McpPathTemplate.env_lookup("ProgramFiles")
var paths: Array[String] = []
if not home.is_empty():
paths.append(home.path_join(".claude/local"))
paths.append(home.path_join(".local/bin"))
paths.append(home.path_join(".cargo/bin"))
paths.append(home.path_join("AppData/Local/Programs/uv"))
if not local.is_empty():
paths.append(local.path_join("Programs/uv"))
if not prog.is_empty():
paths.append(prog.path_join("uv"))
return paths
_:
return [
home.path_join(".local/bin"),
home.path_join(".claude/local"),
home.path_join(".cargo/bin"),
"/usr/local/bin",
]
@@ -0,0 +1 @@
uid://cnp5b6fcwou2y
+372
View File
@@ -0,0 +1,372 @@
@tool
class_name McpCliStrategy
extends RefCounted
## Strategy for MCP clients that own their own state via a CLI (e.g.
## `claude mcp add`). Reads `cli_register_template` / `cli_unregister_template`
## / `cli_status_args` from the descriptor and substitutes `{name}` / `{url}`
## tokens. Command-shape descriptors additionally use the whole-element launch
## tokens `{command}` / `{args...}` (see `format_args`). No descriptor-supplied
## Callables — see `_base.gd` for why.
##
## Every shell-out goes through `McpCliExec.run`, which wraps the call in a
## wall-clock timeout. A hung CLI (e.g. `claude mcp list` under
## inter-Claude-Code contention) gets killed at the budget instead of
## locking up the caller forever — see issues #238 / #239.
## The descriptor token resolved from `godot_ai/mcp_client_scope` (#872).
## Only Claude Code takes it today; any `config_type = "cli"` descriptor can.
const SCOPE_TOKEN := "{scope}"
const _CONFIGURE_TIMEOUT_MS := 10000
const _REMOVE_TIMEOUT_MS := 10000
const _STATUS_TIMEOUT_MS := 6000
static func configure(
client: McpClient,
server_name: String,
server_url: String,
launch: Dictionary = {},
) -> Dictionary:
## Fail closed before any subprocess runs: a command-shape client without a
## verified attach launcher must not register anything (see
## docs/client-configuration.md — an ERROR beats an entry known to be broken).
var launch_error := command_launch_error(client, launch)
if not launch_error.is_empty():
return {"status": "error", "message": launch_error}
var cli := _resolve_cli(client)
if cli.is_empty():
return {"status": "error", "message": "%s not found" % client.display_name}
# Best-effort prior cleanup so re-configure is idempotent. Bounded to
# the same budget — a hung unregister shouldn't block the configure
# that follows.
if not client.cli_unregister_template.is_empty():
## #872: a `{scope}` descriptor sweeps EVERY scope, not just the
## selected one. Flipping the setting user -> project and pressing
## Configure would otherwise leave the old user-scope entry alive —
## exactly the "loaded in every unrelated workspace" problem the
## setting exists to fix. `mcp remove` on an absent entry is a no-op
## and the result is discarded either way, so the extra passes are
## safe; they cost one bounded subprocess per additional scope.
for pre_scope in _cleanup_scopes(client):
var pre_args := _format_args(
client.cli_unregister_template, server_name, server_url, {}, pre_scope
)
McpCliExec.run(cli, pre_args, _REMOVE_TIMEOUT_MS)
if client.cli_register_template.is_empty():
return {"status": "error", "message": "%s descriptor missing cli_register_template" % client.display_name}
var args := _format_args(client.cli_register_template, server_name, server_url, launch)
var result := McpCliExec.run(cli, args, _CONFIGURE_TIMEOUT_MS)
if result.get("timed_out", false):
return {
"status": "error",
"message": "Configure %s timed out after %ds — see 'Run this manually' below to retry by hand" % [
client.display_name, _CONFIGURE_TIMEOUT_MS / 1000,
],
}
if result.get("spawn_failed", false):
return {"status": "error", "message": "Failed to spawn %s" % client.display_name}
if int(result.get("exit_code", -1)) == 0:
return {"status": "ok", "message": McpClient.configured_message(client, server_url)}
## `claude mcp add` writes its real failure diagnostics to stderr, so
## prefer `output` (stdout + stderr) over `stdout` alone — otherwise
## the user sees "exit code 1" instead of the actual error.
var combined := str(result.get("output", "")).strip_edges()
var err := combined if not combined.is_empty() else "exit code %d" % int(result.get("exit_code", -1))
return {"status": "error", "message": "Failed to configure %s: %s" % [client.display_name, err]}
## Run the descriptor's `cli_status_args`, scan stdout for `server_name` and
## the expected target. The matching rule is the only sensible one for "list
## MCP entries" output across CLI clients we currently support: name AND
## target present → CONFIGURED; name only → MISMATCH; neither →
## NOT_CONFIGURED. For URL descriptors the target is `server_url`; for
## command-shape descriptors it is the resolved attach launcher path (the
## listing prints the registered command line, not a URL). Command-shape CLI
## clients with a JSON fallback file get exact drift detection via the JSON
## strategy instead — the configurator prefers that path and only lands here
## for CLI clients whose state isn't file-readable.
static func check_status(
client: McpClient, server_name: String, server_url: String, launch: Dictionary = {}
) -> McpClient.Status:
return check_status_with_cli_path(client, server_name, server_url, _resolve_cli(client), launch)
static func check_status_with_cli_path(
client: McpClient, server_name: String, server_url: String, cli: String, launch: Dictionary = {}
) -> McpClient.Status:
return check_status_details(client, server_name, server_url, cli, launch).get("status", McpClient.Status.NOT_CONFIGURED)
## Detailed variant used by the dock's refresh worker so it can surface a
## "probe timed out" badge on the affected row instead of silently
## conflating the timeout with NOT_CONFIGURED. Returns
## `{"status": Status, "error_msg": String}`. The caller plumbs
## `error_msg` straight into `_apply_row_status`.
static func check_status_details(
client: McpClient, server_name: String, server_url: String, cli: String, launch: Dictionary = {}
) -> Dictionary:
if cli.is_empty():
return _status_details(McpClient.Status.NOT_CONFIGURED)
if client.cli_status_args.is_empty():
return _status_details(McpClient.Status.NOT_CONFIGURED)
var expected_target := server_url
if client.command_shape != McpClient.CommandShape.NONE:
## Same fail-closed contract as configure: without a verified launcher
## there is no target to compare against, and guessing would report a
## broken entry as green.
var launch_error := command_launch_error(client, launch)
if not launch_error.is_empty():
return _status_details(McpClient.Status.ERROR, launch_error)
expected_target = str(launch.get("command", ""))
var result := McpCliExec.run(
cli,
McpClient._array_from_packed(client.cli_status_args),
_STATUS_TIMEOUT_MS,
false
)
if result.get("timed_out", false):
return _status_details(McpClient.Status.ERROR, "probe timed out")
if result.get("spawn_failed", false):
return _status_details(McpClient.Status.NOT_CONFIGURED)
if int(result.get("exit_code", -1)) != 0:
return _status_details(McpClient.Status.NOT_CONFIGURED)
var text := str(result.get("stdout", ""))
if text.find(server_name) < 0:
return _status_details(McpClient.Status.NOT_CONFIGURED)
## Server registered, but pointing somewhere else — drift after a
## port change. Surface as mismatch so the dock offers Reconfigure.
if text.find(expected_target) < 0:
return _status_details(McpClient.Status.CONFIGURED_MISMATCH)
return _status_details(McpClient.Status.CONFIGURED)
## Empty string when this client's launch requirements are satisfied. A
## command-shape descriptor requires a successfully resolved attach launch;
## URL descriptors (`CommandShape.NONE`) never require one. Mirrors
## `McpJsonStrategy.command_launch_error` / the TOML equivalent.
static func command_launch_error(client: McpClient, launch: Dictionary) -> String:
if client.command_shape == McpClient.CommandShape.NONE:
return ""
if not bool(launch.get("ok", false)):
return str(launch.get("error", "No compatible attach launcher was found."))
return ""
static func _status_details(status: McpClient.Status, error_msg: String = "") -> Dictionary:
return {"status": status, "error_msg": error_msg}
static func remove(client: McpClient, server_name: String) -> Dictionary:
var cli := _resolve_cli(client)
if cli.is_empty():
return {"status": "error", "message": "%s not found" % client.display_name}
if client.cli_unregister_template.is_empty():
return {"status": "error", "message": "%s descriptor missing cli_unregister_template" % client.display_name}
var args := _format_args(client.cli_unregister_template, server_name, "")
var result := McpCliExec.run(cli, args, _REMOVE_TIMEOUT_MS)
if result.get("timed_out", false):
return {
"status": "error",
"message": "Remove %s timed out after %ds — see 'Run this manually' below to retry by hand" % [
client.display_name, _REMOVE_TIMEOUT_MS / 1000,
],
}
if result.get("spawn_failed", false):
return {"status": "error", "message": "Failed to spawn %s" % client.display_name}
if int(result.get("exit_code", -1)) == 0:
return {"status": "ok", "message": "%s configuration removed" % client.display_name}
## `claude mcp add` writes its real failure diagnostics to stderr, so
## prefer `output` (stdout + stderr) over `stdout` alone — otherwise
## the user sees "exit code 1" instead of the actual error.
var combined := str(result.get("output", "")).strip_edges()
var err := combined if not combined.is_empty() else "exit code %d" % int(result.get("exit_code", -1))
return {"status": "error", "message": "Failed to remove %s: %s" % [client.display_name, err]}
## Substitute `{name}` and `{url}` tokens in every template entry.
## Tokens match verbatim — `{name_suffix}` is NOT touched, so callers don't
## have to worry about partial-token collisions in their argv.
##
## Launch tokens are whole-element only: an element that is exactly
## `{command}` becomes the resolved attach launcher path, and an element that
## is exactly `{args...}` is spliced into the argv as one element per launch
## arg. Whole-element matching keeps a literal brace inside a path or flag
## from ever triggering an expansion.
##
## `scope_override` forces a specific `{scope}` value instead of the live
## setting; the configure pre-cleanup uses it to sweep the scopes the user is
## NOT currently on. Empty means "resolve from the setting".
static func format_args(
template: PackedStringArray,
server_name: String,
server_url: String,
launch: Dictionary = {},
scope_override: String = "",
) -> Array[String]:
return _format_args(template, server_name, server_url, launch, scope_override)
static func _format_args(
template: PackedStringArray,
server_name: String,
server_url: String,
launch: Dictionary = {},
scope_override: String = "",
) -> Array[String]:
var scope := scope_override if not scope_override.is_empty() else McpSettings.client_scope()
var out: Array[String] = []
for arg in template:
var s := String(arg)
if s == "{command}":
out.append(str(launch.get("command", "")))
continue
if s == "{args...}":
for launch_arg in launch.get("args", []):
out.append(str(launch_arg))
continue
s = s.replace("{name}", server_name)
s = s.replace("{url}", server_url)
## Resolved per-call rather than baked into the descriptor so the
## setting can change without an editor restart, and so the manual
## command shown in the dock always matches what Configure would run.
s = s.replace(SCOPE_TOKEN, scope)
out.append(s)
return out
## Scope-aware status for descriptors that declare `cli_scope_status_template`.
## Returns the same `{"status", "error_msg"}` shape as check_status_details.
##
## Split from the subprocess call so the verdict is a pure function of what the
## CLI printed — `_scope_probe_verdict` is unit-tested against real recorded
## `claude mcp get` output rather than needing a `claude` binary on the runner.
static func check_scope_status_details(
client: McpClient,
server_name: String,
server_url: String,
cli: String,
launch: Dictionary,
expected_scope: String,
) -> Dictionary:
if cli.is_empty() or client.cli_scope_status_template.is_empty():
return _status_details(McpClient.Status.NOT_CONFIGURED)
var expected_target := server_url
if client.command_shape != McpClient.CommandShape.NONE:
## Same fail-closed contract as configure and the plain status probe.
var launch_error := command_launch_error(client, launch)
if not launch_error.is_empty():
return _status_details(McpClient.Status.ERROR, launch_error)
expected_target = str(launch.get("command", ""))
var args := _format_args(client.cli_scope_status_template, server_name, server_url)
var result := McpCliExec.run(cli, args, _STATUS_TIMEOUT_MS, false)
if result.get("timed_out", false):
return _status_details(McpClient.Status.ERROR, "probe timed out")
if result.get("spawn_failed", false):
return _status_details(McpClient.Status.NOT_CONFIGURED)
return _scope_probe_verdict(
int(result.get("exit_code", -1)),
str(result.get("stdout", "")),
expected_scope,
expected_target,
)
## The `Scope:` label the probe printed, normalised to a CLIENT_SCOPES value,
## or "" when no line was recognisable. Matched on the first word after the
## label so the parenthetical blurb ("(shared via .mcp.json)") can change
## wording between CLI releases without breaking detection.
static func _scope_from_probe_output(text: String) -> String:
for raw_line in text.split("\n"):
var line := String(raw_line).strip_edges()
var marker := line.find("Scope:")
if marker < 0:
continue
var rest := line.substr(marker + 6).strip_edges().to_lower()
for scope in McpSettings.CLIENT_SCOPES:
var name := String(scope)
if rest.begins_with(name):
return name
return ""
## Pure verdict for a scope probe. A non-zero exit is the CLI's "no such
## server" signal. An entry resolved from a scope other than the selected one
## is MISMATCH, not CONFIGURED: the dock offers Reconfigure, and Configure's
## all-scope pre-cleanup is what actually moves it.
##
## A missing or unrecognised `Scope:` line degrades to the target check alone,
## deliberately rather than failing closed. `_verify_post_state` turns any
## non-CONFIGURED status after a successful write into an error, so treating an
## unparsed label as MISMATCH would mean a future CLI release that reworded or
## dropped that line breaks Configure outright — a false error plus a
## permanently amber row whose Reconfigure button cannot clear it. The entry
## has already been matched by name and exact launcher path at that point; the
## only thing in doubt is which scope it came from, and a slightly optimistic
## dot is a much cheaper failure than a configure flow that reports success as
## failure. Today's claude (2.1.241) always prints the line, so this is a
## forward-compatibility hedge, not a live code path.
static func _scope_probe_verdict(
exit_code: int, text: String, expected_scope: String, expected_target: String
) -> Dictionary:
if exit_code != 0:
return _status_details(McpClient.Status.NOT_CONFIGURED)
## `resolved_scope` rides along as structured data so callers can act on it
## — `_verify_post_state`'s path hint needs to know WHICH scope survived to
## name the right file, and parsing it back out of the human-facing message
## would couple that hint to this wording (#879). Parsed BEFORE the target
## check so both mismatch paths carry it: an entry whose launcher drifted
## sends the user to the wrong file just as readily as one whose scope did.
var resolved := _scope_from_probe_output(text)
if not expected_target.is_empty() and text.find(expected_target) < 0:
var drifted := _status_details(McpClient.Status.CONFIGURED_MISMATCH)
if not resolved.is_empty():
drifted["resolved_scope"] = resolved
return drifted
if resolved.is_empty():
return _status_details(McpClient.Status.CONFIGURED)
if resolved != expected_scope:
var details := _status_details(
McpClient.Status.CONFIGURED_MISMATCH,
"registered at %s scope, not %s" % [resolved, expected_scope],
)
details["resolved_scope"] = resolved
return details
return _status_details(McpClient.Status.CONFIGURED)
## True when the descriptor's register template takes `{scope}`, i.e. where
## its entry lands is decided by the `godot_ai/mcp_client_scope` setting rather
## than fixed by the descriptor. The configurator uses this to decide whether
## the JSON-fallback file is still a valid place to read status back from.
static func uses_scope_token(client: McpClient) -> bool:
return client.cli_register_template.has(SCOPE_TOKEN)
## Public view of the pre-cleanup sweep, for the manual-command text: what the
## dock tells the user to run has to match what Configure actually runs (#877).
static func cleanup_scopes(client: McpClient) -> Array[String]:
return _cleanup_scopes(client)
## Scopes the configure pre-cleanup removes from. A descriptor without the
## `{scope}` token has exactly one place its entry can live, so it keeps the
## single pass it always had — "" means "resolve the scope normally".
static func _cleanup_scopes(client: McpClient) -> Array[String]:
if not uses_scope_token(client):
return [""]
var scopes: Array[String] = []
for scope in McpSettings.CLIENT_SCOPES:
scopes.append(String(scope))
return scopes
static func _resolve_cli(client: McpClient) -> String:
return McpCliFinder.find(McpClient._array_from_packed(client.cli_names))
static func resolve_cli_path(client: McpClient) -> String:
return _resolve_cli(client)
@@ -0,0 +1 @@
uid://bvib7d8eabbcm
+759
View File
@@ -0,0 +1,759 @@
@tool
class_name McpDshStrategy
extends RefCounted
## Configure / status / remove for DeepSeek Harness (dsh) MCP servers.
##
## DeepSeek Harness has no `mcp` CLI verb (verified against dsh
## 0.1.0-rc.6 — `dsh --help` exposes only `web` and `plugin`). MCP servers
## register as `@deepseek-ai/dsh-mcp-client` plugin entries in the HOME patch
## layer `$DSH_HOME/cordis.patch.yml` — the machine-local layer dsh applies
## over EVERY profile's own patch layer (the web GUI included), so one entry
## covers every profile the user boots.
##
## The patch file is a top-level YAML array of loader patch entries
## (`@deepseek-ai/cordis-plugin-include`'s `applyEntryPatches`). A non-insert
## row only OVERRIDES an existing bundle row (a new id is skipped with a
## warning), so a brand-new server must be added with an `insert` row:
##
## - insert:
## - id: mcp-godot-ai
## name: '@deepseek-ai/dsh-mcp-client'
## config:
## serverName: godot-ai
## transport: stdio
## command: "..."
## args: [...]
##
## Both shapes verified live against the dsh loader
## (`dsh --profile web --dump-config` composes the inserted entry; a plain
## row for a new id warns "patch: entry not found" and is skipped). We write
## one dedicated insert row per server, preserve every other row byte-for-byte
## (including other users' insert rows, overrides, comments, and `!!js`
## expressions), and status-verify the entry we wrote — the same file-read
## drift contract the JSON/TOML/YAML strategies use.
##
## The entry's `config` nests the launch under `serverName`/`transport`/
## `command`/`args`; `serverName` is the model-facing tool namespace
## (`mcp__godot-ai__<tool>`), so it carries the server name like the
## `server_key_path` map key does for the other strategies.
## Loader entry id prefix inside the patch list. The full id is
## `<prefix><server_name>` — "mcp-godot-ai" for the godot-ai server.
const ENTRY_ID_PREFIX := "mcp-"
## The loader plugin that bridges MCP servers into the harness. Must be
## installed in the profile's node_modules (dsh ships it as a dependency of
## the CLI package). Quoted in the file: `@` is a reserved YAML indicator and
## cannot start a plain scalar.
const PLUGIN_NAME := "@deepseek-ai/dsh-mcp-client"
## Indent of a nested entry header under `- insert:` (children step 2 spaces
## deeper), matching the shipped profile patch files.
const ENTRY_INDENT := 4
static func entry_id(server_name: String) -> String:
return ENTRY_ID_PREFIX + server_name
static func configure(
client: McpClient,
server_name: String,
server_url: String,
launch: Dictionary = {},
) -> Dictionary:
var resolution := client.resolved_config_path_details()
var path := str(resolution.get("path", ""))
var path_error := str(resolution.get("error", ""))
if not path_error.is_empty():
return {"status": "error", "message": path_error}
if path.is_empty():
return {"status": "error", "message": "Could not resolve config path for %s on this OS" % client.display_name}
## Fail closed before touching the file — same contract as JSON/TOML/YAML.
var launch_error := command_launch_error(client, launch)
if not launch_error.is_empty():
return {"status": "error", "message": launch_error}
var read := _read(path)
if not read["ok"]:
return {"status": "error", "message": "Refusing to overwrite %s: %s. Fix or move the file, then re-run Configure." % [path, read["error"]]}
## Fail closed on content that is not a loader patch list: dsh's patch
## parser rejects a non-array file at boot, so appending our row to a
## top-level mapping would hand the user a broken file (#867 review).
## `[]` is a valid empty sequence meaning "no rows"; the literal must not
## survive an append (block rows after `[]` are malformed YAML), so only
## that line is dropped — comments around it belong to the user and stay.
var text := String(read["data"])
text = _strip_empty_flow_sequence(text)
if not _is_patch_sequence(text):
return {
"status": "error",
"message": "Refusing to overwrite %s: it is not a top-level YAML list of loader entries. Fix or remove the file, then re-run Configure." % path,
}
var existing_config: Variant = null
var lines := _split_lines(text)
var found := _find_entry_block(lines, entry_id(server_name), true)
if found.is_empty():
var plain := _find_entry_block(lines, entry_id(server_name), false)
if not plain.is_empty():
## A plain `- id: mcp-godot-ai` row is inert for a new id — the
## loader skips it with a warning and never registers the server.
## Replacing the whole row with the insert form migrates it.
found = plain
if not found.is_empty():
## Pass the parsed config through so user-mutable fields (env,
## toolCallTimeoutMs, reconnect) survive a reconfigure.
existing_config = _extract_config(lines, found)
var new_entry := build_entry(client, server_name, server_url, existing_config, launch)
## Nested replacement must keep the sequence's existing header indent —
## items of one block sequence share indentation, so a hand-written
## 2-space file re-emitted at 4 spaces would no longer parse (#867
## review). Fresh rows and plain-row migrations keep the 4-space style.
var base_indent := ENTRY_INDENT
if not found.is_empty():
var header_indent := int(found.get("indent", 0))
if header_indent > 0:
base_indent = header_indent
var rendered := render_nested_entry(entry_id(server_name), new_entry, base_indent)
var out := ""
if found.is_empty():
out = _append_row(lines, rendered)
else:
out = _replace_lines(lines, found, rendered)
if not McpAtomicWrite.write(path, out):
return {"status": "error", "message": "Cannot write to %s" % path}
return {"status": "ok", "message": McpClient.configured_message(client, server_url)}
static func check_status(
client: McpClient,
server_name: String,
server_url: String,
launch: Dictionary = {},
) -> McpClient.Status:
return check_status_details(client, server_name, server_url, launch)["status"]
## Same contract as the other strategies (#711): {status, error_msg}. An
## existing-but-unreadable config is ERROR with the diagnostic — not
## NOT_CONFIGURED — so the dock row can tell "no config" from "config the
## editor can't read" instead of offering a Configure that would fail.
static func check_status_details(
client: McpClient, server_name: String, server_url: String, launch: Dictionary = {}
) -> Dictionary:
var resolution := client.resolved_config_path_details()
var path := str(resolution.get("path", ""))
var path_error := str(resolution.get("error", ""))
if not path_error.is_empty():
return {"status": McpClient.Status.ERROR, "error_msg": path_error}
if path.is_empty() or not FileAccess.file_exists(path):
return {"status": McpClient.Status.NOT_CONFIGURED, "error_msg": ""}
var read := _read(path)
if not read["ok"]:
return {
"status": McpClient.Status.ERROR,
"error_msg": "Cannot read %s: %s" % [path, read["error"]],
}
var text := String(read["data"])
if not _is_patch_sequence(text):
## A top-level mapping/scalar is not a dsh patch list: nothing in it
## can be "configured", and a nested `- id:` inside a mapping must
## not read as ours (#867 review).
return {
"status": McpClient.Status.ERROR,
"error_msg": "%s is not a top-level YAML list of loader entries; fix or remove the file." % path,
}
var lines := _split_lines(text)
var found := _find_entry_block(lines, entry_id(server_name), true)
if found.is_empty():
## A plain row for our id is inert (the loader skips unknown ids), so
## the server is NOT registered no matter what the file says. It only
## becomes registered once Configure rewrites it as an insert row.
return {"status": McpClient.Status.NOT_CONFIGURED, "error_msg": ""}
var config := _extract_config(lines, found)
if config.is_empty():
return {"status": McpClient.Status.NOT_CONFIGURED, "error_msg": ""}
## An entry exists but no verified launcher does — mirror the other
## strategies: this is an environment ERROR, not entry drift.
var launch_error := command_launch_error(client, launch)
if not launch_error.is_empty():
return {"status": McpClient.Status.ERROR, "error_msg": launch_error}
if verify_entry(client, config, server_name, server_url, launch):
return {"status": McpClient.Status.CONFIGURED, "error_msg": ""}
return {"status": McpClient.Status.CONFIGURED_MISMATCH, "error_msg": ""}
static func remove(client: McpClient, server_name: String) -> Dictionary:
var resolution := client.resolved_config_path_details()
var path := str(resolution.get("path", ""))
var path_error := str(resolution.get("error", ""))
if not path_error.is_empty():
return {"status": "error", "message": path_error}
if path.is_empty() or not FileAccess.file_exists(path):
return {"status": "ok", "message": "Not configured"}
var read := _read(path)
if not read["ok"]:
return {"status": "error", "message": "Refusing to rewrite %s: %s." % [path, read["error"]]}
var text := String(read["data"])
if not _is_patch_sequence(text):
## Same fail-closed contract as configure (#867 review): never modify
## a file that dsh cannot load as a patch list.
return {
"status": "error",
"message": "Refusing to rewrite %s: it is not a top-level YAML list of loader entries. Fix or remove the file, then re-run Remove." % path,
}
var lines := _split_lines(text)
var found := _find_entry_block(lines, entry_id(server_name), true)
if found.is_empty():
found = _find_entry_block(lines, entry_id(server_name), false)
if found.is_empty():
return {"status": "ok", "message": "%s configuration removed" % client.display_name}
var out := _remove_lines(lines, found)
if _is_blank_or_comment_only(out):
## dsh's patch parser requires a top-level YAML array: an empty or
## comment-only patch file FAILS BOOT (`loadOptionalPatches` throws on
## a non-array file), while an absent file means "no layer". After our
## row is gone, an all-blank file must therefore be deleted, not
## written back.
if FileAccess.file_exists(path):
var remove_err := DirAccess.remove_absolute(path)
if remove_err != OK:
return {"status": "error", "message": "Cannot remove %s (error %d)" % [path, remove_err]}
return {"status": "ok", "message": "%s configuration removed" % client.display_name}
if not McpAtomicWrite.write(path, out):
return {"status": "error", "message": "Cannot write to %s" % path}
return {"status": "ok", "message": "%s configuration removed" % client.display_name}
## Build the `config` dict written inside the loader entry. Command mode
## (FLAT, #838) nests `serverName`/`transport`/`command`/`args`; URL mode nests
## `serverName`/`transport`/`url`. Existing user state (env, timeouts,
## reconnect) survives a reconfigure via the deep copy.
static func build_entry(
client: McpClient,
server_name: String,
server_url: String,
existing: Variant = null,
launch: Dictionary = {},
) -> Dictionary:
var entry: Dictionary = (existing as Dictionary).duplicate(true) if existing is Dictionary else {}
entry["serverName"] = server_name
if client.command_shape == McpClient.CommandShape.FLAT:
entry["command"] = str(launch.get("command", ""))
entry["args"] = _array_copy(launch.get("args", []))
if not client.command_transport_key.is_empty():
entry[client.command_transport_key] = client.command_transport_value
for key in client.command_initial_fields:
if not entry.has(key):
entry[key] = client.command_initial_fields[key]
for key in client.command_legacy_keys:
entry.erase(String(key))
return entry
if client.command_shape == McpClient.CommandShape.NONE:
## Single URL-mode source of truth (#867 review): build_entry's URL
## branch delegates to build_url_entry so the two can never drift.
return build_url_entry(client, server_name, server_url, existing)
return {}
## URL-mode sibling for the manual-instruction fallback text and the
## build_entry URL branch. DeepSeek Harness' mcp-client bridge accepts
## `transport: streamable-http` with a `url`, so the entry keeps the same
## `serverName` namespace with an HTTP transport instead of the stdio attach
## bridge. Existing user state survives via the deep copy.
static func build_url_entry(
client: McpClient, server_name: String, server_url: String, existing: Variant = null
) -> Dictionary:
var entry: Dictionary = (existing as Dictionary).duplicate(true) if existing is Dictionary else {}
entry["serverName"] = server_name
entry["transport"] = "streamable-http"
if not client.entry_url_field.is_empty():
entry[client.entry_url_field] = server_url
return entry
## Verify a stored `config` dict matches the current launch. Command mode
## checks every launch-affecting value exactly and requires legacy URL keys
## gone; serverName must match the pinned namespace. URL mode checks the url
## and the streamable-http pin.
static func verify_entry(
client: McpClient,
config: Dictionary,
server_name: String,
server_url: String,
launch: Dictionary = {},
) -> bool:
if config.get("serverName") != server_name:
return false
if client.command_shape != McpClient.CommandShape.NONE:
if client.command_shape != McpClient.CommandShape.FLAT or not bool(launch.get("ok", false)):
return false
for key in client.command_legacy_keys:
if config.has(String(key)):
return false
if config.get("command") != launch.get("command"):
return false
if not _arrays_equal(config.get("args", null), launch.get("args", null)):
return false
if not client.command_transport_key.is_empty():
if config.get(client.command_transport_key, null) != client.command_transport_value:
return false
return true
if config.get(client.entry_url_field, "") != server_url:
return false
return config.get("transport", "") == "streamable-http"
## Empty string when this client's launch requirements are satisfied.
## Mirrors `McpYamlStrategy.command_launch_error`; the patch-list dialect
## supports FLAT (and the URL fallback) only.
static func command_launch_error(client: McpClient, launch: Dictionary) -> String:
if client.command_shape == McpClient.CommandShape.NONE:
return ""
if client.command_shape != McpClient.CommandShape.FLAT:
return "%s uses a command shape not supported by DeepSeek Harness config yet" % client.display_name
if not bool(launch.get("ok", false)):
return str(launch.get("error", "No compatible attach launcher was found."))
return ""
# --- Rendering --------------------------------------------------------------
## Render a full `- insert:` row containing one nested loader entry. Public
## seam for the dock's manual-instruction text so the pasted YAML matches
## what Configure would write byte-for-byte.
static func render_insert_row(entry_id_value: String, config: Dictionary) -> PackedStringArray:
var lines: PackedStringArray = ["- insert:"]
lines.append_array(render_nested_entry(entry_id_value, config))
return lines
## Render one nested loader entry (the `- id:` block inside an `insert`
## list). `base_indent` defaults to the 4-space style used by the shipped
## patch files; a nested replacement reuses the indent the existing entry was
## found at so the block sequence keeps a single indentation (#867 review).
static func render_nested_entry(
entry_id_value: String, config: Dictionary, base_indent: int = ENTRY_INDENT
) -> PackedStringArray:
var lines: PackedStringArray = []
lines.append("%s- id: %s" % [_indent_str(base_indent), entry_id_value])
lines.append("%sname: '%s'" % [_indent_str(base_indent + 2), PLUGIN_NAME])
lines.append("%sconfig:" % _indent_str(base_indent + 2))
for key in config:
lines.append_array(_emit_field(String(key), config[key], base_indent + 4))
return lines
## Emit one `key: value` line (or a nested block for Dictionary values) at
## the given indent. Arrays render in flow style; scalars through the shared
## YAML emitter so quoting rules stay identical across the two YAML dialects.
static func _emit_field(key: String, value: Variant, indent: int) -> PackedStringArray:
var lines: PackedStringArray = []
var prefix := _indent_str(indent)
if value is Dictionary:
lines.append("%s%s:" % [prefix, key])
for sub_key in value:
lines.append_array(_emit_field(String(sub_key), value[sub_key], indent + 2))
elif value is Array or value is PackedStringArray:
lines.append("%s%s: %s" % [prefix, key, McpYamlStrategy.emit_flow_array(_array_copy(value))])
else:
lines.append("%s%s: %s" % [prefix, key, McpYamlStrategy.emit_scalar(value)])
return lines
# --- Line-level parsing (no YAML parser in Godot) ---------------------------
## Split file text into lines, dropping only a single trailing newline so the
## remainder round-trips byte-for-byte. `text` is never split on the empty
## string, so an empty file yields an empty array.
static func _split_lines(text: String) -> PackedStringArray:
if text.is_empty():
return PackedStringArray()
var lines := text.split("\n")
if lines.size() > 0 and String(lines[lines.size() - 1]).is_empty():
lines.remove_at(lines.size() - 1)
return lines
## Locate the block owned by our loader entry:
## require_nested=true — the `- id: <entry_id>` line inside an `insert`
## list (indent > 0), plus its indented children.
## require_nested=false — a plain top-level `- id: <entry_id>` row (indent
## 0), plus its indented children.
## Returns {"start": int, "end": int, "indent": int} (end exclusive) or {}
## when absent. The header indent is detected dynamically so a hand-written
## file using non-2-space indentation is still found; children are lines
## deeper than the header. `end` stops right AFTER the last content line:
## trailing blank and comment lines between our entry and the next sibling
## belong to the file, not to our block, so `_replace_lines` / `_remove_lines`
## (which splice [start, end)) never delete them (#867 review).
static func _find_entry_block(lines: PackedStringArray, entry_id_value: String, require_nested: bool) -> Dictionary:
for i in range(lines.size()):
var indent := _indent_of(lines[i])
if require_nested and indent <= 0:
continue
if not require_nested and indent != 0:
continue
var parsed := _parse_entry_header(lines[i])
if parsed.is_empty() or parsed.get("id") != entry_id_value:
continue
## A nested match must actually live inside an `- insert:` row: a
## `- id:` list under some other top-level row (an override row's own
## sequence, say) is not a loader entry and is not ours to touch.
if require_nested and not _under_insert_row(lines, i):
continue
var j := i + 1
var last_content := i
while j < lines.size():
var l := lines[j]
if _is_blank_or_comment(l):
j += 1
continue
if _indent_of(l) <= indent:
break
last_content = j
j += 1
return {"start": i, "end": last_content + 1, "indent": indent}
return {}
## True when the nearest preceding 0-indent content line above `index` is an
## `- insert:` row header. Blank and comment lines are skipped; hitting the
## top of the file without a header means the nested line is orphaned and
## cannot be a loader entry.
static func _under_insert_row(lines: PackedStringArray, index: int) -> bool:
for i in range(index - 1, -1, -1):
if _is_blank_or_comment(lines[i]):
continue
if _indent_of(lines[i]) == 0:
return lines[i].strip_edges().begins_with("- insert:")
return false
## Parse a `- id: <value>` header line into {"id": String}. Returns {} for
## any other line. The value may be plain or single/double-quoted.
static func _parse_entry_header(line: String) -> Dictionary:
var stripped := line.strip_edges()
if not stripped.begins_with("- "):
return {}
var body := stripped.substr(2).strip_edges()
if not body.begins_with("id:"):
return {}
var value := body.substr(3).strip_edges()
if value.begins_with("\"") and value.ends_with("\"") and value.length() >= 2:
var parsed: Variant = JSON.parse_string(value)
if parsed is String:
value = parsed
elif value.begins_with("'") and value.ends_with("'") and value.length() >= 2:
value = value.substr(1, value.length() - 2)
return {"id": value}
## Extract the `config:` sub-dict from a found entry block. Fields may nest
## (env, reconnect); deeper values are parsed with the same scalar coercion
## the YAML strategy uses. Returns {} when the block has no config block.
static func _extract_config(lines: PackedStringArray, block: Dictionary) -> Dictionary:
var start := int(block.get("start", -1))
var end := int(block.get("end", -1))
if start < 0:
return {}
var config_indent := -1
var config_start := -1
for i in range(start, end):
var stripped := lines[i].strip_edges()
if _is_blank_or_comment(lines[i]):
continue
if stripped == "config:":
config_indent = _indent_of(lines[i])
config_start = i
break
if config_start < 0:
return {}
return _parse_block_lines(lines, config_start + 1, end, config_indent)
## Parse sibling `key: value` lines at a fixed minimum indent into a
## Dictionary. Nested blocks (a key with no inline value) recurse and their
## child lines are consumed by the recursion — the parent index advances past
## them so they are never re-parsed as top-level config fields (#867 review).
## Flow arrays parse back into Arrays via JSON. Quoted and plain scalars
## strip via the YAML strategy's shared `coerce_scalar`.
static func _parse_block_lines(lines: PackedStringArray, start: int, end: int, min_indent: int) -> Dictionary:
var out: Dictionary = {}
var i := start
while i < end:
var l := lines[i]
if _is_blank_or_comment(l):
i += 1
continue
var indent := _indent_of(l)
if indent <= min_indent:
break
var stripped := l.strip_edges()
var colon := stripped.find(":")
if colon < 0:
i += 1
continue
var key := stripped.substr(0, colon).strip_edges()
var value := stripped.substr(colon + 1).strip_edges()
if value.is_empty():
## Nested block: find where its children end (a line at or above
## the block key's indent), parse only that span, and jump past it.
var child_end := i + 1
while child_end < end:
if not _is_blank_or_comment(lines[child_end]) and _indent_of(lines[child_end]) <= indent:
break
child_end += 1
out[key] = _parse_block_lines(lines, i + 1, child_end, indent)
i = child_end
else:
## Shared with the YAML strategy so the two dialects can never
## drift on quoting/typing rules (#867 review).
out[key] = McpYamlStrategy.coerce_scalar(value)
i += 1
return out
# --- Rewrites ---------------------------------------------------------------
## Append a new `- insert:` row at the end of the file. A blank separator
## line keeps rows visually distinct; the file always ends with a newline.
static func _append_row(lines: PackedStringArray, nested_lines: PackedStringArray) -> String:
var parts: Array[String] = []
for l in lines:
parts.append(l)
## Collapse trailing blank lines so the appended row is not orphaned
## below stray empties; one separator blank keeps rows readable.
while parts.size() > 0 and String(parts[parts.size() - 1]).strip_edges().is_empty():
parts.remove_at(parts.size() - 1)
if parts.size() > 0:
parts.append("")
parts.append("- insert:")
for l in nested_lines:
parts.append(l)
return _join_lines(parts)
## Replace the lines owned by our entry. When the found block is a plain
## top-level row (indent 0 header), the whole row — including its children —
## is replaced by the insert form. When it is a nested block inside an
## insert row, only that block's lines are replaced, leaving sibling entries
## and comments in the same row byte-for-byte intact.
static func _replace_lines(lines: PackedStringArray, block: Dictionary, nested_lines: PackedStringArray) -> String:
var start := int(block.get("start", -1))
var end := int(block.get("end", -1))
var parts: Array[String] = []
for i in range(lines.size()):
if i == start:
## A nested block keeps the surrounding `- insert:` row, so only
## the nested entry lines (indent > 0) are spliced in.
if _indent_of(lines[start]) > 0:
for l in nested_lines:
parts.append(l)
else:
parts.append("- insert:")
for l in nested_lines:
parts.append(l)
if i >= start and i < end:
continue
parts.append(lines[i])
return _join_lines(parts)
## Remove our entry. A nested block is spliced out; if its enclosing insert
## row is left without any nested entries (only the bare `- insert:` header,
## blanks, and comments), the row header goes too so the file stays a valid
## patch list. A plain row for our id is removed wholesale.
static func _remove_lines(lines: PackedStringArray, block: Dictionary) -> String:
var start := int(block.get("start", -1))
var end := int(block.get("end", -1))
var nested := _indent_of(lines[start]) > 0
var parts: Array[String] = []
if nested:
## Find the enclosing `- insert:` row header: the nearest preceding
## 0-indent `- ` line.
var row_start := -1
for i in range(start - 1, -1, -1):
if _indent_of(lines[i]) == 0 and lines[i].strip_edges().begins_with("- "):
row_start = i
break
## Row end: first 0-indent non-blank/comment line after the block.
var row_end := lines.size()
for i in range(end, lines.size()):
if _indent_of(lines[i]) == 0 and not _is_blank_or_comment(lines[i]):
row_end = i
break
var has_other_entries := false
for i in range(row_start + 1, row_end):
if i >= start and i < end:
continue
if _is_blank_or_comment(lines[i]):
continue
if _indent_of(lines[i]) > 0 and lines[i].strip_edges().begins_with("- "):
has_other_entries = true
break
if has_other_entries or row_start < 0:
## Only our block goes; sibling entries and any trailing
## comments in the same row survive.
for i in range(lines.size()):
if i >= start and i < end:
continue
parts.append(lines[i])
else:
## The row held only our entry: drop the `- insert:` header and
## our block, but keep trailing blank/comment lines — a comment
## the user wrote above the next row must survive (#867 review).
for i in range(lines.size()):
if i >= row_start and i < end:
continue
parts.append(lines[i])
parts = _trim_trailing_blanks(parts)
else:
## Plain top-level row for our id: drop the whole block.
for i in range(lines.size()):
if i >= start and i < end:
continue
parts.append(lines[i])
parts = _trim_trailing_blanks(parts)
return _join_lines(parts)
## Remove trailing blank lines left behind by a removal.
static func _trim_trailing_blanks(parts: Array[String]) -> Array[String]:
while parts.size() > 0 and String(parts[parts.size() - 1]).strip_edges().is_empty():
parts.remove_at(parts.size() - 1)
return parts
## True when the text holds no patch rows at all — blank lines and comments
## only. Such a file cannot be written back (see `remove`), because dsh's
## patch parser rejects a non-array file at boot.
static func _is_blank_or_comment_only(text: String) -> bool:
for line in text.split("\n"):
var stripped := line.strip_edges()
if not stripped.is_empty() and not stripped.begins_with("#"):
return false
return true
## When the document's only content line is the empty flow sequence `[]`,
## return the text with that one line removed — blanks and comments stay,
## because they belong to the user. Any other document comes back unchanged;
## a `[]` mixed with real rows is left for `_is_patch_sequence` to refuse.
static func _strip_empty_flow_sequence(text: String) -> String:
var lines := _split_lines(text)
var bracket_line := -1
for i in range(lines.size()):
if _is_blank_or_comment(lines[i]):
continue
if bracket_line < 0 and lines[i].strip_edges() == "[]":
bracket_line = i
continue
return text
if bracket_line < 0:
return text
var parts: Array[String] = []
for i in range(lines.size()):
if i != bracket_line:
parts.append(lines[i])
if parts.is_empty():
return ""
return _join_lines(parts)
## True when the text is a valid loader patch list (or empty/comment-only /
## the empty flow sequence `[]`, which all mean "no layer"). Anything else —
## a top-level mapping or scalar — would make dsh fail boot, so Configure
## refuses to write into it instead of producing a broken file (#867 review).
## Only 0-indent content lines define the top-level structure: they must all
## be `- ` rows. Deeper lines are row children and are not inspected. `[]` is
## valid only as the COMPLETE document — appending rows after the empty flow
## sequence would produce malformed YAML, so a mixed `[]` + rows file fails
## the check too.
static func _is_patch_sequence(text: String) -> bool:
if text.strip_edges().is_empty():
return true
var saw_brackets := false
var content_count := 0
for line in text.split("\n"):
var stripped := line.strip_edges()
if stripped.is_empty() or stripped.begins_with("#"):
continue
content_count += 1
if stripped == "[]":
saw_brackets = true
continue
if _indent_of(line) == 0 and not stripped.begins_with("- "):
return false
if saw_brackets and content_count > 1:
return false
return true
static func _join_lines(parts: Array) -> String:
if parts.is_empty():
return ""
var lines: Array[String] = []
for p in parts:
lines.append(String(p))
return "\n".join(lines) + "\n"
# --- Small helpers ----------------------------------------------------------
static func _indent_str(n: int) -> String:
var out := ""
for i in range(n):
out += " "
return out
static func _indent_of(line: String) -> int:
var n := 0
while n < line.length() and (line[n] == " " or line[n] == "\t"):
n += 1
return n
static func _is_blank_or_comment(line: String) -> bool:
var stripped := line.strip_edges()
return stripped.is_empty() or stripped.begins_with("#")
static func _array_copy(value: Variant) -> Array:
if value is Array:
return (value as Array).duplicate(true)
if value is PackedStringArray:
return McpClient._array_from_packed(value)
return []
static func _arrays_equal(left: Variant, right: Variant) -> bool:
if not (left is Array or left is PackedStringArray):
return false
if not (right is Array or right is PackedStringArray):
return false
var left_array := _array_copy(left)
var right_array := _array_copy(right)
if left_array.size() != right_array.size():
return false
for i in range(left_array.size()):
if left_array[i] != right_array[i]:
return false
return true
## Returns {"ok": true, "data": String} when the file is absent or readable,
## and {"ok": false, "error": String} when unreadable. Callers must NOT fall
## back to an empty string on the error path — doing so blows away the user's
## other patch rows on the next write.
static func _read(path: String) -> Dictionary:
if not FileAccess.file_exists(path):
return {"ok": true, "data": ""}
var f := FileAccess.open(path, FileAccess.READ)
if f == null:
var err := FileAccess.get_open_error()
return {"ok": false, "error": "could not open for reading (%s)" % error_string(err)}
var t := f.get_as_text()
f.close()
return {"ok": true, "data": t}
@@ -0,0 +1 @@
uid://ch0brn7swb6qx
+977
View File
@@ -0,0 +1,977 @@
@tool
class_name McpJsonStrategy
extends RefCounted
## Read–merge–write strategy for JSON-backed MCP clients.
## All knobs come from the McpClient descriptor as plain data — no Callables.
## See `_base.gd` for why descriptors are data-only.
static func configure(
client: McpClient,
server_name: String,
server_url: String,
launch: Dictionary = {},
project_roots: PackedStringArray = PackedStringArray(),
) -> Dictionary:
if _uses_merge_tiers(client):
return _configure_merged(client, server_name, server_url, launch, project_roots)
var resolution := client.resolved_config_path_details()
var path := str(resolution.get("path", ""))
var path_error := str(resolution.get("error", ""))
if not path_error.is_empty():
return {"status": "error", "message": path_error}
if path.is_empty():
return {"status": "error", "message": "Could not resolve config path for %s on this OS" % client.display_name}
var seed_path := str(resolution.get("seed_path", ""))
var read_path := seed_path if not FileAccess.file_exists(path) and not seed_path.is_empty() else path
var read := _read_or_init(read_path)
if not read["ok"]:
return {"status": "error", "message": "Refusing to overwrite %s: %s. Fix or move the file, then re-run Configure." % [read_path, read["error"]]}
var launch_error := command_launch_error(client, launch)
if not launch_error.is_empty():
return {"status": "error", "message": launch_error}
var config: Dictionary = read["data"]
var holder := _ensure_path(config, select_server_key_path(config, client))
## Pass the existing entry through so `build_entry` can preserve user-mutable
## state (auto-approval lists, `disabled` toggles) instead of resetting it
## to descriptor defaults on every Configure click. See `entry_initial_fields`
## docs in `_base.gd`.
var existing: Variant = holder.get(server_name, null)
holder[server_name] = build_entry(client, server_url, existing, launch)
# F5: refuse to re-serialize a file whose parsed integers above 2^53 would
# lose precision. Editing by hand keeps those values intact.
if _has_lossy_numbers(config):
return {"status": "error", "message": "Refusing to rewrite %s: contains integers above 2^53 that Godot's JSON parser would re-emit imprecise. Edit the file by hand." % path}
if not McpAtomicWrite.write(path, JSON.stringify(_narrow_integral_numbers(config), "\t", false)):
return {"status": "error", "message": "Cannot write to %s" % path}
return {"status": "ok", "message": McpClient.configured_message(client, server_url)}
## Pi-style clients merge several global config files. Update the effective
## highest-precedence definition; fail closed when a project override exists
## because Pi's external working directory cannot be inferred safely here.
static func _configure_merged(
client: McpClient,
server_name: String,
server_url: String,
launch: Dictionary,
project_roots: PackedStringArray,
) -> Dictionary:
var launch_error := command_launch_error(client, launch)
if not launch_error.is_empty():
return {"status": "error", "message": launch_error}
var project := _load_project_definitions(client, server_name, project_roots)
if not project.get("ok", false):
return {"status": "error", "message": str(project.get("error", "Cannot inspect project config tiers"))}
var project_tiers: Array = project.get("tiers", [])
if not project_tiers.is_empty():
return {"status": "error", "message": _project_override_message(project_tiers, "update or remove", client.display_name, server_name)}
var loaded := _load_merge_tiers(client)
if not loaded.get("ok", false):
return {"status": "error", "message": str(loaded.get("error", "Cannot read merged config tiers"))}
var tiers: Array = loaded.get("tiers", [])
if tiers.is_empty():
return {"status": "error", "message": "Could not resolve config path for %s on this OS" % client.display_name}
var target_index := 0
for index in range(tiers.size()):
var config: Dictionary = tiers[index]["data"]
var holder := _walk_path(config, select_server_key_path(config, client))
if holder is Dictionary and holder.has(server_name):
target_index = index
var tier: Dictionary = tiers[target_index]
var config: Dictionary = tier["data"]
var holder := _ensure_path(config, select_server_key_path(config, client))
var existing: Variant = holder.get(server_name, null)
holder[server_name] = build_entry(client, server_url, existing, launch)
var path := str(tier["path"])
# F5: refuse to re-serialize a tier whose parsed integers above 2^53 would
# lose precision. The same check guards the simple `configure` path.
if _has_lossy_numbers(config):
return {"status": "error", "message": "Refusing to rewrite %s: contains integers above 2^53 that Godot's JSON parser would re-emit imprecise. Edit the file by hand." % path}
if not McpAtomicWrite.write(path, JSON.stringify(_narrow_integral_numbers(config), "\t", false)):
return {"status": "error", "message": "Cannot write to %s" % path}
# Codex F1 caveat: when we wrote a global tier but couldn't find any project
# override in the roots we probed AND the user hasn't told us where Pi (or any
# external cwd client) actually runs from, we can't prove this write is the
# effective config. Surface the actionable hint instead of silently passing.
# The early `not project_tiers.is_empty()` return above already covers the
# "project override exists in probed roots" case (we fail closed there), so
# reaching this point with `project_tiers.is_empty()` means: no probed
# override was found.
var message := McpClient.configured_message(client, server_url)
var external_cwd := str(McpClientConfigurator._editor_setting_lookup(McpClientConfigurator.SETTING_EXTERNAL_CLIENT_CWD))
if external_cwd.is_empty() and project_tiers.is_empty():
message += " If %s is launched from a cwd this editor cannot see, the effective entry may still be in a project-tier file — set godot_ai/external_client_cwd to that cwd to verify." % client.display_name
return {"status": "ok", "message": message}
static func check_status(
client: McpClient,
server_name: String,
server_url: String,
launch: Dictionary = {},
project_roots: PackedStringArray = PackedStringArray(),
) -> McpClient.Status:
return check_status_details(client, server_name, server_url, launch, project_roots).get("status", McpClient.Status.NOT_CONFIGURED)
## Detailed variant feeding the dock's error_msg plumbing (#711): a config
## file that EXISTS but can't be read or parsed is Status.ERROR carrying the
## read/parse error, not NOT_CONFIGURED — the write path refuses to touch
## such a file (see `_read_or_init`), so the status dot must say "broken
## file", not "click Configure".
static func check_status_details(
client: McpClient,
server_name: String,
server_url: String,
launch: Dictionary = {},
project_roots: PackedStringArray = PackedStringArray(),
) -> Dictionary:
if _uses_merge_tiers(client):
return _check_status_merged(client, server_name, server_url, launch, project_roots)
var resolution := client.resolved_config_path_details()
var path := str(resolution.get("path", ""))
var path_error := str(resolution.get("error", ""))
if not path_error.is_empty():
return {"status": McpClient.Status.ERROR, "error_msg": path_error}
if path.is_empty() or not FileAccess.file_exists(path):
return {"status": McpClient.Status.NOT_CONFIGURED, "error_msg": ""}
var read := _read_or_init(path)
if not read["ok"]:
return {"status": McpClient.Status.ERROR, "error_msg": String(read["error"])}
var config: Dictionary = read["data"]
var holder := _walk_path(config, select_server_key_path(config, client))
if not (holder is Dictionary) or not holder.has(server_name):
return {"status": McpClient.Status.NOT_CONFIGURED, "error_msg": ""}
return _entry_status_details(client, holder[server_name], server_url, launch)
## Verify the effective last definition after applying the client's merge order.
static func _check_status_merged(
client: McpClient,
server_name: String,
server_url: String,
launch: Dictionary,
project_roots: PackedStringArray,
) -> Dictionary:
var loaded := _load_merge_tiers(client)
if not loaded.get("ok", false):
return {"status": McpClient.Status.ERROR, "error_msg": str(loaded.get("error", "Cannot read merged config tiers"))}
var effective: Variant = null
for tier in loaded.get("tiers", []):
var config: Dictionary = tier["data"]
var holder := _walk_path(config, select_server_key_path(config, client))
if holder is Dictionary and holder.has(server_name):
effective = holder[server_name]
var project := _load_project_definitions(client, server_name, project_roots)
if not project.get("ok", false):
return {"status": McpClient.Status.ERROR, "error_msg": str(project.get("error", "Cannot inspect project config tiers"))}
var project_tiers: Array = project.get("tiers", [])
if not project_tiers.is_empty():
# Last-definition-wins mirrors the global-tier fold above and how
# pi-codemode-mcp merges project tiers on disk. Earlier tiers are dead
# once a later one defines the same server, so an early-stale entry
# doesn't make Pi's effective config drift (codex-review finding F2).
# Pass `[latest]` to `_project_override_message` so the error names only
# the file the user actually has to edit.
var latest: Dictionary = project_tiers[project_tiers.size() - 1]
var details := _entry_status_details(client, latest["entry"], server_url, launch)
if details.get("status") != McpClient.Status.CONFIGURED:
return {"status": McpClient.Status.CONFIGURED_MISMATCH, "error_msg": _project_override_message([latest], "update or remove", client.display_name, server_name)}
return {"status": McpClient.Status.CONFIGURED, "error_msg": ""}
if effective == null:
return {"status": McpClient.Status.NOT_CONFIGURED, "error_msg": ""}
return _entry_status_details(client, effective, server_url, launch)
static func _entry_status_details(
client: McpClient,
entry: Variant,
server_url: String,
launch: Dictionary,
) -> Dictionary:
if not (entry is Dictionary):
return {"status": McpClient.Status.NOT_CONFIGURED, "error_msg": ""}
var launch_error := command_launch_error(client, launch)
if not launch_error.is_empty():
return {"status": McpClient.Status.ERROR, "error_msg": launch_error}
if verify_entry(client, entry, server_url, launch):
return {"status": McpClient.Status.CONFIGURED, "error_msg": ""}
return {"status": McpClient.Status.CONFIGURED_MISMATCH, "error_msg": ""}
static func remove(
client: McpClient,
server_name: String,
project_roots: PackedStringArray = PackedStringArray(),
) -> Dictionary:
if _uses_merge_tiers(client):
return _remove_merged(client, server_name, project_roots)
var resolution := client.resolved_config_path_details()
var path := str(resolution.get("path", ""))
var path_error := str(resolution.get("error", ""))
if not path_error.is_empty():
return {"status": "error", "message": path_error}
if path.is_empty() or not FileAccess.file_exists(path):
return {"status": "ok", "message": "Not configured"}
# Token-preserving removal (F5): pull the original file text via
# `_read_file_text` and edit only the entry's bytes — keeps every other
# byte, including integers above 2^53, byte-for-byte identical to what
# the user wrote. The parsed-dict round-trip path is gone.
var file_read := _read_file_text(path)
if not file_read.get("ok", false):
return {"status": "error", "message": "Refusing to rewrite %s: %s." % [path, file_read.get("error", "")]}
var config: Dictionary = file_read.get("data", {})
var key_path := select_server_key_path(config, client)
var original_text: String = str(file_read.get("original_text", ""))
var updated: String = _text_remove_server_entry(original_text, key_path, server_name)
if updated == original_text:
return {"status": "ok", "message": "Not configured"}
if not McpAtomicWrite.write(path, updated):
return {"status": "error", "message": "Cannot write to %s" % path}
return {"status": "ok", "message": "%s configuration removed" % client.display_name}
static func _remove_merged(
client: McpClient, server_name: String, project_roots: PackedStringArray
) -> Dictionary:
var project := _load_project_definitions(client, server_name, project_roots)
if not project.get("ok", false):
return {"status": "error", "message": str(project.get("error", "Cannot inspect project config tiers"))}
var project_tiers: Array = project.get("tiers", [])
if not project_tiers.is_empty():
return {"status": "error", "message": _project_override_message(project_tiers, "remove", client.display_name, server_name)}
var loaded := _load_merge_tiers(client)
if not loaded.get("ok", false):
return {"status": "error", "message": str(loaded.get("error", "Cannot read merged config tiers"))}
var writes: Array[Dictionary] = []
for tier in loaded.get("tiers", []):
if not tier.get("exists", false):
continue
var key_path := select_server_key_path(tier["data"], client)
var original: String = str(tier.get("original_text", ""))
# Token-preserving removal (F5): edit `original` textually instead of
# re-serialising the parsed config. This preserves every byte outside
# the entry block, so unrelated integers (including those above the
# IEEE-754 exact-int range that `_narrow_integral_numbers` cannot
# round-trip) keep their original literals.
var updated: String = _text_remove_server_entry(original, key_path, server_name)
if updated == original:
continue # entry not in this tier; skip
writes.append({
"path": tier["path"],
"text": updated,
"original_text": original,
})
if writes.is_empty():
return {"status": "ok", "message": "Not configured"}
var written := _write_transaction(writes)
if not written.get("ok", false):
return {"status": "error", "message": written.get("error", "Cannot remove merged configuration")}
return {"status": "ok", "message": "%s configuration removed" % client.display_name}
## Synthesize the entry dict the strategy writes under
## `server_key_path[server_name]`. Both URL and command entries deep-copy the
## existing dict before overwriting strategy-owned fields, preserving unknown
## client additions as well as descriptor-documented user fields.
static func build_entry(
client: McpClient,
server_url: String,
existing: Variant = null,
launch: Dictionary = {},
) -> Dictionary:
if _is_supported_command_shape(client.command_shape):
var command_entry: Dictionary = (existing as Dictionary).duplicate(true) if existing is Dictionary else {}
if client.command_shape == McpClient.CommandShape.COMMAND_ARRAY:
## OpenCode-style: the entry's `command` field IS the argv array.
## A stale sibling `args` from a FLAT-style hand edit would be
## ambiguous next to it, so it is strategy-owned and removed.
command_entry["command"] = _launch_argv(launch)
command_entry.erase("args")
else:
command_entry["command"] = str(launch.get("command", ""))
command_entry["args"] = _array_copy(launch.get("args", []))
if not client.command_transport_key.is_empty():
command_entry[client.command_transport_key] = client.command_transport_value
for key in client.command_initial_fields:
if not command_entry.has(key):
command_entry[key] = client.command_initial_fields[key]
for key in client.command_legacy_keys:
command_entry.erase(String(key))
_remove_legacy_env_keys(command_entry, client.command_env_legacy_keys)
return command_entry
if client.command_shape != McpClient.CommandShape.NONE:
return {}
return build_url_entry(client, server_url, existing)
static func build_url_entry(client: McpClient, server_url: String, existing: Variant = null) -> Dictionary:
var entry: Dictionary = (existing as Dictionary).duplicate(true) if existing is Dictionary else {}
entry[client.entry_url_field] = server_url
for k in client.entry_extra_fields:
entry[k] = client.entry_extra_fields[k]
for k in client.entry_initial_fields:
if not entry.has(k):
entry[k] = client.entry_initial_fields[k]
return entry
## Default verifier for a stored entry. Command entries must match every
## launch-affecting value exactly; legacy URL or env keys are migration drift.
## For URL clients, assert `entry[entry_url_field] == url` AND every
## key in `entry_extra_fields` matches verbatim. Type-pinning for Cline /
## Roo / Kilo (`type: "streamable-http"` etc.) falls out of this — pre-fix
## entries that lack the type field fail verification and surface as drift.
static func verify_entry(
client: McpClient,
entry: Dictionary,
server_url: String,
launch: Dictionary = {},
) -> bool:
if client.command_shape != McpClient.CommandShape.NONE:
if not _is_supported_command_shape(client.command_shape) or not bool(launch.get("ok", false)):
return false
for key in client.command_legacy_keys:
if entry.has(String(key)):
return false
var env = entry.get("env", null)
if env is Dictionary:
for key in client.command_env_legacy_keys:
if env.has(String(key)):
return false
if client.command_shape == McpClient.CommandShape.COMMAND_ARRAY:
if not _arrays_equal(entry.get("command", null), _launch_argv(launch)):
return false
if entry.has("args"):
return false
else:
if entry.get("command") != launch.get("command"):
return false
if not _arrays_equal(entry.get("args", null), launch.get("args", null)):
return false
if not client.command_transport_key.is_empty():
if not entry.has(client.command_transport_key):
return false
if entry.get(client.command_transport_key) != client.command_transport_value:
return false
return true
if entry.get(client.entry_url_field, "") != server_url:
return false
for k in client.entry_extra_fields:
if entry.get(k) != client.entry_extra_fields[k]:
return false
return true
static func command_launch_error(client: McpClient, launch: Dictionary) -> String:
if client.command_shape == McpClient.CommandShape.NONE:
return ""
if not _is_supported_command_shape(client.command_shape):
return "%s uses a command shape not supported by JSON yet" % client.display_name
if not bool(launch.get("ok", false)):
return str(launch.get("error", "No compatible attach launcher was found."))
return ""
static func _is_supported_command_shape(shape: McpClient.CommandShape) -> bool:
return shape == McpClient.CommandShape.FLAT or shape == McpClient.CommandShape.COMMAND_ARRAY
## The full launch argv as one array: launcher path followed by every arg.
static func _launch_argv(launch: Dictionary) -> Array:
var argv: Array = [str(launch.get("command", ""))]
argv.append_array(_array_copy(launch.get("args", [])))
return argv
static func _remove_legacy_env_keys(entry: Dictionary, legacy_keys: PackedStringArray) -> void:
if legacy_keys.is_empty():
return
var existing_env = entry.get("env", null)
if not (existing_env is Dictionary):
return
var env: Dictionary = (existing_env as Dictionary).duplicate(true)
for key in legacy_keys:
env.erase(String(key))
if env.is_empty():
entry.erase("env")
else:
entry["env"] = env
static func _array_copy(value: Variant) -> Array:
if value is Array:
return (value as Array).duplicate(true)
if value is PackedStringArray:
return McpClient._array_from_packed(value)
return []
static func _arrays_equal(left: Variant, right: Variant) -> bool:
if not (left is Array or left is PackedStringArray):
return false
if not (right is Array or right is PackedStringArray):
return false
var left_array := _array_copy(left)
var right_array := _array_copy(right)
if left_array.size() != right_array.size():
return false
for i in range(left_array.size()):
if left_array[i] != right_array[i]:
return false
return true
## Read a config file once and parse the captured text. Returns
## {"exists": bool, "ok": bool, "data": Dictionary, "original_text": String}
## when the file is absent or parses cleanly, and
## {"exists": true, "ok": false, "error": String, "original_text": String}
## when the file exists with non-empty content we cannot safely round-trip.
## Callers must NOT treat the error path as an empty config — doing so blows
## away the user's other MCP entries on the next write. The `original_text`
## is the exact captured source so transactional rollback can restore
## byte-for-byte; the UTF-8 BOM is stripped only from the parsing copy.
static func _read_file_text(path: String) -> Dictionary:
if not FileAccess.file_exists(path):
return {"exists": false, "ok": true, "data": {}, "original_text": ""}
var file := FileAccess.open(path, FileAccess.READ)
if file == null:
var open_err := FileAccess.get_open_error()
return {"exists": true, "ok": false, "error": "could not open for reading (error %d)" % open_err, "original_text": ""}
var content := file.get_as_text()
file.close()
if content.strip_edges().is_empty():
return {"exists": true, "ok": true, "data": {}, "original_text": content}
var parse_copy := content
# Strip a UTF-8 BOM if present — some editors (notably on Windows) save
# JSON with a leading , which Godot's JSON.parse rejects outright.
# Previously this landed on the "unparseable → wipe" path.
if parse_copy.begins_with(""):
parse_copy = parse_copy.substr(1)
var json := JSON.new()
if json.parse(parse_copy) != OK:
var msg := "JSON parse error on line %d: %s" % [json.get_error_line(), json.get_error_message()]
push_warning("MCP | %s in %s" % [msg, path])
return {"exists": true, "ok": false, "error": msg, "original_text": content}
if not (json.data is Dictionary):
return {"exists": true, "ok": false, "error": "top-level value is %s, expected object" % type_string(typeof(json.data)), "original_text": content}
return {"exists": true, "ok": true, "data": json.data, "original_text": content}
## Returns {"ok": true, "data": Dictionary} when the file is absent or parses
## cleanly, and {"ok": false, "error": String} when the file exists with
## non-empty content we cannot safely round-trip. Callers must NOT fall back
## to an empty dict on the error path — doing so blows away the user's other
## MCP entries on the next write.
static func _read_or_init(path: String) -> Dictionary:
var read := _read_file_text(path)
var result: Dictionary = {"ok": read.get("ok", false), "data": read.get("data", {})}
if not result.get("ok", false):
result["error"] = read.get("error", "")
return result
## Walk a key path, creating intermediate Dicts as needed. Returns the leaf Dict.
static func _ensure_path(root: Dictionary, key_path: PackedStringArray) -> Dictionary:
var cur := root
for key in key_path:
var next = cur.get(key)
if not (next is Dictionary):
next = {}
cur[key] = next
cur = next
return cur
## Walk a key path, returning the leaf Dict if all hops exist; else null.
static func _walk_path(root: Dictionary, key_path: PackedStringArray) -> Variant:
var cur: Variant = root
for key in key_path:
if not (cur is Dictionary) or not cur.has(key):
return null
cur = cur[key]
return cur
## Match clients that accept multiple server-map keys without creating a
## higher-precedence canonical map that shadows an existing legacy map. A
## non-null scalar still wins, matching nullish-coalescing parsers; Configure's
## `_ensure_path` then repairs it using the strategy's existing behavior.
static func select_server_key_path(root: Dictionary, client: McpClient) -> PackedStringArray:
var candidates: Array[PackedStringArray] = [client.server_key_path]
# Dynamic access keeps mixed-snapshot self-updates parse-safe when a new
# strategy is briefly loaded with an older McpClient base (#398/#736).
var aliases = client.get("server_key_path_aliases")
if aliases is Array:
for alias in aliases:
if alias is PackedStringArray:
candidates.append(alias)
for key_path in candidates:
if _walk_path(root, key_path) != null:
return key_path
return client.server_key_path
static func _uses_merge_tiers(client: McpClient) -> bool:
var templates = client.get("config_merge_path_templates")
return templates is Dictionary and not templates.is_empty()
## Resolve the global config tiers in the client's documented merge order.
static func _merge_paths(client: McpClient) -> PackedStringArray:
var result := PackedStringArray()
var templates = client.get("config_merge_path_templates")
if templates is Dictionary:
var platform_key := McpPathTemplate.platform_key(templates)
if not platform_key.is_empty():
var raw_paths: Variant = templates.get(platform_key, [])
if raw_paths is Array or raw_paths is PackedStringArray:
for template in raw_paths:
var path := McpPathTemplate.expand(str(template)).simplify_path()
if not path.is_empty() and not result.has(path):
result.append(path)
return result
## Pi's project tiers are relative to Pi's process cwd, not Godot's. Inspect
## plausible roots only to detect overrides; callers fail closed rather than
## mutating both guesses.
static func _project_candidate_paths(
client: McpClient, roots: PackedStringArray
) -> PackedStringArray:
var result := PackedStringArray()
var project_paths = client.get("config_merge_project_paths")
if not (project_paths is PackedStringArray):
return result
for project_path in project_paths:
var absolute_path := String(project_path).simplify_path()
if absolute_path.is_absolute_path() and FileAccess.file_exists(absolute_path) and not result.has(absolute_path):
result.append(absolute_path)
for root in roots:
if String(root).is_empty():
continue
for relative_path in project_paths:
if String(relative_path).is_absolute_path():
continue
var path := String(root).path_join(String(relative_path)).simplify_path()
if FileAccess.file_exists(path) and not result.has(path):
result.append(path)
return result
static func _load_project_definitions(
client: McpClient, server_name: String, project_roots: PackedStringArray
) -> Dictionary:
var tiers: Array[Dictionary] = []
for path in _project_candidate_paths(client, project_roots):
var read := _read_or_init(path)
if not read.get("ok", false):
return {"ok": false, "error": "Cannot inspect project config %s: %s" % [path, read.get("error", "invalid JSON")]}
var config: Dictionary = read["data"]
var key_path := select_server_key_path(config, client)
var holder := _walk_path(config, key_path)
if holder is Dictionary and holder.has(server_name):
tiers.append({
"path": path,
"data": config,
"key_path": key_path,
"entry": holder[server_name],
})
return {"ok": true, "tiers": tiers}
static func _project_override_message(
tiers: Array, action: String, client_name: String, server_name: String
) -> String:
var paths := PackedStringArray()
for tier in tiers:
paths.append(str(tier["path"]))
return "%s project config overrides %s at %s. %s resolves project files from its own working directory, so the dock cannot safely choose one; %s the entry manually." % [client_name, server_name, ", ".join(paths), client_name, action]
static func _load_merge_tiers(client: McpClient) -> Dictionary:
var tiers: Array[Dictionary] = []
for path in _merge_paths(client):
var read := _read_file_text(path)
if not read.get("ok", false):
return {
"ok": false,
"error": "Refusing to rewrite %s: %s. Fix or move the file, then re-run Configure." % [path, read.get("error", "invalid JSON")],
}
tiers.append({
"path": path,
"exists": read.get("exists", false),
"original_text": read.get("original_text", ""),
"data": read.get("data", {}),
})
return {"ok": true, "tiers": tiers}
## Commit a multi-file removal with best-effort rollback. Each individual write
## is atomic; this layer restores earlier tiers if a later commit fails.
static func _write_transaction(writes: Array[Dictionary]) -> Dictionary:
var completed: Array[Dictionary] = []
for write in writes:
var path := str(write["path"])
if McpAtomicWrite.write(path, str(write["text"])):
completed.append(write)
continue
var rollback_failed := PackedStringArray()
completed.reverse()
for previous in completed:
if not McpAtomicWrite.write(str(previous["path"]), str(previous["original_text"])):
rollback_failed.append(str(previous["path"]))
var suffix := ""
if not rollback_failed.is_empty():
suffix = " Rollback also failed for: %s" % ", ".join(rollback_failed)
return {"ok": false, "error": "Cannot write to %s; earlier tier changes were rolled back.%s" % [path, suffix]}
return {"ok": true}
## Path that `_check_status_merged`'s last-wins logic would consider
## authoritative for the server entry, or "" when no entry is found in
## any tier. Mirrors the same project-tier / global-tier resolution the
## status check uses so callers (notably the dock Open/Reveal buttons)
## land on the file that actually drives Pi's effective config.
##
## Resolution order (codex round 3, F-3-4):
## 1. Latest project tier containing the entry — F2 last-wins means the
## latest of `.pi/mcp.json` and `.mcp.json` wins (matches `_check_status_merged`).
## 2. Latest global tier containing the entry — already iterated in
## merge order, last-iterated-wins (same loop pattern as
## `manual_target_details` lines 660-665).
## 3. "" when no tier has the entry — caller decides what fallback
## (`path_template`) to use.
##
## Returns "" (not `path_template`) on the "nothing anywhere" branch so
## the dock can distinguish "no entry yet" from "entry is at the
## template path" if it wants to.
static func authoritative_tier_path(
client: McpClient,
server_name: String,
project_roots: PackedStringArray,
) -> String:
if not _uses_merge_tiers(client):
return ""
var project := _load_project_definitions(client, server_name, project_roots)
if not bool(project.get("ok", false)):
return ""
var project_tiers: Array = project.get("tiers", [])
if not project_tiers.is_empty():
# F2 last-wins: latest project tier is authoritative. When there are
# multiple project tiers, the user-visible status is driven by the
# latest, so the Open/Reveal buttons should send them there too.
var latest: Dictionary = project_tiers[project_tiers.size() - 1]
return str(latest.get("path", ""))
var loaded := _load_merge_tiers(client)
if not bool(loaded.get("ok", false)):
return ""
var tiers: Array = loaded.get("tiers", [])
# Iterate in priority order, overwrite `selected_path` each time we
# find a tier containing the entry. With Pi's merge path order
# [mcp.json, .mcp.json] this leaves the higher-precedence `.mcp.json`.
var selected_path: String = ""
for tier in tiers:
var data: Dictionary = tier.get("data", {})
var holder: Variant = _walk_path(data, select_server_key_path(data, client))
if holder is Dictionary and holder.has(server_name):
selected_path = str(tier.get("path", ""))
return selected_path
## Pick the file and top-level map shown by the manual JSON instructions using
## the same merge and alias precedence as automatic Configure.
static func manual_target_details(
client: McpClient,
server_name: String,
fallback_path: String,
project_roots: PackedStringArray = PackedStringArray(),
) -> Dictionary:
if _uses_merge_tiers(client):
var project := _load_project_definitions(client, server_name, project_roots)
if not project.get("ok", false):
return {"ok": false, "error": project.get("error", "Cannot inspect project config tiers")}
var project_tiers: Array = project.get("tiers", [])
if project_tiers.size() > 1:
return {"ok": false, "error": _project_override_message(project_tiers, "update or remove", client.display_name, server_name)}
if project_tiers.size() == 1:
var selected_project: Dictionary = project_tiers[0]
return {
"ok": true,
"path": selected_project["path"],
"key_path": selected_project["key_path"],
}
var loaded := _load_merge_tiers(client)
if not loaded.get("ok", false):
return {"ok": false, "error": loaded.get("error", "Cannot read merged config tiers")}
var tiers: Array = loaded.get("tiers", [])
var selected: Variant = tiers[0] if not tiers.is_empty() else null
for tier in tiers:
var config: Dictionary = tier["data"]
var holder := _walk_path(config, select_server_key_path(config, client))
if holder is Dictionary and holder.has(server_name):
selected = tier
if selected != null:
var config: Dictionary = selected["data"]
return {
"ok": true,
"path": selected["path"],
"key_path": select_server_key_path(config, client),
}
var read := _read_or_init(fallback_path)
if not read.get("ok", false):
return {"ok": false, "error": "Cannot inspect %s: %s" % [fallback_path, read.get("error", "invalid JSON")]}
return {
"ok": true,
"path": fallback_path,
"key_path": select_server_key_path(read["data"], client),
}
## Godot's JSON.parse turns every JSON number into a float, so a later
## JSON.stringify re-emits the user's integer fields as "8080.0" — which strict
## consumers (Go's encoding/json into an int field, etc.) reject, and which
## needlessly rewrites every number across the user's *other* entries. Re-narrow
## exactly-representable integral floats back to int so they serialize without
## the ".0". Walks dicts/arrays in place and returns the (same) value.
##
## Integers above 2^53 already lost precision when Godot parsed them to double,
## so they're left as the float Godot produced rather than faking exactness —
## byte-perfect preservation would require not parsing the file at all, and such
## magnitudes don't occur in MCP client configs.
static func _narrow_integral_numbers(value: Variant) -> Variant:
match typeof(value):
TYPE_FLOAT:
if is_finite(value) and value == floor(value) and absf(value) <= 9007199254740992.0:
return int(value)
TYPE_DICTIONARY:
for k in value:
value[k] = _narrow_integral_numbers(value[k])
TYPE_ARRAY:
for i in value.size():
value[i] = _narrow_integral_numbers(value[i])
return value
## True when `value` contains any float outside the IEEE-754 exact-int range
## (`abs(f) > 2^53`) that would lose precision when JSON.stringify re-emits it.
## The configure path refuses such files instead of silently mutating unrelated
## integers; the remove path never needs this check because it does
## token-preserving surgery (F5).
static func _has_lossy_numbers(value: Variant) -> bool:
match typeof(value):
TYPE_FLOAT:
# `>=` not `>`: `2^53 + 1` rounds down to `2^53.0` when Godot parses
# the integer to double, so even a "clean" `2^53.0` after parse may
# have lost a least-significant bit we cannot recover. Refusing at
# the boundary is conservative but correct — and 2^53+ magnitudes
# don't appear in real MCP client configs anyway.
if is_finite(value) and absf(value) >= 9007199254740992.0:
return true
TYPE_DICTIONARY:
for k in value:
if _has_lossy_numbers(value[k]):
return true
TYPE_ARRAY:
for i in value.size():
if _has_lossy_numbers(value[i]):
return true
return false
## Token-preserving entry removal. Walks the JSON text with a brace-balanced
## scanner to locate the entry at `key_path[0]/.../[server_name]` and returns
## `text` with that entry's bytes deleted (along with the surrounding
## whitespace + trailing comma). Everything else — including integers above
## the IEEE-754 exact-int range that `_narrow_integral_numbers` would silently
## mutate — is preserved byte-for-byte. Returns `text` unchanged when the
## entry isn't found in `text` (callers treat this as a no-op write).
static func _text_remove_server_entry(text: String, key_path: PackedStringArray, server_name: String) -> String:
# Descend through `key_path`. Each step finds `"<key>":` directly inside the
# current container and moves the cursor past the value's opening `{` or `[`.
# After the loop, `cursor` is positioned just past the opening bracket of the
# container that holds our entry, and `container_depth` tracks how deeply
# nested that container is from the root (0 = root object/array).
var cursor := 0
# Skip past the root's opening `{` or `[` (and any leading whitespace or
# UTF-8 BOM) so `_find_key_at_container_depth` starts scanning from
# inside the root container with `inner_depth == 0`. Without this step,
# the first character encountered is the root's `{`, which bumps
# `inner_depth` to 1 and makes the top-level key check fail. The BOM
# skip mirrors `_read_file_text`'s parse_copy BOM-strip above so the
# scanner sees the same view of the file the parser does; the BOM
# itself stays in `text` so the byte-survival F5 contract still holds
# (codex round 3, F-3-6 — without it, files saved with a Windows BOM
# left the entry in place after Remove).
while cursor < text.length() and _is_json_ws(text[cursor]):
cursor += 1
if cursor < text.length() and text[cursor] == "":
cursor += 1
if cursor < text.length() and (text[cursor] == "{" or text[cursor] == "["):
cursor += 1
var container_depth := 0
for key in key_path:
var key_bytes := JSON.stringify(String(key))
var match := _find_key_at_container_depth(text, cursor, key_bytes)
if match.is_empty():
return text
var open_char := text[match["value_start"]]
if open_char != "{" and open_char != "[":
return text # leaf isn't an object/array — path can't reach our entry
container_depth += 1
cursor = match["value_start"] + 1
# Now find `"<server_name>":` at the top level of the container we descended
# into (the helper gates on `inner_depth == 0`).
var entry_bytes := JSON.stringify(server_name)
var entry_match := _find_key_at_container_depth(text, cursor, entry_bytes)
if entry_match.is_empty():
return text
var key_start: int = entry_match["key_start"]
var value_end: int = entry_match["value_end"]
# Trim the surrounding comma in a mutually exclusive way: the trailing comma
# (after the entry's value) carries the burden when the entry is NOT the last
# in its container, and the leading comma (before the entry's key) carries it
# only when the entry IS the last. Trimming BOTH unconditionally corrupts a
# middle-position entry into `"x"}"y"` with no separator — codex-review
# finding F5 (regression after the initial round).
var remove_end := value_end
# Skip JSON whitespace before checking for the trailing comma — valid
# (if unusual) formats like `"godot-ai":{} ,"other":{}` put whitespace
# between the value and its separator. codex round 4 finding.
var trailing_scan := remove_end
while trailing_scan < text.length() and _is_json_ws(text[trailing_scan]):
trailing_scan += 1
var had_trailing_comma := trailing_scan < text.length() and text[trailing_scan] == ","
if had_trailing_comma:
# Jump past the comma (and any whitespace before it we just skipped).
remove_end = trailing_scan + 1
while remove_end < text.length() and _is_json_ws(text[remove_end]):
remove_end += 1
var remove_start := key_start
if not had_trailing_comma:
while remove_start > 0 and _is_json_ws(text[remove_start - 1]):
remove_start -= 1
if remove_start > 0 and text[remove_start - 1] == ",":
remove_start -= 1
while remove_start > 0 and _is_json_ws(text[remove_start - 1]):
remove_start -= 1
return text.substr(0, remove_start) + text.substr(remove_end)
## Look for `key_bytes` (a JSON-encoded string literal including the surrounding
## quotes) at the TOP LEVEL of the container we're inside, scanning `text` from
## `start` onwards. The caller is expected to advance `start` just past the
## container's opening `{` or `[`, so we begin with `inner_depth == 0` and a
## key matches when we hit it while `inner_depth` is still 0. The scan exits
## immediately if `inner_depth` drops below 0 (the target container closed)
## to prevent same-named keys in later sibling containers from being picked
## up. Returns `{key_start, value_start, value_end}` on success or `{}` if
## the key isn't present in that container.
static func _find_key_at_container_depth(text: String, start: int, key_bytes: String) -> Dictionary:
var i := start
var in_string := false
var escape := false
var inner_depth := 0 # depth *inside* the current container
while i < text.length():
var c := text[i]
if in_string:
if escape:
escape = false
elif c == "\\":
escape = true
elif c == '"':
in_string = false
i += 1
continue
if c == '"':
if text.substr(i, key_bytes.length()) == key_bytes:
var after_key := i + key_bytes.length()
while after_key < text.length() and _is_json_ws(text[after_key]):
after_key += 1
if after_key < text.length() and text[after_key] == ":":
var value_start := after_key + 1
while value_start < text.length() and _is_json_ws(text[value_start]):
value_start += 1
var value_end := _json_value_span_end(text, value_start)
if value_end >= 0 and inner_depth == 0:
return {"key_start": i, "value_start": value_start, "value_end": value_end}
in_string = true
i += 1
continue
if c == "{" or c == "[":
inner_depth += 1
elif c == "}" or c == "]":
inner_depth -= 1
# Bail out the moment we leave the container we're scanning. Without
# this guard, a later sibling container at the same level (e.g. a
# top-level `extensions` block after `mcpServers` closes) re-balances
# `inner_depth` back to 0 and a same-named key in that sibling would
# match — silently deleting data we never intended to touch
# (codex-review finding F5, regression after the initial round).
if inner_depth < 0:
return {}
i += 1
return {}
## Given `value_start` pointing at the first char of a JSON value, return the
## position AFTER the value's last char. Handles objects, arrays, strings,
## and bare literals (numbers, true/false/null). Returns -1 if the value is
## unterminated.
static func _json_value_span_end(text: String, value_start: int) -> int:
if value_start >= text.length():
return -1
var c := text[value_start]
if c == "{" or c == "[":
var open_char := c
var close_char := "}" if c == "{" else "]"
var depth := 1
var i := value_start + 1
var in_string := false
var escape := false
while i < text.length() and depth > 0:
var ch := text[i]
if in_string:
if escape:
escape = false
elif ch == "\\":
escape = true
elif ch == '"':
in_string = false
i += 1
continue
if ch == '"':
in_string = true
elif ch == open_char:
depth += 1
elif ch == close_char:
depth -= 1
i += 1
return i if depth == 0 else -1
if c == '"':
var i := value_start + 1
var escape := false
while i < text.length():
var ch := text[i]
if escape:
escape = false
elif ch == "\\":
escape = true
elif ch == '"':
return i + 1
i += 1
return -1
# Bare literal: number / true / false / null. Walk until a JSON-significant
# delimiter (`,`, `}`, `]`, or whitespace).
var i := value_start
while i < text.length():
var ch := text[i]
if ch == "," or ch == "}" or ch == "]" or _is_json_ws(ch):
break
i += 1
return i
static func _is_json_ws(c: String) -> bool:
return c == " " or c == "\t" or c == "\n" or c == "\r"
@@ -0,0 +1 @@
uid://g8a4iijpk22w
+383
View File
@@ -0,0 +1,383 @@
@tool
class_name McpManualCommand
extends RefCounted
const SHELL_POSIX := "posix"
const SHELL_POWERSHELL := "powershell"
## Keep this intersection deliberately small. PowerShell treats a leading `@`
## as splatting syntax and commas as list separators, while POSIX shells accept
## both literally; quoting either is safer than trying to infer token position.
const _SHELL_BARE_SAFE_CHARS := "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_+=:./-"
## Synthesize the "Run this manually" string the dock surfaces when
## auto-configure can't find a CLI / write a file. Generated from the
## descriptor's declarative fields — there is no per-client builder
## Callable. See `_base.gd` for why descriptors are data-only.
static func build(
client: McpClient,
server_name: String,
server_url: String,
resolved_path: String,
launch: Dictionary = {},
project_roots: PackedStringArray = PackedStringArray(),
) -> String:
match client.config_type:
"cli":
return _build_cli(client, server_name, server_url, resolved_path, launch, project_roots)
"json":
return _build_json(client, server_name, server_url, resolved_path, launch, project_roots)
"toml":
return _build_toml(client, server_name, server_url, resolved_path, launch)
"yaml":
return _build_yaml(client, server_name, server_url, resolved_path, launch)
"dsh":
return _build_dsh(client, server_name, server_url, resolved_path, launch)
return ""
## CLI clients: format the register template against the *short* CLI name so
## the user can paste it into a terminal regardless of where their binary
## lives. (The auto-configure path resolves to an absolute uvx-style path;
## that's noise for a paste-into-terminal hint. The attach launcher path
## inside a command-shape line stays absolute — status verification compares
## the registered command against the resolved launcher verbatim.)
static func _build_cli(
client: McpClient,
server_name: String,
server_url: String,
resolved_path: String = "",
launch: Dictionary = {},
project_roots: PackedStringArray = PackedStringArray(),
) -> String:
if client.cli_register_template.is_empty() or client.cli_names.is_empty():
return ""
var shell_kind := _shell_kind_for_platform()
var short_name: String = String(client.cli_names[0])
# Prefer the non-.exe form for a cross-platform-looking command line.
for n in client.cli_names:
if not String(n).ends_with(".exe"):
short_name = String(n)
break
var cmd := ""
if client.command_shape != McpClient.CommandShape.NONE:
var launch_error := McpCliStrategy.command_launch_error(client, launch)
if not launch_error.is_empty():
cmd = "Attach launch command unavailable: %s" % launch_error
else:
var args := McpCliStrategy.format_args(client.cli_register_template, server_name, server_url, launch)
var parts: Array[String] = [short_name]
for arg in args:
parts.append(String(arg))
var lines := _sweep_lines(client, server_name, server_url, short_name, shell_kind)
lines.append(_render_command_line(parts, shell_kind))
cmd = _format_shell_block(lines, shell_kind) + _sweep_caveat(client)
else:
var args := McpCliStrategy.format_args(client.cli_register_template, server_name, server_url)
var parts: Array[String] = [short_name]
for arg in args:
parts.append(String(arg))
var lines := _sweep_lines(client, server_name, server_url, short_name, shell_kind)
lines.append(_render_command_line(parts, shell_kind))
cmd = _format_shell_block(lines, shell_kind) + _sweep_caveat(client)
# #463: a CLI client with a JSON fallback (Claude Code) may have no `claude`
# binary at all — e.g. installed only as a VS Code/Cursor extension. The CLI
# line above is useless to that user, so also show the config-file edit that
# auto-configure falls back to writing.
if client.has_json_fallback() and not resolved_path.is_empty():
return "%s\n\nNo `%s` CLI (e.g. installed as a VS Code/Cursor extension)? %s" % [
cmd, short_name, _build_json(client, server_name, server_url, resolved_path, launch, project_roots),
]
return cmd
## #877: Configure's first act is the pre-cleanup — it runs the unregister
## template once per scope the descriptor can write to (`_cli_strategy.gd`
## `configure`) before registering. Rendering only the register line made this
## hint disagree with what the button does, which is the worst property a
## "run this manually" string can have. The removes go in the SAME shell block
## as the register, in the order Configure runs them, so the whole thing stays
## one copy-paste rather than a labelled command plus loose prose.
static func _sweep_lines(
client: McpClient,
server_name: String,
server_url: String,
short_name: String,
shell_kind: String,
) -> Array[String]:
var lines: Array[String] = []
if client.cli_unregister_template.is_empty():
return lines
for pre_scope in McpCliStrategy.cleanup_scopes(client):
var args := McpCliStrategy.format_args(
client.cli_unregister_template, server_name, server_url, {}, pre_scope
)
var parts: Array[String] = [short_name]
for arg in args:
parts.append(String(arg))
lines.append(_render_command_line(parts, shell_kind))
return lines
## The one thing the commands themselves cannot show: that the project pass
## resolves `.mcp.json` against the CLI's cwd, so it can delete an entry from a
## repository that has nothing to do with this project. Only for `{scope}`
## descriptors — a single implicit-scope pass removes exactly the entry the
## register is about to rewrite, which needs no warning. Kept outside the shell
## block so pasting the block never picks up prose.
static func _sweep_caveat(client: McpClient) -> String:
if client.cli_unregister_template.is_empty() or not McpCliStrategy.uses_scope_token(client):
return ""
return (
"\n\nThe project-scope remove rewrites the .mcp.json in the directory the"
+ " editor was launched from — not necessarily this project — so it drops a"
+ " hand-maintained godot-ai entry there. Other servers in that file are left"
+ " alone."
)
static func _shell_kind_for_platform() -> String:
return SHELL_POWERSHELL if OS.get_name() == "Windows" else SHELL_POSIX
## Render a command for one explicitly named shell. The label is load-bearing:
## POSIX and PowerShell use different escaping for embedded single quotes, so
## presenting the command without its target shell invites a bad copy/paste.
static func _format_shell_command(parts: Array[String], shell_kind: String) -> String:
return _format_shell_block([_render_command_line(parts, shell_kind)], shell_kind)
## One label over N command lines. The label is per-block, not per-line:
## repeating "Run in PowerShell:" four times would bury the single line that
## actually registers.
static func _format_shell_block(lines: Array[String], shell_kind: String) -> String:
var label := "Run in PowerShell:" if shell_kind == SHELL_POWERSHELL else "Run in a POSIX shell:"
return "%s\n%s" % [label, "\n".join(lines)]
static func _render_command_line(parts: Array[String], shell_kind: String) -> String:
var rendered: Array[String] = []
for part in parts:
rendered.append(_shell_display_arg(part, shell_kind))
return " ".join(rendered)
## Quote one argv element for the paste-into-terminal hint. Single-quoted
## strings are literal in both supported shells, but embedded single quotes
## have shell-specific spellings. Backslashes, double quotes, dollar signs,
## and PowerShell backticks remain byte-for-byte unchanged inside the quotes.
static func _shell_display_arg(arg: String, shell_kind: String) -> String:
if arg.is_empty():
return "''"
var stays_bare := true
for index in range(arg.length()):
if _SHELL_BARE_SAFE_CHARS.find(arg.substr(index, 1)) < 0:
stays_bare = false
break
if stays_bare:
return arg
if shell_kind == SHELL_POWERSHELL:
return "'%s'" % arg.replace("'", "''")
return "'%s'" % arg.replace("'", "'\"'\"'")
static func _build_json(
client: McpClient,
server_name: String,
server_url: String,
resolved_path: String,
launch: Dictionary = {},
project_roots: PackedStringArray = PackedStringArray(),
) -> String:
var target := McpJsonStrategy.manual_target_details(client, server_name, resolved_path, project_roots)
var target_note := ""
if not target.get("ok", false):
target_note = "Target inspection failed: %s" % str(target.get("error", "cannot inspect config"))
target = {"path": resolved_path, "key_path": client.server_key_path}
var target_path := str(target.get("path", resolved_path))
var key_path: PackedStringArray = target.get("key_path", client.server_key_path)
var key := key_path[0] if key_path.size() > 0 else "mcpServers"
if client.command_shape != McpClient.CommandShape.NONE:
var lines: Array[String] = []
var launch_error := McpJsonStrategy.command_launch_error(client, launch)
if launch_error.is_empty():
var command_entry := McpJsonStrategy.build_entry(client, server_url, null, launch)
lines.append("Edit %s and add under \"%s\":" % [target_path, key])
lines.append(" \"%s\": %s" % [server_name, _format_entry_inline(command_entry)])
else:
lines.append("Attach launch command unavailable: %s" % launch_error)
if client.command_supports_url_fallback:
lines.append("")
lines.append("Advanced fallback — use this URL-mode entry instead; never configure both shapes together. URL mode depends on your client's own reconnect behavior. If the server is down when the client starts, restarting the client may be required.")
lines.append("Edit %s and add under \"%s\":" % [target_path, key])
var fallback_entry := McpJsonStrategy.build_url_entry(client, server_url)
lines.append(" \"%s\": %s" % [server_name, _format_entry_inline(fallback_entry)])
if not target_note.is_empty():
lines.append("")
lines.append(target_note)
return "\n".join(lines)
var entry := McpJsonStrategy.build_entry(client, server_url)
var instructions := "Edit %s and add under \"%s\":\n \"%s\": %s" % [target_path, key, server_name, _format_entry_inline(entry)]
if not target_note.is_empty():
instructions += "\n\n" + target_note
return instructions
static func _build_toml(
client: McpClient,
_server_name: String,
server_url: String,
resolved_path: String,
launch: Dictionary = {},
) -> String:
var header := _toml_header(client)
if client.command_shape != McpClient.CommandShape.NONE:
var lines: Array[String] = []
var rendered := McpTomlStrategy.render_body(client, server_url, launch)
if bool(rendered.get("ok", false)):
lines.append("Edit %s and add:" % resolved_path)
lines.append(" %s" % header)
for body_line in rendered.get("lines", []):
lines.append(" %s" % str(body_line))
else:
lines.append("Attach launch command unavailable: %s" % str(rendered.get("error", "no compatible launcher found")))
if client.command_supports_url_fallback:
lines.append("")
lines.append("Advanced fallback — replace the command/args block above with this URL-mode block; never configure both shapes together. URL mode depends on your client's own reconnect behavior. If the server is down when the client starts, restarting the client may be required.")
lines.append("Edit %s and add:" % resolved_path)
lines.append(" %s" % header)
lines.append(" url = %s" % McpTomlStrategy.encode_basic_string(server_url))
return "\n".join(lines)
var body := McpTomlStrategy.format_body(client.toml_body_template, server_url)
var lines: Array[String] = ["Edit %s and add:" % resolved_path, " %s" % header]
for b in body:
lines.append(" %s" % String(b))
return "\n".join(lines)
static func _build_yaml(
client: McpClient,
server_name: String,
server_url: String,
resolved_path: String,
launch: Dictionary = {},
) -> String:
var key := client.server_key_path[0] if client.server_key_path.size() > 0 else "mcp_servers"
if client.command_shape != McpClient.CommandShape.NONE:
var lines: Array[String] = []
var launch_error := McpYamlStrategy.command_launch_error(client, launch)
if launch_error.is_empty():
var command_entry := McpYamlStrategy.build_entry(client, server_url, null, launch)
lines.append("Edit %s and add under '%s':" % [resolved_path, key])
for entry_line in McpYamlStrategy.render_entry_lines(server_name, command_entry):
lines.append(String(entry_line))
else:
lines.append("Attach launch command unavailable: %s" % launch_error)
if client.command_supports_url_fallback:
lines.append("")
lines.append("Advanced fallback — use this URL-mode entry instead; never configure both shapes together. URL mode depends on your client's own reconnect behavior. If the server is down when the client starts, restarting the client may be required.")
lines.append("Edit %s and add under '%s':" % [resolved_path, key])
var fallback_entry := {client.entry_url_field: server_url}
for entry_line in McpYamlStrategy.render_entry_lines(server_name, fallback_entry):
lines.append(String(entry_line))
return "\n".join(lines)
var entry := McpYamlStrategy.build_entry(client, server_url)
var lines: Array[String] = [
"Edit %s and add under '%s':" % [resolved_path, key],
" %s:" % server_name,
]
for k in entry:
lines.append(" %s: %s" % [k, str(entry[k])])
return "\n".join(lines)
## DeepSeek Harness: the user pastes a loader `insert` row into the home
## patch layer (`$DSH_HOME/cordis.patch.yml`). Auto-configure writes exactly
## these lines, so the manual text matches the file Configure would produce
## byte-for-byte.
static func _build_dsh(
client: McpClient,
server_name: String,
server_url: String,
resolved_path: String,
launch: Dictionary = {},
) -> String:
var lines: Array[String] = ["Edit %s and add this loader entry (an `insert` row; a plain `- id:` row only overrides an existing bundle id and would be skipped):" % resolved_path]
var entry_id_value := McpDshStrategy.entry_id(server_name)
if client.command_shape != McpClient.CommandShape.NONE:
var launch_error := McpDshStrategy.command_launch_error(client, launch)
if launch_error.is_empty():
var command_entry := McpDshStrategy.build_entry(client, server_name, server_url, null, launch)
for row_line in McpDshStrategy.render_insert_row(entry_id_value, command_entry):
lines.append(String(row_line))
else:
lines.append("Attach launch command unavailable: %s" % launch_error)
if client.command_supports_url_fallback:
lines.append("")
## No command/args block was rendered on the launch-error path, so
## "replace the block above" would point at nothing.
var fallback_lead := (
"replace the command/args block above with this URL-mode entry"
if launch_error.is_empty()
else "use this URL-mode entry instead"
)
lines.append("Advanced fallback — %s; never configure both shapes together. URL mode depends on the harness' own reconnect behavior. If the server is down when the harness starts, restarting the harness may be required." % fallback_lead)
var fallback_entry := McpDshStrategy.build_url_entry(client, server_name, server_url)
for row_line in McpDshStrategy.render_insert_row(entry_id_value, fallback_entry):
lines.append(String(row_line))
return "\n".join(lines)
var url_entry := McpDshStrategy.build_url_entry(client, server_name, server_url)
for row_line in McpDshStrategy.render_insert_row(entry_id_value, url_entry):
lines.append(String(row_line))
return "\n".join(lines)
## Mirrors the [section."name"] header `_toml_strategy._primary_header`
## emits, kept here so the manual-command text matches the file we'd write.
static func _toml_header(client: McpClient) -> String:
var parts := client.toml_section_path
if parts.size() < 2:
return "[%s]" % ".".join(parts)
var section := ".".join(McpClient._array_from_packed(McpClient._packed_slice(parts, 0, parts.size() - 1)))
var name := parts[parts.size() - 1]
return "[%s.\"%s\"]" % [section, name]
## Format an entry dict as a single inline JSON-ish string, matching the
## pre-refactor manual-command style: `{ "k": v, "k": v }` with spaces.
## Pre-existing manual-command tests assert the exact substring shape; this
## keeps them stable.
##
## Uses `JSON.stringify` for every leaf String (key OR value) so paths
## containing backslashes / quotes / newlines render as syntactically valid
## JSON. A Windows uvx path like `C:\Users\foo\uvx.exe` would otherwise be
## emitted as `"C:\Users\foo\uvx.exe"` — invalid JSON, unsafe to paste.
static func _format_entry_inline(entry: Dictionary) -> String:
var parts: Array[String] = []
for k in entry:
parts.append("%s: %s" % [JSON.stringify(String(k)), _format_value(entry[k])])
if parts.is_empty():
return "{}"
return "{ %s }" % ", ".join(parts)
static func _format_value(value: Variant) -> String:
# Strings, bools, numbers, null all round-trip correctly through JSON.stringify
# without spurious quoting of non-string scalars (true → `true`, 5 → `5`).
# Arrays and Dictionaries are formatted manually so the inline ` { k: v } `
# spacing matches the pre-refactor manual-command output shape that tests
# pin with assert_contains.
if value is Array:
var arr_parts: Array[String] = []
for v in value:
arr_parts.append(_format_value(v))
return "[%s]" % ", ".join(arr_parts)
if value is Dictionary:
var d_parts: Array[String] = []
for k in value:
d_parts.append("%s: %s" % [JSON.stringify(String(k)), _format_value(value[k])])
if d_parts.is_empty():
return "{}"
return "{ %s }" % ", ".join(d_parts)
return JSON.stringify(value)
@@ -0,0 +1 @@
uid://ct1wmgfk408x0
+206
View File
@@ -0,0 +1,206 @@
@tool
class_name McpPathTemplate
extends RefCounted
## Expands ~ / $HOME / $APPDATA / $XDG_CONFIG_HOME / $LOCALAPPDATA / $USERPROFILE
## inside path templates so per-client descriptors can declare paths declaratively
## without hand-rolling per-OS lookups.
## #691: dock worker threads (client-status refresh, configure/remove
## actions) and the #678 startup walk's discovery worker expand these
## templates off the main thread, while the spawn step mutates the
## process-global environment around `OS.create_process`
## (`GODOT_AI_OWNER_PID`, `GODOT_AI_PLUGIN_SPAWNED`, `PYTHONPATH`,
## `GODOT_AI_DISABLE_TELEMETRY`). A glibc `getenv` racing a concurrent
## `setenv` can return a freed pointer — rare but process-fatal. All env
## reads in this layer therefore go through `env_lookup`: on the MAIN
## thread it reads live and refreshes a mutex-guarded snapshot; off the
## main thread it serves from the snapshot, so no `OS.get_environment`
## runs concurrently with the spawn window's mutations. Callers pre-warm
## every var their workers can touch via `warm_env_snapshot` (plugin
## `_enter_tree` and the dock's phase-1 refresh prep, both main-thread,
## both before any worker starts).
static var _env_snapshot := {}
static var _env_snapshot_mutex := Mutex.new()
## Every var this layer and its sibling consumers (`_base.gd`
## `config_file_override_details`, `config_home_override`, `_cli_finder.gd` lookups,
## `client_configurator.gd` mode/trace reads) can touch off-main.
## Descriptor-declared config-file/config-home env names are passed as extras
## by the warm callers.
const _BASE_ENV_VARS: Array[String] = [
"HOME",
"USERPROFILE",
"XDG_CONFIG_HOME",
"APPDATA",
"LOCALAPPDATA",
"SHELL",
"ProgramFiles",
"GODOT_AI_MODE",
"GODOT_AI_STARTUP_TRACE",
## #804 (#752 adoption): _find_venv_python reads this via env_lookup on
## the dock's worker path; without pre-warming, a set override reads as
## empty there and is silently ignored — the exact misconfiguration the
## push_warning in client_configurator.gd exists to surface.
"GODOT_AI_VENV_PYTHON",
]
## Thread-safe env read (#691). Main thread: live read + snapshot refresh.
## Worker thread: snapshot only, so it can never race a main-thread
## setenv/unsetenv. A worker read of a never-warmed var returns "" — the
## same value an unset var reads as — never a live OS.get_environment,
## which would reintroduce the race for exactly the vars nobody thought
## to warm. Missing warm-up degrades resolution; it must not touch the
## process-global environment off-main.
static func env_lookup(name: String) -> String:
if OS.get_thread_caller_id() == OS.get_main_thread_id():
var live := OS.get_environment(name)
_env_snapshot_mutex.lock()
_env_snapshot[name] = live
_env_snapshot_mutex.unlock()
return live
_env_snapshot_mutex.lock()
var cached: Variant = _env_snapshot.get(name, null)
_env_snapshot_mutex.unlock()
if cached != null:
return str(cached)
return ""
## Main-thread pre-warm so subsequent worker reads never touch the real
## environment. Idempotent; safe to call before every worker dispatch.
static func warm_env_snapshot(extra_vars: PackedStringArray = PackedStringArray()) -> void:
for var_name in _BASE_ENV_VARS:
env_lookup(var_name)
for var_name in extra_vars:
if not String(var_name).is_empty():
env_lookup(String(var_name))
## Pick the right entry from a {"darwin": ..., "windows": ..., "linux": ...} map.
static func resolve(template_map: Dictionary) -> String:
var key := platform_key(template_map)
if key.is_empty():
return ""
var template: String = template_map[key]
return expand(template)
## Return the platform-specific key present in a descriptor map. `unix` is a
## shorthand for macOS and Linux. Public so descriptors can use the same
## platform selection for ordered path-candidate arrays as for one path.
static func platform_key(template_map: Dictionary) -> String:
var key := _os_key()
if template_map.has(key):
return key
if (key == "darwin" or key == "linux") and template_map.has("unix"):
return "unix"
return ""
## Expand one path template into zero or more concrete paths. A single `*` is
## allowed inside one DIRECTORY segment (for example `Packages/Claude_*`). The
## wildcard is resolved by enumerating that segment's parent; the remaining
## suffix may name a file that does not exist yet, which lets callers derive a
## deterministic create target for a fresh packaged-app install.
##
## Multiple wildcards fail closed and return no candidates. A wildcard final
## segment may identify an installation directory for `detect_paths`. Returned
## paths are sorted for deterministic tests and diagnostics; callers still
## reject ambiguous config groups rather than picking one.
static func expand_path_candidates(template: String) -> PackedStringArray:
var expanded := expand(template)
if expanded.is_empty():
return PackedStringArray()
var star := expanded.find("*")
if star < 0:
return PackedStringArray([expanded])
if expanded.find("*", star + 1) >= 0:
return PackedStringArray()
var slash_before := maxi(expanded.rfind("/", star), expanded.rfind("\\", star))
var forward_after := expanded.find("/", star)
var backward_after := expanded.find("\\", star)
var slash_after := forward_after
if slash_after < 0 or (backward_after >= 0 and backward_after < slash_after):
slash_after = backward_after
if slash_before < 0:
return PackedStringArray()
var parent := expanded.substr(0, slash_before)
var pattern := (
expanded.substr(slash_before + 1)
if slash_after < 0
else expanded.substr(slash_before + 1, slash_after - slash_before - 1)
)
var suffix := "" if slash_after < 0 else expanded.substr(slash_after + 1)
var pattern_star := pattern.find("*")
if pattern_star < 0:
return PackedStringArray()
var prefix := pattern.substr(0, pattern_star)
var ending := pattern.substr(pattern_star + 1)
var dir := DirAccess.open(parent)
if dir == null:
return PackedStringArray()
var matches := PackedStringArray()
for child in dir.get_directories():
if _wildcard_segment_matches(String(child), prefix, ending):
var matched_path := parent.path_join(String(child))
matches.append(matched_path if suffix.is_empty() else matched_path.path_join(suffix))
matches.sort()
return matches
## Substitute env vars and ~ in a single template string.
static func expand(template: String) -> String:
if template.is_empty():
return ""
var out := template
if out.begins_with("~/") or out == "~":
var home := _home()
out = home if out == "~" else home.path_join(out.substr(2))
# $HOME, $APPDATA, $LOCALAPPDATA, $USERPROFILE, $XDG_CONFIG_HOME
for var_name in ["XDG_CONFIG_HOME", "LOCALAPPDATA", "USERPROFILE", "APPDATA", "HOME"]:
var token := "$%s" % var_name
if out.find(token) >= 0:
var value := env_lookup(var_name)
if value.is_empty() and var_name == "XDG_CONFIG_HOME":
value = _home().path_join(".config")
if value.is_empty() and var_name == "APPDATA":
value = _home().path_join("AppData/Roaming")
if value.is_empty() and var_name == "LOCALAPPDATA":
value = _home().path_join("AppData/Local")
if value.is_empty() and var_name == "HOME":
value = _home()
out = out.replace(token, value)
return out
static func _os_key() -> String:
match OS.get_name():
"macOS":
return "darwin"
"Windows":
return "windows"
_:
return "linux"
static func _wildcard_segment_matches(value: String, prefix: String, ending: String) -> bool:
# Prefix/suffix tests alone allow the two fixed portions to overlap inside a
# too-short value. Glob semantics require room for both portions even when
# `*` matches an empty string.
if value.length() < prefix.length() + ending.length():
return false
if OS.get_name() == "Windows":
return value.to_lower().begins_with(prefix.to_lower()) and value.to_lower().ends_with(ending.to_lower())
return value.begins_with(prefix) and value.ends_with(ending)
static func _home() -> String:
var h := env_lookup("HOME")
if h.is_empty():
h = env_lookup("USERPROFILE")
return h
@@ -0,0 +1 @@
uid://5pd418va35ms
+156
View File
@@ -0,0 +1,156 @@
@tool
class_name McpClientRegistry
extends RefCounted
## Central enumeration of every supported MCP client. Adding a new client
## means: drop a file in clients/, then append one path below.
##
## Paths, not preloads (#736): a preload array pulled all client descriptor
## scripts into the boot-time compile closure of everything that preloads
## this registry (plugin.gd via client_configurator.gd and mcp_dock.gd),
## stalling "Initializing plugins" on every editor boot. Descriptors are
## only needed when the dock refreshes client statuses or a client_*
## command runs, so they load lazily on first registry access.
const _CLIENT_SCRIPT_PATHS := [
"res://addons/godot_ai/clients/claude_code.gd",
"res://addons/godot_ai/clients/claude_desktop.gd",
"res://addons/godot_ai/clients/codex.gd",
"res://addons/godot_ai/clients/grok.gd",
"res://addons/godot_ai/clients/antigravity.gd",
"res://addons/godot_ai/clients/cursor.gd",
"res://addons/godot_ai/clients/windsurf.gd",
"res://addons/godot_ai/clients/vscode.gd",
"res://addons/godot_ai/clients/vscode_insiders.gd",
"res://addons/godot_ai/clients/zed.gd",
"res://addons/godot_ai/clients/gemini_cli.gd",
"res://addons/godot_ai/clients/cline.gd",
"res://addons/godot_ai/clients/kilo_code.gd",
"res://addons/godot_ai/clients/roo_code.gd",
"res://addons/godot_ai/clients/zoo_code.gd",
"res://addons/godot_ai/clients/kiro.gd",
"res://addons/godot_ai/clients/trae.gd",
"res://addons/godot_ai/clients/cherry_studio.gd",
"res://addons/godot_ai/clients/opencode.gd",
"res://addons/godot_ai/clients/qwen_code.gd",
"res://addons/godot_ai/clients/kimi_code.gd",
"res://addons/godot_ai/clients/hermes.gd",
"res://addons/godot_ai/clients/pi.gd",
"res://addons/godot_ai/clients/deepseek_harness.gd",
]
static var _instances: Array[McpClient] = []
static var _by_id: Dictionary = {}
## First registry access can come from the dock's client-status refresh
## worker thread while the main thread hits it via a client_* command —
## serialize the one-time load so a racing thread can never observe a
## half-built registry. load() itself is thread-safe via ResourceLoader.
static var _load_mutex := Mutex.new()
## True when even a fresh rebuild yields instances missing base-schema
## fields — the deep stale-script state after an in-session self-update
## (#850; docs/releasing.md release-shape rules). Only an editor restart
## heals it; callers surface RESTART_TO_FINISH_UPDATE instead of erroring
## per client. Never reset within a session: rebuilding again cannot help,
## it would only repeat the load work and warning on every dock sweep.
static var _stale_session := false
const RESTART_TO_FINISH_UPDATE := (
"Godot AI was updated in this editor session. Restart the editor to finish the update."
)
static func all() -> Array[McpClient]:
_ensure_loaded()
return _instances
static func get_by_id(id: String) -> McpClient:
_ensure_loaded()
return _by_id.get(id, null)
static func ids() -> PackedStringArray:
var out := PackedStringArray()
for c in all():
out.append(c.id)
return out
static func has_id(id: String) -> bool:
_ensure_loaded()
return _by_id.has(id)
## True when this editor session is running a self-update whose script
## reloads left descriptor state unusable. Client operations short-circuit
## with RESTART_TO_FINISH_UPDATE rather than spamming per-field errors.
static func stale_session_detected() -> bool:
_ensure_loaded()
return _stale_session
## An instance is coherent when fields added to the CURRENT McpClient schema
## read back with their declared types. After an in-session self-update,
## hot-patched or pre-update instances answer Nil for vars the update added
## (#850: `config_path_candidates` and
## `config_file_env` read as Nil, crashing platform_key / String()). The
## reflected `get()` avoids typed-access errors on such instances.
static func _instance_is_coherent(inst: Object) -> bool:
if inst == null:
return false
return (
inst.get("config_path_candidates") is Dictionary
and inst.get("config_file_env") is String
and inst.get("path_template") is Dictionary
and inst.get("server_key_path_aliases") is Array
and inst.get("config_merge_path_templates") is Dictionary
and inst.get("config_merge_project_paths") is PackedStringArray
)
static func _cache_is_coherent() -> bool:
return not _instances.is_empty() and _instance_is_coherent(_instances[0])
static func _ensure_loaded() -> void:
if _stale_session:
return
if _cache_is_coherent():
return
_load_mutex.lock()
## Re-check under the lock: another thread may have rebuilt (or concluded
## staleness) while this one waited.
if not _stale_session and not _cache_is_coherent():
## Covers both the first load and the post-self-update rebuild: this
## registry file can survive an update unchanged, so its statics keep
## serving pre-update instances to freshly reloaded callers. A rebuild
## instantiates from the reloaded descriptor scripts, which repairs
## every case except a stale base Script object itself.
_load()
if not _instances.is_empty() and not _cache_is_coherent():
_stale_session = true
push_warning("MCP | %s" % RESTART_TO_FINISH_UPDATE)
_load_mutex.unlock()
static func _load() -> void:
## Build into locals and publish whole containers last, so the lock-free
## fast path in _ensure_loaded can never see a partially-filled registry.
var instances: Array[McpClient] = []
var by_id: Dictionary = {}
for path in _CLIENT_SCRIPT_PATHS:
var script := load(path) as GDScript
if script == null:
push_warning("MCP | failed to load client descriptor %s" % path)
continue
var inst: McpClient = script.new()
if inst.id.is_empty():
push_warning("MCP | client descriptor %s has empty id" % path)
continue
if by_id.has(inst.id):
push_warning("MCP | duplicate client id: %s" % inst.id)
continue
instances.append(inst)
by_id[inst.id] = inst
_by_id = by_id
_instances = instances
+1
View File
@@ -0,0 +1 @@
uid://bxougoq8xwg1
+730
View File
@@ -0,0 +1,730 @@
@tool
class_name McpTomlStrategy
extends RefCounted
## TOML upsert for URL entries and client-owned command entries.
##
## This remains deliberately smaller than a general TOML parser, but the
## parts that affect migration are semantic: assignments retain their whole
## value span (including multiline arrays/strings), and command/args status
## verification decodes TOML strings and arrays rather than comparing text.
static func configure(
client: McpClient,
_server_name: String,
server_url: String,
launch: Dictionary = {},
) -> Dictionary:
var resolution := client.resolved_config_path_details()
var path := str(resolution.get("path", ""))
var path_error := str(resolution.get("error", ""))
if not path_error.is_empty():
return {"status": "error", "message": path_error}
if path.is_empty():
return {"status": "error", "message": "Could not resolve config path for %s" % client.display_name}
var seed_path := str(resolution.get("seed_path", ""))
var read_path := seed_path if not FileAccess.file_exists(path) and not seed_path.is_empty() else path
var read := _read_or_init(read_path)
if not read["ok"]:
return {"status": "error", "message": "Refusing to overwrite %s: %s. Fix or move the file, then re-run Configure." % [read_path, read["error"]]}
var rendered := render_body(client, server_url, launch)
if not bool(rendered.get("ok", false)):
return {"status": "error", "message": str(rendered.get("error", "Could not build the TOML entry."))}
var lines: Array[String] = _split_lines(String(read["data"]))
var body: Array[String] = rendered["lines"]
var pinned_keys: Dictionary = rendered["pinned_keys"]
var initial_keys: Dictionary = rendered["initial_keys"]
var removed_keys: Dictionary = rendered["removed_keys"]
var section := _find_section(lines, _all_headers(client))
var header := _primary_header(client)
var new_lines: Array[String] = [header]
if section.is_empty():
new_lines.append_array(body)
var output_fresh: Array[String] = []
output_fresh.append_array(lines)
if not output_fresh.is_empty() and not output_fresh[-1].strip_edges().is_empty():
output_fresh.append("")
output_fresh.append_array(new_lines)
if not McpAtomicWrite.write(path, "\n".join(output_fresh)):
return {"status": "error", "message": "Cannot write to %s" % path}
return {"status": "ok", "message": McpClient.configured_message(client, server_url)}
var old_items := _value_items(lines, int(section["start"]) + 1, int(section["end"]))
var old_by_key := {}
for item in old_items:
var old_key := str(item.get("key", ""))
if not old_key.is_empty() and not old_by_key.has(old_key):
old_by_key[old_key] = item
var body_items := _value_items(body, 0, body.size())
var emitted_keys := {}
for item in body_items:
var key := str(item.get("key", ""))
if key.is_empty():
new_lines.append_array(item["lines"])
continue
emitted_keys[key] = true
if initial_keys.has(key) and old_by_key.has(key):
new_lines.append_array(old_by_key[key]["lines"])
else:
## Pinned keys always use the freshly rendered span. A generated key
## that is neither pinned nor initial is also rendered deterministically.
new_lines.append_array(item["lines"])
## Carry unknown/user-owned assignments and standalone comments verbatim.
## Whole item spans prevent multiline arrays/strings from being truncated.
for item in old_items:
var key := str(item.get("key", ""))
if not key.is_empty():
if emitted_keys.has(key) or pinned_keys.has(key) or removed_keys.has(key):
continue
new_lines.append_array(item["lines"])
continue
var item_lines: Array = item.get("lines", [])
for line in item_lines:
if not str(line).strip_edges().is_empty():
new_lines.append(str(line))
var output: Array[String] = []
output.append_array(_slice(lines, 0, int(section["start"])))
output.append_array(new_lines)
output.append_array(_slice(lines, int(section["end"]), lines.size()))
output = _rewrite_legacy_descendant_headers(output, client)
if not McpAtomicWrite.write(path, "\n".join(output)):
return {"status": "error", "message": "Cannot write to %s" % path}
return {"status": "ok", "message": McpClient.configured_message(client, server_url)}
static func check_status(
client: McpClient,
server_name: String,
server_url: String,
launch: Dictionary = {},
) -> McpClient.Status:
return check_status_details(client, server_name, server_url, launch).get("status", McpClient.Status.NOT_CONFIGURED)
static func check_status_details(
client: McpClient,
_server_name: String,
server_url: String,
launch: Dictionary = {},
) -> Dictionary:
var resolution := client.resolved_config_path_details()
var path := str(resolution.get("path", ""))
var path_error := str(resolution.get("error", ""))
if not path_error.is_empty():
return {"status": McpClient.Status.ERROR, "error_msg": path_error}
if path.is_empty() or not FileAccess.file_exists(path):
return {"status": McpClient.Status.NOT_CONFIGURED, "error_msg": ""}
var read := _read_or_init(path)
if not read["ok"]:
return {"status": McpClient.Status.ERROR, "error_msg": String(read["error"])}
var lines: Array[String] = _split_lines(String(read["data"]))
var section := _find_section(lines, _all_headers(client))
if section.is_empty():
return {"status": McpClient.Status.NOT_CONFIGURED, "error_msg": ""}
var items := _value_items(lines, int(section["start"]) + 1, int(section["end"]))
var by_key := {}
for item in items:
var key := str(item.get("key", ""))
if not key.is_empty() and not by_key.has(key):
by_key[key] = item
if client.command_shape != McpClient.CommandShape.NONE:
if not bool(launch.get("ok", false)):
return {
"status": McpClient.Status.ERROR,
"error_msg": str(launch.get("error", "No compatible attach launcher was found.")),
}
for legacy_key in client.command_legacy_keys:
if by_key.has(String(legacy_key)):
return {"status": McpClient.Status.CONFIGURED_MISMATCH, "error_msg": ""}
if not by_key.has("command") or not by_key.has("args"):
return {"status": McpClient.Status.CONFIGURED_MISMATCH, "error_msg": ""}
var command_value := _decode_toml_string(_item_value(by_key["command"]))
var args_value := _decode_toml_string_array(_item_value(by_key["args"]))
if not bool(command_value.get("ok", false)) or not bool(args_value.get("ok", false)):
return {"status": McpClient.Status.CONFIGURED_MISMATCH, "error_msg": ""}
if str(command_value.get("value", "")) != str(launch.get("command", "")):
return {"status": McpClient.Status.CONFIGURED_MISMATCH, "error_msg": ""}
if not _string_arrays_equal(args_value.get("value", []), launch.get("args", [])):
return {"status": McpClient.Status.CONFIGURED_MISMATCH, "error_msg": ""}
if not client.command_transport_key.is_empty():
var transport_key := client.command_transport_key
if not by_key.has(transport_key):
return {"status": McpClient.Status.CONFIGURED_MISMATCH, "error_msg": ""}
var decoded_transport := _decode_toml_scalar(_item_value(by_key[transport_key]))
if not bool(decoded_transport.get("ok", false)) or decoded_transport.get("value") != client.command_transport_value:
return {"status": McpClient.Status.CONFIGURED_MISMATCH, "error_msg": ""}
return {"status": McpClient.Status.CONFIGURED, "error_msg": ""}
if not by_key.has("url"):
return {"status": McpClient.Status.CONFIGURED_MISMATCH, "error_msg": ""}
var url_value := _decode_toml_string(_item_value(by_key["url"]))
if not bool(url_value.get("ok", false)) or str(url_value.get("value", "")) != server_url:
return {"status": McpClient.Status.CONFIGURED_MISMATCH, "error_msg": ""}
return {"status": McpClient.Status.CONFIGURED, "error_msg": ""}
static func remove(client: McpClient, _server_name: String) -> Dictionary:
var resolution := client.resolved_config_path_details()
var path := str(resolution.get("path", ""))
var path_error := str(resolution.get("error", ""))
if not path_error.is_empty():
return {"status": "error", "message": path_error}
if path.is_empty() or not FileAccess.file_exists(path):
return {"status": "ok", "message": "Not configured"}
var read := _read_or_init(path)
if not read["ok"]:
return {"status": "error", "message": "Refusing to rewrite %s: %s." % [path, read["error"]]}
var lines: Array[String] = _split_lines(String(read["data"]))
var headers := _all_headers(client)
var subtable_prefixes := _subtable_prefixes(headers)
var output: Array[String] = []
var i := 0
while i < lines.size():
if _matches_any_header(lines[i], headers) or _matches_subtable_prefix(lines[i], subtable_prefixes):
i += 1
while i < lines.size() and not _is_any_section_header(lines[i]):
i += 1
continue
output.append(lines[i])
i += 1
if not McpAtomicWrite.write(path, "\n".join(output)):
return {"status": "error", "message": "Cannot write to %s" % path}
return {"status": "ok", "message": "%s configuration removed" % client.display_name}
## Substitute `{url}` in every legacy URL body-template line.
static func format_body(template: PackedStringArray, server_url: String) -> PackedStringArray:
var out := PackedStringArray()
for line in template:
out.append(String(line).replace("{url}", server_url))
return out
## Encode a TOML basic string. This is intentionally public for the
## cross-language fixture test that parses the rendered sample with tomllib.
static func encode_basic_string(value: String) -> String:
return '"%s"' % value.replace("\\", "\\\\").replace('"', '\\"').replace("\b", "\\b").replace("\t", "\\t").replace("\n", "\\n").replace("\f", "\\f").replace("\r", "\\r")
## Multi-line string-array encoding used by command-shape entries.
static func encode_string_array(values: Variant) -> Array[String]:
var out: Array[String] = ["["]
for value in values:
out.append(" %s," % encode_basic_string(str(value)))
out.append("]")
return out
static func render_body(client: McpClient, server_url: String, launch: Dictionary) -> Dictionary:
if client.command_shape == McpClient.CommandShape.NONE:
if client.toml_body_template.is_empty():
return {"ok": false, "error": "%s descriptor missing toml_body_template" % client.display_name}
var legacy_body := format_body(client.toml_body_template, server_url)
var legacy_lines: Array[String] = []
var pinned := {}
var initial := {}
for idx in range(legacy_body.size()):
legacy_lines.append(String(legacy_body[idx]))
var key := _line_key(String(client.toml_body_template[idx]))
if key.is_empty():
continue
if String(client.toml_body_template[idx]).contains("{url}"):
pinned[key] = true
else:
initial[key] = true
return {
"ok": true,
"lines": legacy_lines,
"pinned_keys": pinned,
"initial_keys": initial,
"removed_keys": {},
}
if client.command_shape != McpClient.CommandShape.COMMAND_ARRAY:
return {"ok": false, "error": "%s uses a command shape not supported by TOML yet" % client.display_name}
if not bool(launch.get("ok", false)):
return {"ok": false, "error": str(launch.get("error", "No compatible attach launcher was found."))}
var command_lines: Array[String] = [
"command = %s" % encode_basic_string(str(launch.get("command", ""))),
]
command_lines.append_array(_encode_assignment("args", launch.get("args", [])))
var pinned_keys := {"command": true, "args": true}
if not client.command_transport_key.is_empty():
var encoded_transport := _encode_scalar(client.command_transport_value)
if encoded_transport.is_empty():
return {
"ok": false,
"error": "Unsupported TOML transport `%s` for %s" % [
client.command_transport_key, client.display_name,
],
}
command_lines.append("%s = %s" % [client.command_transport_key, encoded_transport])
pinned_keys[client.command_transport_key] = true
var initial_keys := {}
for key in client.command_initial_fields:
var encoded := _encode_assignment(str(key), client.command_initial_fields[key])
if encoded.is_empty():
return {"ok": false, "error": "Unsupported TOML default `%s` for %s" % [key, client.display_name]}
command_lines.append_array(encoded)
initial_keys[str(key)] = true
var removed_keys := {}
for key in client.command_legacy_keys:
removed_keys[String(key)] = true
return {
"ok": true,
"lines": command_lines,
"pinned_keys": pinned_keys,
"initial_keys": initial_keys,
"removed_keys": removed_keys,
}
static func _encode_assignment(key: String, value: Variant) -> Array[String]:
if value is Array or value is PackedStringArray:
var lines := encode_string_array(value)
lines[0] = "%s = %s" % [key, lines[0]]
return lines
var encoded := _encode_scalar(value)
var lines: Array[String] = []
if not encoded.is_empty():
lines.append("%s = %s" % [key, encoded])
return lines
static func _encode_scalar(value: Variant) -> String:
if value is String:
return encode_basic_string(value)
if value is bool:
return "true" if value else "false"
if value is int or value is float:
return str(value)
return ""
# --- span-aware merge helpers -------------------------------------------
static func _value_items(lines: Array[String], from: int, to: int) -> Array[Dictionary]:
var out: Array[Dictionary] = []
var i := from
while i < to:
var key := _line_key(lines[i])
if key.is_empty():
out.append({"key": "", "lines": [lines[i]]})
i += 1
continue
var end := _value_span_end(lines, i, to)
out.append({"key": key, "lines": _slice(lines, i, end)})
i = end
return out
static func _value_span_end(lines: Array[String], start: int, limit: int) -> int:
var state := {"quote": "", "square": 0, "curly": 0, "escaped": false}
for i in range(start, limit):
var begin := 0
if i == start:
var eq := _assignment_equal(lines[i])
begin = eq + 1 if eq >= 0 else 0
_scan_toml_value_line(lines[i], begin, state)
if str(state["quote"]).is_empty() and int(state["square"]) == 0 and int(state["curly"]) == 0:
return i + 1
return limit
static func _scan_toml_value_line(line: String, begin: int, state: Dictionary) -> void:
var i := begin
while i < line.length():
var quote := str(state["quote"])
if quote == '"""' or quote == "'''":
if line.substr(i).begins_with(quote):
state["quote"] = ""
i += 3
continue
if quote == '"""' and line.unicode_at(i) == 92 and not bool(state["escaped"]):
state["escaped"] = true
i += 1
continue
state["escaped"] = false
i += 1
continue
if quote == '"' or quote == "'":
var c := line.unicode_at(i)
if quote == '"' and c == 92 and not bool(state["escaped"]):
state["escaped"] = true
i += 1
continue
if c == quote.unicode_at(0) and not bool(state["escaped"]):
state["quote"] = ""
state["escaped"] = false
i += 1
continue
if line.substr(i).begins_with('"""'):
state["quote"] = '"""'
i += 3
continue
if line.substr(i).begins_with("'''"):
state["quote"] = "'''"
i += 3
continue
var c := line.unicode_at(i)
if c == 34:
state["quote"] = '"'
elif c == 39:
state["quote"] = "'"
elif c == 35:
break
elif c == 91:
state["square"] = int(state["square"]) + 1
elif c == 93:
state["square"] = maxi(0, int(state["square"]) - 1)
elif c == 123:
state["curly"] = int(state["curly"]) + 1
elif c == 125:
state["curly"] = maxi(0, int(state["curly"]) - 1)
i += 1
static func _line_key(line: String) -> String:
var eq := _assignment_equal(line)
if eq <= 0:
return ""
return line.substr(0, eq).strip_edges()
static func _assignment_equal(line: String) -> int:
var quote := 0
var escaped := false
for i in range(line.length()):
var c := line.unicode_at(i)
if quote != 0:
if quote == 34 and c == 92 and not escaped:
escaped = true
continue
if c == quote and not escaped:
quote = 0
escaped = false
continue
if c == 34 or c == 39:
quote = c
elif c == 35:
return -1
elif c == 61:
return i
return -1
# --- semantic value decoding --------------------------------------------
static func _item_value(item: Dictionary) -> String:
var item_lines: Array = item.get("lines", [])
if item_lines.is_empty():
return ""
var first := str(item_lines[0])
var eq := _assignment_equal(first)
if eq < 0:
return ""
var parts: Array[String] = [first.substr(eq + 1)]
for i in range(1, item_lines.size()):
parts.append(str(item_lines[i]))
return "\n".join(parts)
static func _decode_toml_scalar(raw: String) -> Dictionary:
var cleaned := _without_comments(raw).strip_edges()
if cleaned == "true":
return {"ok": true, "value": true}
if cleaned == "false":
return {"ok": true, "value": false}
var string_value := _decode_toml_string(cleaned)
if bool(string_value.get("ok", false)):
return string_value
if cleaned.is_valid_int():
return {"ok": true, "value": cleaned.to_int()}
if cleaned.is_valid_float():
return {"ok": true, "value": cleaned.to_float()}
return {"ok": false}
static func _decode_toml_string(raw: String) -> Dictionary:
var cleaned := _without_comments(raw).strip_edges()
if cleaned.length() >= 2 and cleaned.begins_with("'") and cleaned.ends_with("'"):
return {"ok": true, "value": cleaned.substr(1, cleaned.length() - 2)}
if cleaned.length() < 2 or not cleaned.begins_with('"') or not cleaned.ends_with('"'):
return {"ok": false}
var parsed: Variant = JSON.parse_string(cleaned)
if parsed is String:
return {"ok": true, "value": parsed}
return {"ok": false}
static func _decode_toml_string_array(raw: String) -> Dictionary:
var cleaned := _without_comments(raw).strip_edges()
if not cleaned.begins_with("["):
return {"ok": false}
var i := 1
var values: Array[String] = []
while true:
i = _skip_space(cleaned, i)
if i >= cleaned.length():
return {"ok": false}
if cleaned.unicode_at(i) == 93:
i = _skip_space(cleaned, i + 1)
return {"ok": i == cleaned.length(), "value": values}
var parsed := _parse_string_at(cleaned, i)
if not bool(parsed.get("ok", false)):
return {"ok": false}
values.append(str(parsed.get("value", "")))
i = _skip_space(cleaned, int(parsed.get("next", i)))
if i >= cleaned.length():
return {"ok": false}
var c := cleaned.unicode_at(i)
if c == 44:
i += 1
continue
if c == 93:
continue
return {"ok": false}
return {"ok": false} # Unreachable; keeps GDScript's return analysis explicit.
static func _parse_string_at(text: String, start: int) -> Dictionary:
if start >= text.length():
return {"ok": false}
var quote := text.unicode_at(start)
if quote != 34 and quote != 39:
return {"ok": false}
var i := start + 1
var escaped := false
while i < text.length():
var c := text.unicode_at(i)
if quote == 34 and c == 92 and not escaped:
escaped = true
i += 1
continue
if c == quote and not escaped:
var raw := text.substr(start, i - start + 1)
if quote == 39:
return {"ok": true, "value": raw.substr(1, raw.length() - 2), "next": i + 1}
var parsed: Variant = JSON.parse_string(raw)
if parsed is String:
return {"ok": true, "value": parsed, "next": i + 1}
return {"ok": false}
escaped = false
i += 1
return {"ok": false}
static func _without_comments(raw: String) -> String:
var out: Array[String] = []
for line in raw.split("\n"):
var quote := 0
var escaped := false
var kept := ""
for i in range(line.length()):
var c := line.unicode_at(i)
if quote != 0:
kept += line.substr(i, 1)
if quote == 34 and c == 92 and not escaped:
escaped = true
continue
if c == quote and not escaped:
quote = 0
escaped = false
continue
if c == 34 or c == 39:
quote = c
kept += line.substr(i, 1)
elif c == 35:
break
else:
kept += line.substr(i, 1)
out.append(kept)
return "\n".join(out)
static func _skip_space(text: String, start: int) -> int:
var i := start
while i < text.length() and text.substr(i, 1) in [" ", "\t", "\r", "\n"]:
i += 1
return i
static func _string_arrays_equal(left: Variant, right: Variant) -> bool:
if not (left is Array or left is PackedStringArray):
return false
if not (right is Array or right is PackedStringArray):
return false
if left.size() != right.size():
return false
for i in range(left.size()):
if str(left[i]) != str(right[i]):
return false
return true
# --- file / section helpers ---------------------------------------------
static func _read_or_init(path: String) -> Dictionary:
if not FileAccess.file_exists(path):
return {"ok": true, "data": ""}
var f := FileAccess.open(path, FileAccess.READ)
if f == null:
var err := FileAccess.get_open_error()
return {"ok": false, "error": "could not open for reading (error %d)" % err}
var text := f.get_as_text()
f.close()
return {"ok": true, "data": text}
static func _split_lines(content: String) -> Array[String]:
var out: Array[String] = []
for line in content.split("\n"):
out.append(line)
return out
static func _slice(lines: Array[String], from: int, to: int) -> Array[String]:
var out: Array[String] = []
for i in range(from, to):
out.append(lines[i])
return out
static func _primary_header(client: McpClient) -> String:
var parts := client.toml_section_path
if parts.size() < 2:
return "[%s]" % ".".join(parts)
var section := ".".join(McpClient._packed_slice(parts, 0, parts.size() - 1))
var name := parts[parts.size() - 1]
return "[%s.\"%s\"]" % [section, name]
static func _all_headers(client: McpClient) -> Array[String]:
var primary := _primary_header(client)
var out: Array[String] = [primary]
var bare := _bare_key_header(client)
if not bare.is_empty() and bare != primary:
out.append(bare)
for legacy in client.toml_legacy_section_aliases:
out.append("[%s]" % legacy)
return out
static func _bare_key_header(client: McpClient) -> String:
var parts := client.toml_section_path
if parts.is_empty():
return ""
for part in parts:
if not _is_bare_key(String(part)):
return ""
return "[%s]" % ".".join(parts)
static func _is_bare_key(value: String) -> bool:
if value.is_empty():
return false
for i in range(value.length()):
var c := value.unicode_at(i)
var alpha := (c >= 65 and c <= 90) or (c >= 97 and c <= 122)
var digit := c >= 48 and c <= 57
if not (alpha or digit or c == 45 or c == 95):
return false
return true
static func _subtable_prefixes(headers: Array[String]) -> Array[String]:
var out: Array[String] = []
for header in headers:
if header.length() > 2 and header.ends_with("]"):
out.append(header.substr(0, header.length() - 1) + ".")
return out
static func _matches_subtable_prefix(line: String, prefixes: Array[String]) -> bool:
var trimmed := line.strip_edges()
for prefix in prefixes:
if not trimmed.begins_with(prefix):
continue
var rest := trimmed.substr(prefix.length())
var bracket := rest.find("]")
if bracket < 0:
continue
var remainder := rest.substr(bracket + 1).strip_edges()
if remainder.is_empty() or remainder.begins_with("#"):
return true
return false
static func _matches_any_header(line: String, headers: Array[String]) -> bool:
var trimmed := line.strip_edges()
for header in headers:
if not trimmed.begins_with(header):
continue
var remainder := trimmed.substr(header.length()).strip_edges()
if remainder.is_empty() or remainder.begins_with("#"):
return true
return false
static func _find_section(lines: Array[String], headers: Array[String]) -> Dictionary:
for i in range(lines.size()):
if _matches_any_header(lines[i], headers):
var end := lines.size()
for j in range(i + 1, lines.size()):
if _is_any_section_header(lines[j]):
end = j
break
return {"start": i, "end": end}
return {}
static func _is_any_section_header(line: String) -> bool:
var trimmed := line.strip_edges()
if not trimmed.begins_with("["):
return false
var bracket := trimmed.find("]")
if bracket < 0:
return false
var remainder := trimmed.substr(bracket + 1).strip_edges()
return remainder.is_empty() or remainder.begins_with("#")
static func _rewrite_legacy_descendant_headers(
lines: Array[String], client: McpClient
) -> Array[String]:
if client.toml_legacy_section_aliases.is_empty():
return lines
var primary := _primary_header(client)
var primary_prefix := primary.substr(0, primary.length() - 1) + "."
var out: Array[String] = []
for line in lines:
var rewritten := line
var trimmed := line.strip_edges()
var indent_length := line.find("[")
var indent := line.substr(0, indent_length) if indent_length >= 0 else ""
for alias in client.toml_legacy_section_aliases:
var legacy_prefix := "[%s." % String(alias)
if trimmed.begins_with(legacy_prefix):
rewritten = indent + primary_prefix + trimmed.substr(legacy_prefix.length())
break
out.append(rewritten)
return out
@@ -0,0 +1 @@
uid://cwdvxgn0aurqv
+549
View File
@@ -0,0 +1,549 @@
@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. Shared with `McpDshStrategy` for the
## DeepSeek Harness patch-list `args` lines.
static func emit_flow_array(values: Array) -> String:
var parts: Array[String] = []
for v in values:
parts.append(JSON.stringify(str(v)))
return "[%s]" % ", ".join(parts)
static func emit_scalar(v: Variant) -> String:
match typeof(v):
TYPE_BOOL:
return "true" if bool(v) else "false"
TYPE_INT:
return str(int(v))
TYPE_FLOAT:
return str(float(v))
_:
return emit_string_scalar(str(v))
## Plain YAML scalars cannot safely carry ": ", " #", quotes, flow
## indicators, or leading indicator characters — a Windows launcher path with
## spaces would silently corrupt the entry. Quote exactly when needed so
## existing plain values (urls, bools-as-strings) keep their current
## byte-shape on rewrite. Backslashes are quoted too: a Windows path parses
## either way, but quoting makes the written shape unambiguous for any
## consumer. Shared with `McpDshStrategy` for DeepSeek Harness patch entries.
static func emit_string_scalar(s: String) -> String:
if s.is_empty():
return "\"\""
var needs_quote := s.begins_with(" ") or s.ends_with(" ")
if not needs_quote:
for needle in [": ", " #", "\"", "'", "\n", "\t", "\\", "{", "}", "[", "]", ","]:
if s.contains(needle):
needs_quote = true
break
if not needs_quote:
for prefix in ["#", "-", "?", "&", "*", "!", "|", ">", "%", "@", "`"]:
if s.begins_with(prefix):
needs_quote = true
break
return JSON.stringify(s) if needs_quote else s
## Blank and comment-only lines carry no structure — every scan loop skips
## them the same way so a `# comment` can never be mistaken for an entry
## header or a key/value line.
static func _is_blank_or_comment(line: String) -> bool:
var stripped := line.strip_edges()
return stripped.is_empty() or stripped.begins_with("#")
## Returns the leading-whitespace indent width of a line (spaces + tabs
## counted as 1 each). Used to distinguish sibling entries (same indent)
## from nested keys (deeper indent) and parent-level keys (less indent).
static func _indent_of(line: String) -> int:
var n := 0
while n < line.length() and (line[n] == " " or line[n] == "\t"):
n += 1
return n
## Minimal scalar coercion for parsed YAML values. Quotes are stripped;
## bare true/false/numbers are typed; double-quoted flow sequences (the
## command entry's `args`) parse back into an Array. Good enough for Hermes'
## url/headers/command/args. A hand-edited args in block style or with
## unquoted items doesn't JSON-parse — it stays a raw string, compares
## unequal, and surfaces as CONFIGURED_MISMATCH, which Reconfigure
## normalizes back to the flow form. Public: the dsh strategy parses its
## patch-list scalars with the same rules, so both YAML dialects share one
## coercion (#867 review).
static func coerce_scalar(s: String) -> Variant:
var t := s.strip_edges()
if t.begins_with("[") and t.ends_with("]"):
var parsed_array: Variant = JSON.parse_string(t)
if parsed_array is Array:
return parsed_array
return t
if t.begins_with("\"") and t.ends_with("\""):
var parsed_string: Variant = JSON.parse_string(t)
if parsed_string is String:
return parsed_string
return t.substr(1, t.length() - 2)
if t.begins_with("'") and t.ends_with("'"):
return t.substr(1, t.length() - 2)
if t == "true":
return true
if t == "false":
return false
if t.is_valid_int():
return t.to_int()
if t.is_valid_float():
return t.to_float()
return t
## Returns {"ok": true, "data": String} when the file is absent or readable,
## and {"ok": false, "error": String} when unreadable. Callers must NOT fall
## back to an empty string on the error path — doing so blows away the user's
## other config.yaml entries on the next write.
static func _read(path: String) -> Dictionary:
if not FileAccess.file_exists(path):
return {"ok": true, "data": ""}
var f := FileAccess.open(path, FileAccess.READ)
if f == null:
var err := FileAccess.get_open_error()
return {"ok": false, "error": "could not open for reading (%s)" % error_string(err)}
var t := f.get_as_text()
f.close()
return {"ok": true, "data": t}
@@ -0,0 +1 @@
uid://cfnw4oe71ra1l
+39
View File
@@ -0,0 +1,39 @@
@tool
extends McpClient
func _init() -> void:
id = "antigravity"
display_name = "Antigravity"
config_type = "json"
## Antigravity moved its shared MCP config from `~/.gemini/antigravity/`
## to `~/.gemini/config/` (IDE + CLI now read the same file there); the
## old path is left in `detect_paths` below so an existing install is
## still recognized, but new/updated entries write to the current path.
path_template = {
"unix": "~/.gemini/config/mcp_config.json",
"windows": "$USERPROFILE/.gemini/config/mcp_config.json",
}
server_key_path = PackedStringArray(["mcpServers"])
entry_url_field = "serverUrl"
## `disabled` is user-state (they may have flipped the entry off in the
## UI); seeded on first Configure but preserved across reconfigure.
entry_initial_fields = {"disabled": false}
## Attach migration (#838). Antigravity stdio entries are flat
## command/args/env with no type discriminator — transport is inferred
## from `command` vs `serverUrl` presence (antigravity.google/docs/mcp),
## so the legacy `serverUrl` must not survive next to a command.
command_shape = McpClient.CommandShape.FLAT
command_legacy_keys = PackedStringArray(["serverUrl"])
command_initial_fields = {"disabled": false}
command_user_fields = PackedStringArray(["disabled", "disabledTools", "authProviderType", "env"])
command_supports_url_fallback = true
## Antigravity's spawner hangs stdio tool calls when the entry launches a
## GUI-subsystem pythonw.exe (#863), and it hides child console windows
## itself, so the visible-terminal problem the bootstrap solves (#827)
## never applies. Write the plain console launcher on Windows.
needs_consoleless_launcher = false
detect_paths = PackedStringArray(path_template.values() + [
"~/.gemini/antigravity/mcp_config.json",
"$USERPROFILE/.gemini/antigravity/mcp_config.json",
])
@@ -0,0 +1 @@
uid://b4l1g0apa2hch
+18
View File
@@ -0,0 +1,18 @@
@tool
extends McpClient
func _init() -> void:
id = "cherry_studio"
display_name = "Cherry Studio"
config_type = "json"
path_template = {
"darwin": "~/Library/Application Support/CherryStudio/mcp_servers.json",
"windows": "$APPDATA/CherryStudio/mcp_servers.json",
"linux": "$XDG_CONFIG_HOME/CherryStudio/mcp_servers.json",
}
server_key_path = PackedStringArray(["mcpServers"])
entry_extra_fields = {"type": "streamableHttp"}
## `isActive` is user-state (they may have toggled the server off in the UI).
## Seed on first Configure but preserve across reconfigure.
entry_initial_fields = {"isActive": true}
@@ -0,0 +1 @@
uid://dwbuykxvbv5f7
+84
View File
@@ -0,0 +1,84 @@
@tool
extends McpClient
func _init() -> void:
id = "claude_code"
display_name = "Claude Code"
config_type = "cli"
cli_names = PackedStringArray(["claude", "claude.exe"] if OS.get_name() == "Windows" else ["claude"])
## Stdio registration through the client-owned `godot-ai attach` bridge
## (#838). `--` stops claude's own flag parsing so the attach argv passes
## through verbatim; stdio is the CLI's default transport. Scope comes from
## the `godot_ai/mcp_client_scope` EditorSetting via the `{scope}` token; it
## defaults to `user` — the same ~/.claude.json the pre-attach HTTP entry
## lived in — and can be set to `project` to write <project>/.mcp.json so the
## server is not loaded in every unrelated workspace.
cli_register_template = PackedStringArray(
["mcp", "add", "--scope", "{scope}", "{name}", "--", "{command}", "{args...}"]
)
## Explicit scope: an unscoped `mcp remove` deletes from whichever scope
## matches first, so the flag keeps each removal aimed at one known place.
## Note what that does and does not protect (#877): the dock's **Remove**
## button runs this once, at the selected scope only. **Configure** runs it
## once per scope in `CLIENT_SCOPES` before registering, so a Configure at
## any setting does delete a `godot-ai` entry from <cwd>/.mcp.json if one is
## there — see the cwd caveat below for which directory that actually is.
cli_unregister_template = PackedStringArray(["mcp", "remove", "--scope", "{scope}", "{name}"])
## Scope caveat: `--scope project` resolves `.mcp.json` against the
## *spawned CLI's* working directory, which is whatever the editor process
## inherited at launch — not necessarily `res://`. Godot has no API to set
## a child's cwd (`OS.execute_with_pipe` takes none), so this is an
## accepted limitation rather than something the descriptor can pin. It is
## self-consistent: `cli_status_args` runs from the same cwd, so the
## read-back finds whatever the register wrote (see
## `_scope_diverges_from_json_fallback` in client_configurator.gd).
cli_status_args = PackedStringArray(["mcp", "list"])
## `mcp list` prints the resolved entry for the cwd without saying which
## scope won, so it cannot tell "registered at project scope" from "an old
## user-scope entry is shadowing an empty project scope". `mcp get` prints
## both the command and a `Scope: …` line, in the same single subprocess,
## so scope-token clients probe with it instead (#872). Verified against
## claude 2.1.241, which prints one of:
## Scope: User config (available in all your projects)
## Scope: Project config (shared via .mcp.json)
## Scope: Local config (private to you in this project)
## and exits 1 with "No MCP server named …" when the entry is absent.
cli_scope_status_template = PackedStringArray(["mcp", "get", "{name}"])
## #463: JSON fallback for when the `claude` binary isn't on PATH — e.g.
## Claude Code installed only as a VS Code / Cursor extension. The CLI is
## still preferred for Configure whenever it resolves; this is what gets
## written otherwise. `claude mcp add --scope user <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 reads this file only while the selected scope is still `user` —
## it is the CLI's own store for that scope, and file reads give exact
## launch-drift detection that `mcp list` stdout scanning cannot. At
## `project`/`local` the entry is somewhere `path_template` cannot see, so
## `_scope_diverges_from_json_fallback` (client_configurator.gd) routes
## status to `cli_scope_status_template` instead and this file is not read.
path_template = {"unix": "~/.claude.json", "windows": "~/.claude.json"}
server_key_path = PackedStringArray(["mcpServers"])
## URL-mode shape, used only for the manual-instruction fallback text —
## `claude mcp add --scope <scope> --transport http` writes {type: http, url},
## where <scope> is whatever `godot_ai/mcp_client_scope` resolves to.
entry_extra_fields = {"type": "http"}
command_shape = McpClient.CommandShape.FLAT
command_transport_key = "type"
command_transport_value = "stdio"
## Legacy HTTP entries carried a `url`; Claude Code rejects an entry mixing
## url with command fields, and the stale `type: "http"` is repinned to
## "stdio" by the transport key above.
command_legacy_keys = PackedStringArray(["url"])
command_user_fields = PackedStringArray(["env"])
command_supports_url_fallback = true
## Documented: $CLAUDE_CONFIG_DIR relocates Claude Code's config home,
## including .claude.json ($CLAUDE_CONFIG_DIR/.claude.json). The preferred
## CLI path needs no help — the spawned `claude` binary inherits the
## editor's environment and resolves the dir itself — but the JSON
## fallback above would otherwise write ~/.claude.json that a relocated
## install never reads (#617).
config_home_env = "CLAUDE_CONFIG_DIR"
config_home_env_subpath = ".claude.json"
@@ -0,0 +1 @@
uid://cp1u1hdpa6f8d
+38
View File
@@ -0,0 +1,38 @@
@tool
extends McpClient
## Claude Desktop's mcpServers entries launch a local stdio process. The
## client-owned `godot-ai attach` bridge keeps that stdio session stable while
## adopting or starting the shared HTTP backend as Godot editors come and go.
func _init() -> void:
id = "claude_desktop"
display_name = "Claude Desktop"
config_type = "json"
path_template = {
"darwin": "~/Library/Application Support/Claude/claude_desktop_config.json",
"windows": "$APPDATA/Claude/claude_desktop_config.json",
"linux": "$XDG_CONFIG_HOME/Claude/claude_desktop_config.json",
}
## Store-installed Claude runs inside MSIX AppData virtualization. Godot is
## outside that container, so `%APPDATA%` names a different physical file
## once Claude has created its private copy. A unique Store package root is
## authoritative even before the config leaf exists: create the private file
## directly so a later copy-on-write cannot hide an entry written to roaming.
## With no Store package, use the conventional roaming path. The wildcard
## avoids coupling to the publisher-hash suffix.
config_path_candidates = {
"windows": [
"$LOCALAPPDATA/Packages/Claude_*/LocalCache/Roaming/Claude/claude_desktop_config.json",
"$APPDATA/Claude/claude_desktop_config.json",
],
}
detect_paths = PackedStringArray([
"$LOCALAPPDATA/Packages/Claude_*",
])
server_key_path = PackedStringArray(["mcpServers"])
command_shape = McpClient.CommandShape.FLAT
command_legacy_keys = PackedStringArray(["url"])
command_env_legacy_keys = PackedStringArray(["UV_LINK_MODE"])
command_user_fields = PackedStringArray(["env", "disabled"])
@@ -0,0 +1 @@
uid://bilntn5n8oqe3
+43
View File
@@ -0,0 +1,43 @@
@tool
extends McpClient
## Cline is a VS Code extension. Its MCP settings live in VS Code's
## globalStorage under the extension id `saoudrizwan.claude-dev`.
func _init() -> void:
id = "cline"
display_name = "Cline"
config_type = "json"
path_template = {
"darwin": "~/Library/Application Support/Code/User/globalStorage/saoudrizwan.claude-dev/settings/cline_mcp_settings.json",
"windows": "$APPDATA/Code/User/globalStorage/saoudrizwan.claude-dev/settings/cline_mcp_settings.json",
"linux": "$XDG_CONFIG_HOME/Code/User/globalStorage/saoudrizwan.claude-dev/settings/cline_mcp_settings.json",
}
server_key_path = PackedStringArray(["mcpServers"])
## Cline (like Roo) defaults a typeless entry to SSE transport, which
## returns HTTP 400 against our streamable-http endpoint on `/mcp`. Pin
## the type explicitly. Cline's schema uses "streamableHttp" (camelCase,
## see src/services/mcp/schemas.ts in the cline repo) — distinct from
## Roo's "streamable-http" string. Parallel to the Roo fix in #190.
entry_extra_fields = {"type": "streamableHttp"}
## `disabled` and `autoApprove` are user-state (they may have flipped the
## entry off, or auto-approved specific tools). Seed on first Configure
## but preserve across reconfigure — see `entry_initial_fields` in `_base.gd`.
entry_initial_fields = {"disabled": false, "autoApprove": []}
## Attach migration (#838). Cline stdio entries are flat command/args/env;
## its schema accepts `type: "stdio"` and normalizes typeless command
## entries to it (apps/vscode/src/services/mcp/schemas.ts), so pin the
## type — that also repins the legacy "streamableHttp" value instead of
## letting it survive the deep-copy and misroute the transport.
command_shape = McpClient.CommandShape.FLAT
command_transport_key = "type"
command_transport_value = "stdio"
command_legacy_keys = PackedStringArray(["url", "headers"])
command_initial_fields = {"disabled": false, "autoApprove": []}
command_user_fields = PackedStringArray([
"disabled", "autoApprove", "timeout", "oauth", "metadata",
"remoteConfigured", "env", "cwd",
])
command_timeout_fields = PackedStringArray(["timeout"])
command_supports_url_fallback = true
+1
View File
@@ -0,0 +1 @@
uid://d36nywn2nkgts
+45
View File
@@ -0,0 +1,45 @@
@tool
extends McpClient
func _init() -> void:
id = "codex"
display_name = "Codex"
config_type = "toml"
path_template = {"unix": "~/.codex/config.toml", "windows": "$USERPROFILE/.codex/config.toml"}
## Documented: when $CODEX_HOME is set, Codex reads config.toml directly
## from it instead of ~/.codex (#617).
config_home_env = "CODEX_HOME"
config_home_env_subpath = "config.toml"
toml_section_path = PackedStringArray(["mcp_servers", "godot-ai"])
# Older Codex builds used the unquoted form with underscore-substituted ids.
toml_legacy_section_aliases = PackedStringArray(["mcp_servers.godot_ai"])
command_shape = McpClient.CommandShape.COMMAND_ARRAY
command_supports_url_fallback = true
command_legacy_keys = PackedStringArray(["url"])
## Initial-only: users may disable the entry or tune either timeout and
## Configure preserves that choice. Codex currently defaults to 10s for
## startup and 60s per tool; test_run legitimately has a 300s server
## budget, so the generated config leaves transport margin at the client.
command_initial_fields = {
"enabled": true,
"startup_timeout_sec": 60,
"tool_timeout_sec": 360,
}
command_timeout_fields = PackedStringArray([
"startup_timeout_sec",
"tool_timeout_sec",
])
command_user_fields = PackedStringArray([
"enabled",
"required",
"startup_timeout_sec",
"tool_timeout_sec",
"enabled_tools",
"disabled_tools",
"default_tools_approval_mode",
"env",
"env_vars",
"cwd",
])
detect_paths = PackedStringArray(path_template.values())
+1
View File
@@ -0,0 +1 @@
uid://hdlwcfdr8mdk
+21
View File
@@ -0,0 +1,21 @@
@tool
extends McpClient
func _init() -> void:
id = "cursor"
display_name = "Cursor"
config_type = "json"
path_template = {"unix": "~/.cursor/mcp.json", "windows": "$USERPROFILE/.cursor/mcp.json"}
server_key_path = PackedStringArray(["mcpServers"])
## Attach migration (#838). Cursor's stdio entries are flat command/args/env
## (cursor.com/docs/context/mcp). The docs' reference table documents
## `type: "stdio"`; pinning it also repins any hand-added `type: "http"`
## left on the legacy URL entry, which would otherwise survive the
## deep-copy migration and misroute the transport.
command_shape = McpClient.CommandShape.FLAT
command_transport_key = "type"
command_transport_value = "stdio"
command_legacy_keys = PackedStringArray(["url"])
command_user_fields = PackedStringArray(["env", "envFile"])
command_supports_url_fallback = true
+1
View File
@@ -0,0 +1 @@
uid://bvpbssfanukef
@@ -0,0 +1,45 @@
@tool
extends McpClient
func _init() -> void:
id = "deepseek_harness"
display_name = "DeepSeek Harness"
## DeepSeek Harness (dsh) has no `mcp` CLI verb — MCP servers register as
## `@deepseek-ai/dsh-mcp-client` plugin entries in the HOME patch layer
## `$DSH_HOME/cordis.patch.yml`, which dsh applies over every profile's
## own patch layer (the web GUI included). config_type "dsh" routes to
## `McpDshStrategy`. Verified live against dsh 0.1.0-rc.6: new entries
## must be `insert` rows; plain rows only override existing bundle ids.
config_type = "dsh"
## Home patch layer, not a per-profile file: one entry covers the web
## GUI, headless, tui, and any custom profile.
path_template = {
"unix": "~/.dsh/cordis.patch.yml",
"windows": "~/.dsh/cordis.patch.yml",
}
## Documented: `$DSH_HOME` relocates the whole harness home, and the
## loader reads `$DSH_HOME/cordis.patch.yml` (homePatchPath) — same
## false-success-write class as CODEX_HOME (#617), so the override must
## be declared.
config_home_env = "DSH_HOME"
config_home_env_subpath = "cordis.patch.yml"
## Installed detection: the harness home directory itself (dsh GUI and
## CLI both live there), or the config file once one exists.
detect_paths = PackedStringArray(["~/.dsh"])
## Attach migration (#838). The loader entry nests the launch under
## `config`; the mcp-client plugin requires `transport` next to
## command/args, and a `url` must never coexist with command fields.
command_shape = McpClient.CommandShape.FLAT
command_transport_key = "transport"
command_transport_value = "stdio"
command_legacy_keys = PackedStringArray(["url"])
## Fields the mcp-client plugin documents as user-owned; preserved on
## reconfigure and never drift-checked.
command_user_fields = PackedStringArray([
"env", "cwd", "toolCallTimeoutMs", "failOnStartupError", "reconnect",
])
command_timeout_fields = PackedStringArray(["toolCallTimeoutMs"])
## The bridge accepts `transport: streamable-http` with a `url`, so the
## manual instructions may offer the HTTP alternative.
command_supports_url_fallback = true
@@ -0,0 +1 @@
uid://cw3pghlevtql8
+25
View File
@@ -0,0 +1,25 @@
@tool
extends McpClient
func _init() -> void:
id = "gemini_cli"
display_name = "Gemini CLI"
config_type = "json"
path_template = {
"unix": "~/.gemini/settings.json",
"windows": "$USERPROFILE/.gemini/settings.json",
}
server_key_path = PackedStringArray(["mcpServers"])
entry_url_field = "httpUrl"
## Attach migration (#838). Gemini CLI stdio entries are flat
## command/args/env(+cwd); the config is one-of `command` | `url` (SSE) |
## `httpUrl` (docs/tools/mcp-server.md), so BOTH URL keys are legacy next
## to a command. `trust` bypasses tool confirmations — never seed it.
command_shape = McpClient.CommandShape.FLAT
command_legacy_keys = PackedStringArray(["httpUrl", "url"])
command_user_fields = PackedStringArray([
"timeout", "trust", "includeTools", "excludeTools", "env", "cwd",
])
command_timeout_fields = PackedStringArray(["timeout"])
command_supports_url_fallback = true
@@ -0,0 +1 @@
uid://b8288pxninajy
+35
View File
@@ -0,0 +1,35 @@
@tool
extends McpClient
func _init() -> void:
id = "grok"
display_name = "Grok Build"
config_type = "toml"
# Grok Build reads MCP servers from ~/.grok/config.toml
# (https://x.ai / Grok user guide: MCP servers section).
path_template = {
"unix": "~/.grok/config.toml",
"windows": "$USERPROFILE/.grok/config.toml",
}
toml_section_path = PackedStringArray(["mcp_servers", "godot-ai"])
# Some docs / older notes used an underscore form.
toml_legacy_section_aliases = PackedStringArray(["mcp_servers.godot_ai"])
## Attach migration (#838). Grok's stdio sections are flat command/args/env
## with no type discriminator (docs.x.ai/build/features/mcp-servers);
## url/headers are the HTTP form and must not survive next to a command.
## Docs default startup_timeout_sec to 30 — a cold `uvx` install of the
## pinned package can exceed that, so new entries seed 60 (preserved once
## the user tunes it). tool_timeout_sec's documented 6000s default already
## clears test_run's 300s budget, so it is left alone. The old body
## template's `enabled = true` line was never documented for Grok — it is
## no longer seeded, and an existing value survives as a user key.
command_shape = McpClient.CommandShape.COMMAND_ARRAY
command_legacy_keys = PackedStringArray(["url", "headers"])
command_initial_fields = {"startup_timeout_sec": 60}
command_user_fields = PackedStringArray([
"env", "enabled", "startup_timeout_sec", "tool_timeout_sec",
])
command_timeout_fields = PackedStringArray(["startup_timeout_sec", "tool_timeout_sec"])
command_supports_url_fallback = true
detect_paths = PackedStringArray(path_template.values())
+1
View File
@@ -0,0 +1 @@
uid://ckchsj5s3q1b0
+46
View File
@@ -0,0 +1,46 @@
@tool
extends McpClient
func _init() -> void:
id = "hermes"
display_name = "Hermes Agent"
config_type = "yaml"
# Hermes reads MCP config from ~/.hermes/config.yaml (YAML), NOT mcp.json.
# Verified against the official docs:
# https://hermes-agent.nousresearch.com/docs/user-guide/features/mcp
# Windows: Hermes stores config under $LOCALAPPDATA/hermes (NOT $APPDATA,
# which is Roaming) — confirmed by where the running Hermes process reads.
# NOTE: _path_template.expand() only substitutes $VAR tokens, not %VAR%.
path_template = {
"unix": "~/.hermes/config.yaml",
"windows": "$LOCALAPPDATA/hermes/config.yaml"
}
# Hermes uses the snake_case `mcp_servers` key (not `mcpServers`).
# PackedStringArray explicitly, matching every other descriptor — an
# untyped Array literal relies on implicit conversion that newer Godot
# builds enforce more strictly (the #722 CI lesson for Array[String]).
server_key_path = PackedStringArray(["mcp_servers"])
# HTTP entries use `url` (+ optional `headers`); transport is inferred —
# there is no `type` field in Hermes MCP config.
entry_url_field = "url"
# No transport pin: Hermes infers streamable-http from the URL.
entry_extra_fields = {}
entry_initial_fields = {}
## Attach migration (#838). Hermes stdio entries are flat command/args/env
## (hermes-agent.nousresearch.com/docs/user-guide/features/mcp), transport
## inferred exactly like the URL form — which is why `url` and the
## HTTP-only `headers` must not survive next to a command: an entry with
## both picks the wrong transport. `enabled`/`tools`/`env` stay user-owned.
command_shape = McpClient.CommandShape.FLAT
command_legacy_keys = PackedStringArray(["url", "headers"])
command_user_fields = PackedStringArray(["enabled", "tools", "env"])
command_supports_url_fallback = true
# Hermes is "installed" wherever the config.yaml lives; presence of the
# file is sufficient for the dock's installed badge.
detect_paths = PackedStringArray()
+1
View File
@@ -0,0 +1 @@
uid://ewmadhrvs5d7
+38
View File
@@ -0,0 +1,38 @@
@tool
extends McpClient
func _init() -> void:
id = "kilo_code"
display_name = "Kilo Code"
config_type = "json"
path_template = {
"darwin": "~/Library/Application Support/Code/User/globalStorage/kilocode.kilo-code/settings/mcp_settings.json",
"windows": "$APPDATA/Code/User/globalStorage/kilocode.kilo-code/settings/mcp_settings.json",
"linux": "$XDG_CONFIG_HOME/Code/User/globalStorage/kilocode.kilo-code/settings/mcp_settings.json",
}
server_key_path = PackedStringArray(["mcpServers"])
## Kilo Code (like Roo) defaults a typeless entry to SSE transport, which
## returns HTTP 400 against our streamable-http endpoint on `/mcp`. Pin
## the type explicitly. Parallel to the Roo fix in #190.
entry_extra_fields = {"type": "streamable-http"}
## `disabled` and `alwaysAllow` are user-state (they may have flipped the
## entry off, or auto-approved specific tools). Seed on first Configure
## but preserve across reconfigure — see `entry_initial_fields` in `_base.gd`.
entry_initial_fields = {"disabled": false, "alwaysAllow": []}
## Attach migration (#838). UNLIKE its Roo siblings the stdio entry must be
## TYPELESS: Kilo's v7 platform treats this file as a migration source and
## routes on `type` — any http type value sends the entry down the remote
## branch where it is dropped for lack of a url, and only a bare
## command/args/env entry is verified to work in BOTH the legacy extension
## and the v7 migrator (packages/opencode/src/kilocode/mcp-migrator.ts).
## `type` therefore joins the legacy keys instead of being repinned.
command_shape = McpClient.CommandShape.FLAT
command_legacy_keys = PackedStringArray(["url", "type", "headers"])
command_initial_fields = {"disabled": false, "alwaysAllow": []}
command_user_fields = PackedStringArray([
"disabled", "alwaysAllow", "timeout", "cwd", "watchPaths",
"disabledTools", "env",
])
command_timeout_fields = PackedStringArray(["timeout"])
command_supports_url_fallback = true
+1
View File
@@ -0,0 +1 @@
uid://dc1x77i1cmb6w
+34
View File
@@ -0,0 +1,34 @@
@tool
extends McpClient
func _init() -> void:
id = "kimi_code"
display_name = "Kimi Code"
## Kimi Code has no `mcp` CLI subcommand (verified against v0.28.1 —
## `kimi mcp` falls through to the root --help, and the docs at
## moonshotai.github.io/kimi-code/en/customization/mcp confirm servers are
## managed via ~/.kimi-code/mcp.json, not a CLI verb). JSON is therefore
## the only working config method, not a fallback.
config_type = "json"
path_template = {"unix": "~/.kimi-code/mcp.json", "windows": "~/.kimi-code/mcp.json"}
server_key_path = PackedStringArray(["mcpServers"])
entry_extra_fields = {"transport": "http"}
## Documented: `$KIMI_CODE_HOME/mcp.json` relocates the config
## (moonshotai.github.io/kimi-code/en/customization/mcp) — same
## false-success-write class as CODEX_HOME (#617).
config_home_env = "KIMI_CODE_HOME"
config_home_env_subpath = "mcp.json"
## Attach migration (#838). Kimi Code stdio entries are flat
## command/args/env(+cwd): "Entries with a `command` field are stdio
## servers". `transport` is only defined for SSE-with-url, so the legacy
## `transport: "http"` must be removed alongside `url`. Timeout fields are
## camelCase, unlike Codex's snake_case.
command_shape = McpClient.CommandShape.FLAT
command_legacy_keys = PackedStringArray(["url", "transport"])
command_user_fields = PackedStringArray([
"enabled", "startupTimeoutMs", "toolTimeoutMs", "enabledTools",
"disabledTools", "env", "cwd",
])
command_timeout_fields = PackedStringArray(["startupTimeoutMs", "toolTimeoutMs"])
command_supports_url_fallback = true
+1
View File
@@ -0,0 +1 @@
uid://d2whd6a5fofhg
+23
View File
@@ -0,0 +1,23 @@
@tool
extends McpClient
func _init() -> void:
id = "kiro"
display_name = "Kiro"
config_type = "json"
path_template = {
"unix": "~/.kiro/settings/mcp.json",
"windows": "$USERPROFILE/.kiro/settings/mcp.json",
}
server_key_path = PackedStringArray(["mcpServers"])
## `disabled` is user-state — preserved across reconfigure.
entry_initial_fields = {"disabled": false}
## Attach migration (#838). Kiro stdio entries are flat command/args/env
## with no type discriminator (kiro.dev/docs/mcp/configuration).
## `autoApprove` is user-state, same contract as the URL entry's fields.
command_shape = McpClient.CommandShape.FLAT
command_legacy_keys = PackedStringArray(["url"])
command_initial_fields = {"disabled": false}
command_user_fields = PackedStringArray(["disabled", "autoApprove", "disabledTools", "env"])
command_supports_url_fallback = true
+1
View File
@@ -0,0 +1 @@
uid://dqdmd2jw5qen7
+39
View File
@@ -0,0 +1,39 @@
@tool
extends McpClient
## OpenCode stores MCP servers under `mcp.<name>` (not the typical mcpServers
## map) and uses `type: "remote"` for HTTP servers.
func _init() -> void:
id = "opencode"
display_name = "OpenCode"
config_type = "json"
## `$HOME` on Windows is deliberate: OpenCode reads ~/.config/... on ALL
## platforms (verified via `opencode debug paths`), and
## McpPathTemplate._home() falls back to USERPROFILE when HOME is unset —
## pinned by test_opencode_client_uses_home_config_on_windows. The documented
## `OPENCODE_CONFIG` override names an exact file and must win over this
## default for configure, status, remove, and manual instructions.
path_template = {
"unix": "~/.config/opencode/opencode.json",
"windows": "$HOME/.config/opencode/opencode.json",
}
config_file_env = "OPENCODE_CONFIG"
server_key_path = PackedStringArray(["mcp"])
entry_extra_fields = {"type": "remote"}
## `enabled` is user-state (they may have toggled the server off).
entry_initial_fields = {"enabled": true}
## Attach migration (#838). OpenCode local entries carry the launch as ONE
## argv array — `"command": ["uvx", …]` with no separate args key — plus a
## schema-REQUIRED `type: "local"` (McpLocalConfig in opencode.ai/config.json;
## env lives under `environment`, not `env`). The pin rewrites the legacy
## `type: "remote"` in place; url/headers are remote-only and must go.
command_shape = McpClient.CommandShape.COMMAND_ARRAY
command_transport_key = "type"
command_transport_value = "local"
command_legacy_keys = PackedStringArray(["url", "headers"])
command_initial_fields = {"enabled": true}
command_user_fields = PackedStringArray(["enabled", "timeout", "environment", "cwd"])
command_timeout_fields = PackedStringArray(["timeout"])
command_supports_url_fallback = true
+1
View File
@@ -0,0 +1 @@
uid://s8n0vfirf2pj
+63
View File
@@ -0,0 +1,63 @@
@tool
extends McpClient
func _init() -> void:
id = "pi"
display_name = "Pi Agent"
config_type = "json"
# pi-codemode-mcp reads MCP server definitions from ~/.pi/agent/mcp.json
# (first merge tier; ~/.pi/agent/.mcp.json, .pi/mcp.json, and
# .mcp.json merge after, project-scope last). Documented at
# github.com/mitsuhiko/pi-codemode-mcp README "Configuration files"
# section. Windows path mirrors antigravity's $USERPROFILE choice so
# the descriptor round-trips on every supported platform.
path_template = {
"unix": "~/.pi/agent/mcp.json",
"windows": "$USERPROFILE/.pi/agent/mcp.json",
}
# Pi merges these files in order; later definitions of the same server win.
# Keep this separate from config_path_candidates, whose entries are alternatives
# rather than merge tiers.
config_merge_path_templates = {
"unix": PackedStringArray([
"~/.pi/agent/mcp.json",
"~/.pi/agent/.mcp.json",
]),
"windows": PackedStringArray([
"$USERPROFILE/.pi/agent/mcp.json",
"$USERPROFILE/.pi/agent/.mcp.json",
]),
}
config_merge_project_paths = PackedStringArray([".pi/mcp.json", ".mcp.json"])
server_key_path = PackedStringArray(["mcpServers"])
# pi-codemode-mcp resolves these with nullish precedence:
# mcpServers ?? mcp-servers ?? servers. Reuse whichever map already exists
# so Configure cannot shadow and deactivate the user's existing servers.
var aliases: Array[PackedStringArray] = [
PackedStringArray(["mcp-servers"]),
PackedStringArray(["servers"]),
]
server_key_path_aliases = aliases
entry_url_field = "url"
# pi-codemode-mcp selects stdio from `command` and remote transport from
# `url`; it ignores `type`. Keep generated stdio entries minimal and typeless.
entry_extra_fields = {}
entry_initial_fields = {}
# Attach migration (#838). Pi stdio entries are flat command/args/env.
# When Configure replaces a legacy URL entry, remove `url`, `headers`, and
# any redundant `type` field so the generated stdio entry stays canonical.
command_shape = McpClient.CommandShape.FLAT
command_legacy_keys = PackedStringArray(["url", "headers", "type"])
# `env` is the only user-mutable field pi's docs surface. Future
# fields still survive — the strategy deep-copies unknown keys.
command_user_fields = PackedStringArray(["env"])
# Pi's loader also accepts pure-URL entries (the example mcp.json in
# the pi-codemode-mcp repo ships stdio + URL entries side-by-side).
# Keep the manual-instruction URL fallback alive.
command_supports_url_fallback = true
# ~/.pi/agent/mcp.json is created by the first pi launch, so its
# presence is the strongest install signal. Mirror antigravity's
# `detect_paths = path_template.values()` pattern so the dock shows
# the installed badge before Configure has run.
detect_paths = PackedStringArray(path_template.values())
+1
View File
@@ -0,0 +1 @@
uid://it00ohwkm2nd
+26
View File
@@ -0,0 +1,26 @@
@tool
extends McpClient
func _init() -> void:
id = "qwen_code"
display_name = "Qwen Code"
config_type = "json"
path_template = {
"unix": "~/.qwen/settings.json",
"windows": "$USERPROFILE/.qwen/settings.json",
}
server_key_path = PackedStringArray(["mcpServers"])
entry_url_field = "httpUrl"
## Attach migration (#838). Qwen Code is a gemini-cli fork with the same
## flat stdio shape and one-of `command` | `url` | `httpUrl` rule
## (docs/users/features/mcp.md). Qwen adds `discoveryTimeoutMs` (stdio
## discovery handshake cap, default 30s).
command_shape = McpClient.CommandShape.FLAT
command_legacy_keys = PackedStringArray(["httpUrl", "url"])
command_user_fields = PackedStringArray([
"timeout", "trust", "includeTools", "excludeTools", "env", "cwd",
"discoveryTimeoutMs",
])
command_timeout_fields = PackedStringArray(["timeout", "discoveryTimeoutMs"])
command_supports_url_fallback = true
+1
View File
@@ -0,0 +1 @@
uid://qwb5udkf423q
+42
View File
@@ -0,0 +1,42 @@
@tool
extends McpClient
func _init() -> void:
id = "roo_code"
display_name = "Roo Code"
config_type = "json"
path_template = {
"darwin": "~/Library/Application Support/Code/User/globalStorage/rooveterinaryinc.roo-cline/settings/mcp_settings.json",
"windows": "$APPDATA/Code/User/globalStorage/rooveterinaryinc.roo-cline/settings/mcp_settings.json",
"linux": "$XDG_CONFIG_HOME/Code/User/globalStorage/rooveterinaryinc.roo-cline/settings/mcp_settings.json",
}
server_key_path = PackedStringArray(["mcpServers"])
## Roo defaults an entry with no "type" to SSE transport — which returns
## HTTP 400 against our streamable-http endpoint on `/mcp`. Pin the type
## explicitly so Roo negotiates streamable-http (the current MCP spec's
## recommended remote transport). See issue #189. The default verifier
## requires every entry_extra_fields key to match, so a pre-#189 typeless
## entry surfaces as drift instead of silently passing as configured.
entry_extra_fields = {"type": "streamable-http"}
## `disabled` and `alwaysAllow` are user-state (they may have flipped the
## entry off, or auto-approved specific tools like `session_manage`).
## Seed on first Configure but preserve across reconfigure — without this
## split, the Configure-All-Mismatched sweep silently wipes the user's
## auto-approval list every time the type pin or URL drifts.
entry_initial_fields = {"disabled": false, "alwaysAllow": []}
## Attach migration (#838). Roo stdio entries are flat command/args/env
## (+cwd); docs state `type` defaults to "stdio" for command configs and
## the stdio schema forbids url/headers. Pin type=stdio — it is documented
## and repins the legacy "streamable-http" value in place.
command_shape = McpClient.CommandShape.FLAT
command_transport_key = "type"
command_transport_value = "stdio"
command_legacy_keys = PackedStringArray(["url", "headers"])
command_initial_fields = {"disabled": false, "alwaysAllow": []}
command_user_fields = PackedStringArray([
"disabled", "alwaysAllow", "timeout", "disabledTools", "watchPaths",
"env", "cwd",
])
command_timeout_fields = PackedStringArray(["timeout"])
command_supports_url_fallback = true
+1
View File
@@ -0,0 +1 @@
uid://denjdf50qrf66
+23
View File
@@ -0,0 +1,23 @@
@tool
extends McpClient
func _init() -> void:
id = "trae"
display_name = "Trae"
config_type = "json"
path_template = {
"darwin": "~/Library/Application Support/Trae/User/mcp.json",
"windows": "$APPDATA/Trae/User/mcp.json",
"linux": "$XDG_CONFIG_HOME/Trae/User/mcp.json",
}
server_key_path = PackedStringArray(["mcpServers"])
## Attach migration (#838). Trae stdio entries are flat command/args/env
## with no type discriminator (docs.trae.cn/ide/add-mcp-servers); transport
## is inferred from `command` vs `url`, so url/headers are legacy next to a
## command. Entry stays minimal — Trae manages enable/disable in its UI,
## and its startup/run timeouts ride user-owned env vars, which survive.
command_shape = McpClient.CommandShape.FLAT
command_legacy_keys = PackedStringArray(["url", "headers"])
command_user_fields = PackedStringArray(["env"])
command_supports_url_fallback = true
+1
View File
@@ -0,0 +1 @@
uid://cwpu48772vfj1
+30
View File
@@ -0,0 +1,30 @@
@tool
extends McpClient
## VS Code (stable) reads MCP servers from per-user mcp.json under
## `servers.<name>` with `{ "type": "http", "url": ... }`.
func _init() -> void:
id = "vscode"
display_name = "VS Code"
config_type = "json"
path_template = {
"darwin": "~/Library/Application Support/Code/User/mcp.json",
"windows": "$APPDATA/Code/User/mcp.json",
"linux": "$XDG_CONFIG_HOME/Code/User/mcp.json",
}
server_key_path = PackedStringArray(["servers"])
entry_extra_fields = {"type": "http"}
## Attach migration (#838). VS Code stdio entries are flat command/args/env
## under `servers` with a documented `type: "stdio"` discriminator
## (code.visualstudio.com/docs/agents/reference/mcp-configuration). The
## stdio schema is `additionalProperties: false` (mcpConfiguration.ts), so
## removing the legacy url/headers is load-bearing — leftovers invalidate
## the whole entry, and the pin flips the legacy `type: "http"` in place.
command_shape = McpClient.CommandShape.FLAT
command_transport_key = "type"
command_transport_value = "stdio"
command_legacy_keys = PackedStringArray(["url", "headers"])
command_user_fields = PackedStringArray(["env", "envFile", "cwd", "sandboxEnabled", "dev"])
command_supports_url_fallback = true
+1
View File
@@ -0,0 +1 @@
uid://dl6cm044pihub
@@ -0,0 +1,23 @@
@tool
extends McpClient
func _init() -> void:
id = "vscode_insiders"
display_name = "VS Code Insiders"
config_type = "json"
path_template = {
"darwin": "~/Library/Application Support/Code - Insiders/User/mcp.json",
"windows": "$APPDATA/Code - Insiders/User/mcp.json",
"linux": "$XDG_CONFIG_HOME/Code - Insiders/User/mcp.json",
}
server_key_path = PackedStringArray(["servers"])
entry_extra_fields = {"type": "http"}
## Attach migration (#838). Identical format to vscode.gd — Insiders ships
## the same mcpConfiguration.ts schema; see that descriptor for citations.
command_shape = McpClient.CommandShape.FLAT
command_transport_key = "type"
command_transport_value = "stdio"
command_legacy_keys = PackedStringArray(["url", "headers"])
command_user_fields = PackedStringArray(["env", "envFile", "cwd", "sandboxEnabled", "dev"])
command_supports_url_fallback = true
@@ -0,0 +1 @@
uid://cad5w4ofyg8a2
+29
View File
@@ -0,0 +1,29 @@
@tool
extends McpClient
func _init() -> void:
# #623: Windsurf was rebranded to Devin Desktop by Cognition (June 2026).
# The id stays "windsurf" — it is the stable registry key used for
# configured-status lookups. The MCP config path is unchanged by the
# rebrand: per the official docs (docs.devin.ai/desktop/cascade/mcp) the
# global config still lives under the platform's `.codeium/windsurf/`
# directory (~/.codeium/windsurf/ on unix, $USERPROFILE/.codeium/windsurf/
# on Windows), and migrated installs carry their settings over in place.
id = "windsurf"
display_name = "Devin Desktop (Windsurf)"
config_type = "json"
path_template = {
"unix": "~/.codeium/windsurf/mcp_config.json",
"windows": "$USERPROFILE/.codeium/windsurf/mcp_config.json",
}
server_key_path = PackedStringArray(["mcpServers"])
entry_url_field = "serverUrl"
## Attach migration (#838). Stdio entries are flat command/args/env
## (docs.devin.ai/desktop/cascade/mcp); transport is inferred from
## `command` vs `serverUrl` presence and no type field is documented —
## do not write one.
command_shape = McpClient.CommandShape.FLAT
command_legacy_keys = PackedStringArray(["serverUrl"])
command_user_fields = PackedStringArray(["env"])
command_supports_url_fallback = true
+1
View File
@@ -0,0 +1 @@
uid://b6pqiok2mlsmg
+29
View File
@@ -0,0 +1,29 @@
@tool
extends McpClient
## Zed registers MCP servers under `context_servers.<name>` and supports both
## stdio and streamable http transports.
func _init() -> void:
id = "zed"
display_name = "Zed"
config_type = "json"
path_template = {
"darwin": "~/.config/zed/settings.json",
"linux": "$XDG_CONFIG_HOME/zed/settings.json",
"windows": "$APPDATA/Zed/settings.json",
}
server_key_path = PackedStringArray(["context_servers"])
## Attach migration (#838). Current Zed's context_servers entries are an
## untagged serde enum discriminated by shape: `command` (string) + args/env
## → stdio, `url` → HTTP (zed.dev/docs/ai/mcp; settings_content/project.rs).
## BECAUSE the enum is untagged, HTTP-only keys left next to `command`
## (`url`, `headers`, `oauth`) make the entry match no variant and break it —
## removing them on migration is load-bearing, not cosmetic. `enabled`,
## `remote`, and `timeout` are user-state on the stdio variant.
command_shape = McpClient.CommandShape.FLAT
command_legacy_keys = PackedStringArray(["url", "headers", "oauth"])
command_user_fields = PackedStringArray(["enabled", "remote", "timeout", "env"])
command_timeout_fields = PackedStringArray(["timeout"])
command_supports_url_fallback = true
+1
View File
@@ -0,0 +1 @@
uid://d152l0u0r6fsc
+36
View File
@@ -0,0 +1,36 @@
@tool
extends McpClient
func _init() -> void:
id = "zoo_code"
display_name = "Zoo Code"
config_type = "json"
path_template = {
"darwin": "~/Library/Application Support/Code/User/globalStorage/zoocodeorganization.zoo-code/settings/mcp_settings.json",
"windows": "$APPDATA/Code/User/globalStorage/zoocodeorganization.zoo-code/settings/mcp_settings.json",
"linux": "$XDG_CONFIG_HOME/Code/User/globalStorage/zoocodeorganization.zoo-code/settings/mcp_settings.json",
}
server_key_path = PackedStringArray(["mcpServers"])
## Local validation against the installed extension shows Zoo stores MCP
## entries in `settings/mcp_settings.json` under `mcpServers`, matching Roo's
## shape. Its changelog also references Streamable HTTP support, so pin the
## transport explicitly to avoid any typeless entry falling back to SSE.
entry_extra_fields = {"type": "streamable-http"}
## Preserve user-controlled state across reconfigure, parallel to Roo/Kilo.
entry_initial_fields = {"disabled": false, "alwaysAllow": []}
## Attach migration (#838). Zoo's stdio zod schema is Roo's: flat
## command/args/env(+cwd), `type: z.enum(["stdio"]).optional()`, and
## url/headers explicitly forbidden on stdio entries (McpHub.ts) — so both
## are legacy keys and the documented type pin repins "streamable-http".
command_shape = McpClient.CommandShape.FLAT
command_transport_key = "type"
command_transport_value = "stdio"
command_legacy_keys = PackedStringArray(["url", "headers"])
command_initial_fields = {"disabled": false, "alwaysAllow": []}
command_user_fields = PackedStringArray([
"disabled", "alwaysAllow", "timeout", "watchPaths", "disabledTools",
"env", "cwd",
])
command_timeout_fields = PackedStringArray(["timeout"])
command_supports_url_fallback = true
+1
View File
@@ -0,0 +1 @@
uid://fmp0nlzcm3ukl
File diff suppressed because it is too large Load Diff
+1
View File
@@ -0,0 +1 @@
uid://bmnk8rsotiks2
@@ -0,0 +1,66 @@
@tool
extends RefCounted
## bridge between dispatcher and addon handler
const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd")
var _spec: McpCustomToolSpec
var _locator: McpServiceLocator
var _handler_instance = null # lazily loaded from _spec.script_path
func _init(spec: McpCustomToolSpec, locator: McpServiceLocator) -> void:
_spec = spec
_locator = locator
## Invoked by dispatcher._call_handler as .call(params) — SINGLE ARG.
## Internally splits into (clean_params, ctx) for the addon handler.
func invoke(params: Dictionary) -> Dictionary:
## Dock enable/disable gate: a disabled tool is dropped from the
## catalog push, but a client holding a stale list (or batch_execute)
## can still name it — reject at dispatch too.
var registry := McpToolRegistry.get_instance()
if registry != null and not registry.is_tool_enabled(_spec.name):
return ErrorCodes.make(ErrorCodes.CUSTOM_TOOL_DISABLED,
"Custom tool '%s' is disabled in the Godot AI dock" % _spec.name)
## Readiness gate (https://github.com/hi-godot/godot-ai/issues/781#issuecomment-5036376599 #1): block writes during play/import.
## Checked BEFORE the lazy load so a gated tool's handler script (and any
## _init side effects) never runs while the editor is busy.
var _readiness := McpConnection.get_readiness()
if _spec.requires_writable and (_readiness == "importing" or _readiness == "playing"):
return ErrorCodes.make(ErrorCodes.EDITOR_NOT_READY, "Editor is '%s' — write blocked for custom tool '%s'" % [_readiness, _spec.name])
if _handler_instance == null:
var script := load(_spec.script_path) as GDScript
if script == null:
return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Cannot load %s" % _spec.script_path)
_handler_instance = script.new() # no-arg; per-call context arrives via ctx
if not _handler_instance.has_method(_spec.method):
return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "%s has no method '%s' for custom tool '%s'" % [_spec.script_path, _spec.method, _spec.name])
## Extract _request_id (dispatcher injected it) and strip from params
## so the addon sees clean params matching its declared schema (https://github.com/hi-godot/godot-ai/issues/781#issuecomment-5036376599 #2).
var request_id: String= params.get("_request_id", "")
var clean_params := params.duplicate()
clean_params.erase("_request_id")
## Construct ctx with transport metadata + live-object locator.
var ctx := McpCallContext.new()
ctx.request_id = request_id
ctx.session_id = _locator.get_connection().get_session_id()
ctx.spec = _spec
ctx.attach_locator(_locator)
ctx.deadline_msec = Time.get_ticks_msec() + _spec.timeout_ms
var result: Dictionary = _handler_instance.call(_spec.method, clean_params, ctx)
if result.get("_deferred", false):
## Enforce the declared contract: batch_execute and the server's
## timeout budget both trust spec.deferred, so a non-deferred spec
## whose handler defers anyway would report success while its real
## reply arrives uncorrelated later.
if not _spec.deferred:
return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR,
"Custom tool '%s' returned a deferred response but its spec declares deferred=false" % _spec.name)
if _spec.timeout_ms > 0:
## Handlers return the SHARED McpDispatcher.DEFERRED_RESPONSE
## const, which Godot makes read-only — stamping the per-spec
## budget on it directly is a script error that aborts the call.
result = result.duplicate()
result["_deferred_timeout_ms"] = _spec.timeout_ms
return result
@@ -0,0 +1 @@
uid://b68uel6lsb7jr
@@ -0,0 +1,44 @@
@tool
class_name McpCallContext
extends RefCounted
## Per-call context handed to addon handlers.
##
## STABLE addon-facing surface (see AGENTS.md — published class_names are
## permanent compat surface): `request_id`, `session_id`, `deadline_msec`,
## `spec` (read-only), `is_expired()`, `send_deferred()`. Anything
## underscore-prefixed is internal wiring owned by the wrapper and may
## change between releases — addons must not reach into it. Keeping this
## surface narrow now is deliberate: it is much cheaper than breaking
## published addons later (#875 review).
var request_id: String = ""
var session_id: String = ""
var deadline_msec: int = 0
var spec: McpCustomToolSpec = null
## Internal — injected by custom_tool_wrapper via attach_locator(). Do not
## use from addon code; the capability methods below are the contract.
var _locator: McpServiceLocator = null
## Internal — called by the wrapper during ctx construction.
func attach_locator(locator: McpServiceLocator) -> void:
_locator = locator
func is_expired() -> bool:
if deadline_msec == 0:
return false
return Time.get_ticks_msec() > deadline_msec
func send_deferred(payload: Dictionary) -> void:
if _locator == null:
push_error("McpCallContext: cannot send deferred response, locator is null")
return
var conn := _locator.get_connection()
if conn == null:
push_error("McpCallContext: cannot send deferred response, connection is null")
return
conn.send_deferred_response(request_id, payload)
@@ -0,0 +1 @@
uid://bdgqe4gyian2o
@@ -0,0 +1,66 @@
@tool
class_name McpCustomToolSpec
extends RefCounted
## Descriptor for one custom tool registered by an addon. Mirrors the
## clients/_base.gd data-only descriptor pattern: fields are set by the
## caller, NO Callables (handler instance is materialized lazily by the
## dispatcher from script_path + method, so hot-reload can't SEGV a
## worker mid-call — same rationale as McpClient, issue #229).
##
## Construction: instantiate and set fields, then pass to
## McpToolRegistry.register(spec). Do NOT subclass — custom tools are
## dynamic (one addon may register several), not file-system-scanned
## like clients.
# --- identity ---
var name: String = "" ## tool name, e.g. "gdunit_run". Must not shadow a built-in.
var description: String = "" ## shown to the agent by the MCP server
var params_schema: Dictionary = {} ## JSON Schema; advertised to the agent for shaping calls. Params are forwarded UNVALIDATED — the handler must validate its own input.
# --- handler resolution (lazy materialization by dispatcher) ---
var script_path: String = "" ## "res://addons/.../handler.gd"; load()ed on first call
var method: StringName = &"" ## method on the handler; signature: (params: Dictionary, ctx: McpCallContext) -> Dictionary
# --- source identity (https://github.com/hi-godot/godot-ai/issues/781#issuecomment-5036376599 #8) ---
var source_path: String = "" ## "plugin.cfg" path: "res://addons/gdunit4_mcp/plugin.cfg". Same path = same addon (replace allowed); different path colliding = reject + dock warning. NOTE: self-declared — a collision/ownership policy for cooperating addons, NOT a security boundary (any in-editor code can claim any path).
var source: String = "" ## display: "gdunit4_mcp". If empty, registry reads [plugin] name from source_path.
# --- exposure ---
var promoted: bool = false ## opt-in: ask the server to ALSO register this tool as a first-class MCP tool ("custom_<name>") with params_schema attached, so agents get native schemas/validation instead of the custom_manage indirection. The server caps promoted count; overflow stays reachable via custom_manage.
# --- execution contract ---
var timeout_ms: int = 4500 ## deferred timeout → DEFERRED_TIMEOUT_MS_BY_COMMAND["custom:<name>"]
var deferred: bool = false ## true → handler may return DEFERRED_RESPONSE, push later via ctx.send_deferred(payload)
var requires_writable: bool = false ## https://github.com/hi-godot/godot-ai/issues/781#issuecomment-5036376599 #1: readiness gate. false → reads run any time; true → blocks during play/import
var undoable: bool = false ## https://github.com/hi-godot/godot-ai/issues/781#issuecomment-5036376599 #6: must be true to participate in undo=true batch_execute
# --- budgets (https://github.com/hi-godot/godot-ai/issues/781#issuecomment-5036376599 #5, enforced at register time) ---
const MAX_DESCRIPTION_CHARS := 600
const MAX_SCHEMA_BYTES := 8192
const MAX_TIMEOUT_MS := 120000
const MIN_TIMEOUT_MS := 500
## Returns an array of human-readable validation errors (empty = valid).
## Called by McpToolRegistry.register() and usable standalone for early
## feedback in addon _enter_tree before the registry is even live.
func validate() -> Array[String]:
var errors: Array[String] = []
if name.is_empty():
errors.append("name is empty\n")
if not name.is_valid_ascii_identifier():
errors.append("name '%s' is not a valid identifier\n" % name)
if description.length() > MAX_DESCRIPTION_CHARS:
errors.append("description exceeds %d chars\n" % MAX_DESCRIPTION_CHARS)
if JSON.stringify(params_schema).to_utf8_buffer().size() > MAX_SCHEMA_BYTES:
errors.append("params_schema exceeds %d bytes\n" % MAX_SCHEMA_BYTES)
if script_path.is_empty() or not ResourceLoader.exists(script_path):
errors.append("script_path '%s' does not exist" % script_path)
if method.is_empty():
errors.append("method is empty")
if timeout_ms < MIN_TIMEOUT_MS or timeout_ms > MAX_TIMEOUT_MS:
errors.append("timeout_ms %d out of range [%d, %d]" % [timeout_ms, MIN_TIMEOUT_MS, MAX_TIMEOUT_MS])
if source_path.is_empty() or not source_path.ends_with("plugin.cfg") or not FileAccess.file_exists(source_path):
errors.append("source_path '%s' must be an existing plugin.cfg path" % source_path)
return errors
@@ -0,0 +1 @@
uid://h7k8j5tpscwy
@@ -0,0 +1,16 @@
@tool
class_name McpServiceLocator
extends RefCounted
var _connection: McpConnection = null
var _log_buffer: McpLogBuffer = null
func setup(connection: McpConnection, log_buffer: McpLogBuffer) -> void:
_connection = connection
_log_buffer = log_buffer
func get_connection() -> McpConnection:
return _connection
func get_log_buffer() -> McpLogBuffer:
return _log_buffer
@@ -0,0 +1 @@
uid://dfvphmvo7en3i
@@ -0,0 +1,277 @@
@tool
class_name McpToolRegistry
extends RefCounted
## Central registry for custom tools. INSTANCE SINGLETON (not static) —
## unlike McpClientRegistry which is read-only/loaded-once/query-only (static
## is fine there), this registry is MUTABLE, has LIFECYCLE (clear on teardown),
## must EMIT SIGNALS to multiple consumers, and needs DEPENDENCY INJECTION
## (dispatcher, locator). Static funcs can't emit instance signals in GDScript,
## and static vars create #46 freed-instance regressions across reloads.
##
## plugin.gd owns the instance (new + setup + clear). Addons access it via
## get_instance() so they don't hold a reference. Source identity is
## spec.source_path (explicit plugin.cfg path, https://github.com/hi-godot/godot-ai/issues/781#issuecomment-5036376599 #8) — same source_path
## = same addon (hot-reload replace); different source_path colliding on
## a name = reject + dock warning.
## singleton instance
static var _instance: McpToolRegistry = null
## Singleton accessor for addons. Returns null if Godot AI isn't loaded.
static func get_instance() -> McpToolRegistry:
return _instance
## Instance fields (NOT static — lifecycle tied to plugin.gd)
var _specs: Dictionary = {} # name -> McpCustomToolSpec
var _by_source_path: Dictionary = {} # source_path -> Array[McpCustomToolSpec] (batch unregister index)
var _dispatcher: McpDispatcher = null # injected by plugin.gd via setup()
var _locator: McpServiceLocator = null # injected by plugin.gd via setup()
var _ready: bool = false
var _promoted_cnt = 0
## Instance signals (LEGAL — instance funcs can emit these). Multiple consumers
## (plugin.gd, dock, addons) can connect.
signal tools_changed # triggers custom_tools_changed push to server
signal registry_ready # https://github.com/hi-godot/godot-ai/issues/781#issuecomment-5036376599 #8: emit after Godot AI reload so addons can re-register
## Called by plugin.gd in _enter_tree. Injects live dependencies and registers
## the singleton. Addons can now reach the registry via get_instance().
func setup(dispatcher: McpDispatcher, locator: McpServiceLocator) -> void:
_dispatcher = dispatcher
_locator = locator
_load_disabled_tools()
_instance = self
func register(spec: McpCustomToolSpec) -> bool:
if not _validate_one(spec):
return false
_commit_one(spec)
tools_changed.emit()
return true
## Atomic batch registration: validate ALL specs first (phase 1, zero
## mutation), then commit ALL (phase 2, cannot fail), then emit ONCE.
## A phase-1 failure leaves the registry AND dispatcher untouched — a
## per-spec register-then-bail loop would leave earlier specs committed
## and dispatchable while the server's tool list never hears about them.
func batch_register(specs: Array[McpCustomToolSpec]) -> bool:
if specs.is_empty():
return true
## Phase 1 — validate only. Also reject duplicate names WITHIN the
## batch: _validate_one checks against committed state (nothing from
## this batch is committed yet), and silently letting the last
## duplicate win would hide an addon packaging bug.
var batch_names := {}
for spec in specs:
if not _validate_one(spec):
return false
if batch_names.has(spec.name):
push_warning("McpToolRegistry: duplicate name '%s' within batch — rejected" % spec.name)
return false
batch_names[spec.name] = true
## Phase 2 — commit everything, emit once.
for spec in specs:
_commit_one(spec)
tools_changed.emit()
return true
## Phase 1 helper: validation + collision policy. NO registry/dispatcher
## mutation — the only side effect is filling the display-source fallback
## on the spec itself, which is harmless on reject.
func _validate_one(spec: McpCustomToolSpec) -> bool:
var errors := spec.validate()
if not errors.is_empty():
push_error("McpToolRegistry: rejecting spec '%s': %s" % [spec.name, ", ".join(errors)])
return false
## Fill display source from plugin.cfg if addon left it empty.
if spec.source.is_empty():
spec.source = _read_plugin_name(spec.source_path)
## reject-on-collision: same source_path (hot-reload) replaces, different rejects
var existing: McpCustomToolSpec= _specs.get(spec.name)
if existing != null and existing.source_path != spec.source_path:
push_warning("McpToolRegistry: name '%s' collision between %s and %s — rejected" % [spec.name, existing.source_path, spec.source_path])
return false
return true
## Phase 2 helper: commit one ALREADY-VALIDATED spec. Handles the
## hot-reload replace (same source_path — guaranteed by _validate_one).
## Never fails, never emits — callers own the tools_changed emit.
func _commit_one(spec: McpCustomToolSpec) -> void:
var existing: McpCustomToolSpec = _specs.get(spec.name)
if existing != null:
## Replace: purge the OLD registration from all four dispatcher
## dicts. Command name is "custom_tool:<name>" — passing the bare
## name would leave a materialized Callable in _handlers and the
## hot-reloaded spec would silently never take effect.
_dispatcher.unregister("custom_tool:" + existing.name, "custom:" + existing.name)
_erase_from_source_path_index_locked(existing)
_specs[spec.name] = spec
_by_source_path.get_or_add(spec.source_path, []).append(spec)
## Bridge to dispatcher lazy registration: register_lazy_handler + register_lazy.
## handler_key = "custom:<name>"; wrapper is CustomToolWrapper (https://github.com/hi-godot/godot-ai/issues/781#issuecomment-5036376599 #2).
var handler_key := "custom:" + spec.name
var wrapper_path := "res://addons/godot_ai/custom_tools/custom_tool_wrapper.gd"
_dispatcher.register_lazy_handler(handler_key, wrapper_path, [spec, _locator])
_dispatcher.register_lazy("custom_tool:" + spec.name, handler_key, &"invoke")
func unregister(name: String) -> bool:
var spec: McpCustomToolSpec = _specs.get(name)
if spec == null:
return false
if _dispatcher != null:
_dispatcher.unregister("custom_tool:" + name, "custom:" + name)
_specs.erase(name)
_erase_from_source_path_index_locked(spec)
tools_changed.emit()
return true
## https://github.com/hi-godot/godot-ai/issues/781#issuecomment-5036376599 #8: batch-unregister all tools from one addon. Pass the source_path
## (plugin.cfg path) — the registry removes every spec whose source_path matches.
func unregister_source(source_path: String) -> int:
var specs: Array = _by_source_path.get(source_path, [])
var count := specs.size()
for spec in specs.duplicate():
if _dispatcher != null:
_dispatcher.unregister("custom_tool:" + spec.name, "custom:" + spec.name)
_specs.erase(spec.name)
_by_source_path.erase(source_path)
if count > 0:
tools_changed.emit()
return count
func all() -> Array[McpCustomToolSpec]:
var out: Array[McpCustomToolSpec] = []
for spec in _specs.values():
out.append(spec)
return out
## Enabled specs only — the catalog-push source. Disabled tools stay
## registered (the dock still lists them for re-enabling) but are never
## advertised to the server and are rejected at dispatch.
func enabled() -> Array[McpCustomToolSpec]:
var out: Array[McpCustomToolSpec] = []
for spec in _specs.values():
if is_tool_enabled(spec.name):
out.append(spec)
return out
# --- per-tool enable state (dock UI) ---
## Persisted per-project via EditorSettings project metadata (the same
## store the editor uses for per-project UI state — no project.godot
## churn, no cross-project bleed). Applies LIVE: toggling re-emits
## tools_changed, which re-pushes the filtered catalog to the server.
const _META_SECTION := "godot_ai"
const _META_KEY_DISABLED := "disabled_custom_tools"
var _disabled_tools: Dictionary = {} # name -> true
func is_tool_enabled(name: String) -> bool:
return not _disabled_tools.has(name)
func set_tool_enabled(name: String, tool_enabled: bool) -> void:
if tool_enabled:
if not _disabled_tools.has(name):
return
_disabled_tools.erase(name)
else:
if _disabled_tools.has(name):
return
_disabled_tools[name] = true
_save_disabled_tools()
tools_changed.emit()
func _load_disabled_tools() -> void:
_disabled_tools.clear()
var es := _editor_settings()
if es == null:
return
var stored: Variant = es.get_project_metadata(_META_SECTION, _META_KEY_DISABLED, PackedStringArray())
for name in PackedStringArray(stored):
_disabled_tools[String(name)] = true
func _save_disabled_tools() -> void:
var es := _editor_settings()
if es == null:
return
var names := PackedStringArray()
for name in _disabled_tools.keys():
names.append(String(name))
es.set_project_metadata(_META_SECTION, _META_KEY_DISABLED, names)
static func _editor_settings() -> EditorSettings:
if not Engine.is_editor_hint():
return null
return EditorInterface.get_editor_settings()
func get_spec(name: String) -> McpCustomToolSpec:
var out: McpCustomToolSpec = _specs.get(name)
return out
func is_ready() -> bool:
return _ready
## Called from plugin.gd teardown — wipes everything so stale specs/Callables
## don't survive a Godot AI reload (#46 regression class). Signals die with
## the instance (consumers disconnect via plugin.gd's own teardown).
func clear() -> void:
_specs.clear()
_by_source_path.clear()
_disabled_tools.clear() # in-memory only; persistence reloads on next setup()
_ready = false
_dispatcher = null
_locator = null
_instance = null
## Called from plugin.gd _enter_tree after setup(). Addons that missed
## _enter_tree during a Godot AI reload listen for registry_ready (via
## get_instance().registry_ready.connect(...)) and re-register deterministically.
func mark_ready() -> void:
_ready = true
registry_ready.emit()
# --- internal helpers ---
func _erase_from_source_path_index_locked(spec: McpCustomToolSpec) -> void:
var specs: Array = _by_source_path.get(spec.source_path, [])
specs.erase(spec)
if specs.is_empty():
_by_source_path.erase(spec.source_path)
## Read [plugin] name from a plugin.cfg for display purposes.
## Returns "" on any error — display source is best-effort.
static func _read_plugin_name(plugin_cfg_path: String) -> String:
var cfg := ConfigFile.new()
if cfg.load(plugin_cfg_path) != OK:
return ""
return cfg.get_value("plugin", "name", "")
static func _get_command_name(spec: McpCustomToolSpec) -> String:
return "custom_tool:" + spec.name
static func _get_handler_name(spec: McpCustomToolSpec) -> String:
return "custom:" + spec.name
@@ -0,0 +1 @@
uid://bq2btxtm8flhb
File diff suppressed because it is too large Load Diff
@@ -0,0 +1 @@
uid://bd1k63iye1bsl
+476
View File
@@ -0,0 +1,476 @@
@tool
class_name McpDispatcher
extends RefCounted
## Routes incoming commands to handlers and manages the command queue
## with a per-frame time budget.
var _command_queue: Array[Dictionary] = []
var _handlers: Dictionary = {} # command_name -> Callable
## Lazy handler registration (#736): plugin.gd registers command names
## against a handler key plus a per-handler script path and constructor
## args, and the handler script is load()ed and instantiated at the FIRST
## dispatch of one of its commands. This keeps the ~30 handler scripts
## (and everything they preload) out of plugin.gd's eager compile
## closure, which stalled "Initializing plugins" on every editor boot.
## Materialized commands are promoted into `_handlers`, so the lazy dicts
## are only consulted on the first call per command.
var _lazy_handler_specs: Dictionary = {} # handler_key -> {path: String, args: Array}
var _lazy_handler_cache: Dictionary = {} # handler_key -> handler instance
var _lazy_commands: Dictionary = {} # command_name -> {handler: String, method: StringName}
var _pending_deferred: Dictionary = {} # request_id -> {command, started_ms, timeout_ms}
var _log_buffer
var _surfaced_error_tracker
## The McpConnection whose pause_processing handlers flip around unsafe
## editor operations (#288 guard). Set by plugin.gd; untyped to honor the
## self-update field-storage policy. When set, _call_handler restores the
## pause depth a crashed handler left unbalanced (#712) — without this a
## single handler crash inside a pause window freezes the transport
## forever (pause has no watchdog or disconnect reset by design).
var pause_target
var mcp_logging := true
var deferred_timeout_overrides_ms: Dictionary = {}
const DEFAULT_DEFERRED_TIMEOUT_MS := 4500
const DEFERRED_TIMEOUT_MS_BY_COMMAND := {
"create_script": 4500,
## Fresh-`.gd` writes defer through the same import-settle window as
## create_script (#714) — same headroom over IMPORT_SETTLE_MAX_MSEC.
"write_file": 4500,
"stop_project": 4500,
"run_project": 6000,
"take_screenshot": 30000,
"check_client_status": 30000,
"game_eval": 15000,
"game_command": 15000,
"scan_filesystem": 30000,
}
const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd")
const FuzzySuggestions := preload("res://addons/godot_ai/utils/fuzzy_suggestions.gd")
func _init(log_buffer: McpLogBuffer, surfaced_error_tracker = null) -> void:
_log_buffer = log_buffer
_surfaced_error_tracker = surfaced_error_tracker
## Register a command handler. The callable receives (params: Dictionary) -> Dictionary.
func register(command_name: String, handler: Callable) -> void:
_handlers[command_name] = handler
## Declare a lazily-constructed handler (#736). `script_path` is load()ed
## and instantiated with `ctor_args` at the first dispatch of any command
## registered against `handler_key` via register_lazy. `ctor_args` may hold
## plugin-lifetime objects (connection, buffers, the dispatcher itself for
## batch); clear() drops them so teardown ordering matches the old eager
## registration (#46).
func register_lazy_handler(handler_key: String, script_path: String, ctor_args: Array) -> void:
_lazy_handler_specs[handler_key] = {"path": script_path, "args": ctor_args}
## Register a command that resolves to `method` on the lazily-constructed
## handler declared under `handler_key`. Same dispatch semantics as
## register(); only construction timing differs.
func register_lazy(command_name: String, handler_key: String, method: StringName) -> void:
_lazy_commands[command_name] = {"handler": handler_key, "method": method}
func unregister(command_name: String, handler_key: String) -> void:
_handlers.erase(command_name)
_lazy_commands.erase(command_name)
## Drop the lazy spec/cache only when no remaining command routes to
## this handler key. Custom tools use 1:1 keys ("custom:<name>") so this
## always erases for them, but built-in keys serve many commands —
## erasing the shared spec would strand the siblings on
## "No lazy handler declared" at dispatch time.
for entry in _lazy_commands.values():
if entry.get("handler", "") == handler_key:
return
_lazy_handler_cache.erase(handler_key)
_lazy_handler_specs.erase(handler_key)
## Drop registered handlers, queued commands, and the log buffer ref so
## plugin.gd can release RefCounted handlers before Godot reloads their
## class_name scripts (issue #46). After clear(), the dispatcher is inert.
func clear() -> void:
## Stop lazy handlers before releasing the cache. Handler-owned polling
## coroutines retain any in-flight worker and deferred-response connection
## across frames, then join only after the worker is no longer alive.
for instance in _lazy_handler_cache.values():
if is_instance_valid(instance) and instance.has_method("prepare_for_teardown"):
instance.call("prepare_for_teardown")
_handlers.clear()
## Release lazily-constructed handler instances (and the ctor args that
## reference plugin-lifetime objects) at the same teardown point where
## eager handler Callables used to be dropped — their destructors must
## run while their scripts are still loaded (#46). This also breaks the
## dispatcher -> batch handler -> dispatcher ref cycle.
_lazy_handler_specs.clear()
_lazy_handler_cache.clear()
_lazy_commands.clear()
_command_queue.clear()
_pending_deferred.clear()
_log_buffer = null
_surfaced_error_tracker = null
pause_target = null
## Drop queued-but-unexecuted commands. Called by the connection on
## disconnect (#712): commands queued by the previous connection must not
## execute under the next one — the requester is gone, its in-flight
## futures were already failed server-side, and a mutation landing after
## reconnect is a surprise write nobody can correlate. Deferred bookkeeping
## has its own reset (clear_deferred_responses).
func clear_command_queue() -> void:
_command_queue.clear()
## Invoke a registered handler directly by name. Returns the handler's raw
## response dict (no request_id or status wrapping). Returns an UNKNOWN_COMMAND
## error dict if the command is not registered. Used by batch_execute.
func dispatch_direct(command: String, params: Dictionary) -> Dictionary:
if not has_command(command):
return ErrorCodes.make(ErrorCodes.UNKNOWN_COMMAND, "Unknown command: %s" % command)
## Strip the reserved deferred-reply key: only _dispatch may thread it.
## A caller-supplied _request_id (e.g. inside a batch_execute
## sub-command's params) would flip a deferred-capable handler into
## deferred mode against a request id the dispatcher never registered —
## the direct caller would get the DEFERRED sentinel instead of a result
## and the out-of-band reply would be dropped as expired.
if params.has("_request_id"):
params = params.duplicate()
params.erase("_request_id")
return _call_handler(command, params)
## Whether a command is registered (eagerly or lazily).
func has_command(command: String) -> bool:
return _handlers.has(command) or _lazy_commands.has(command)
## Rank registered commands by similarity to `cmd_name` and return the top `limit`
## matches. Uses Godot's built-in String.similarity() (0.0–1.0). Returns an empty
## array if no candidates clear the threshold. Used by batch_execute to surface
## "did you mean" suggestions when an unknown command is passed.
func suggest_similar(cmd_name: String, limit: int = 3, threshold: float = 0.5) -> Array[String]:
return FuzzySuggestions.rank(cmd_name, _registered_command_names(), limit, threshold, 0.0, 0.0)
## Union of eagerly-registered and lazily-registered command names.
## Materialized lazy commands live in both dicts, so dedupe via keys.
func _registered_command_names() -> Array:
var names: Dictionary = {}
for command in _handlers:
names[command] = true
for command in _lazy_commands:
names[command] = true
return names.keys()
## Enqueue a raw command dict received from the WebSocket.
func enqueue(cmd: Dictionary) -> void:
_command_queue.append(cmd)
func pending_deferred_count() -> int:
return _pending_deferred.size()
func clear_deferred_responses() -> void:
_pending_deferred.clear()
func has_pending_deferred_response(request_id: String) -> bool:
return request_id.is_empty() or _pending_deferred.has(request_id)
func complete_deferred_response(request_id: String) -> bool:
if request_id.is_empty():
return true
if not _pending_deferred.has(request_id):
return false
_pending_deferred.erase(request_id)
return true
## Handlers whose response flows out-of-band (e.g. debugger-channel capture)
## return this marker so tick() skips auto-sending a response. The handler is
## responsible for pushing the final response via McpConnection._send_json when
## the async operation completes. The dispatcher tracks the request_id and emits
## DEFERRED_TIMEOUT if the out-of-band response never arrives. The request_id is
## threaded through params under the "_request_id" key so the handler can
## correlate the response.
const DEFERRED_RESPONSE := {"_deferred": true}
## Process queued commands within a frame budget (milliseconds).
## Returns an array of response dictionaries to send back.
func tick(budget_ms: float = 4.0) -> Array[Dictionary]:
var responses: Array[Dictionary] = _collect_deferred_timeouts()
var start := Time.get_ticks_msec()
var idx := 0
while idx < _command_queue.size() and (Time.get_ticks_msec() - start) < budget_ms:
var cmd: Dictionary = _command_queue[idx]
var response := _dispatch(cmd)
if not response.get("_deferred", false):
responses.append(response)
idx += 1
if idx > 0:
_command_queue = _command_queue.slice(idx)
return responses
func _dispatch(cmd: Dictionary) -> Dictionary:
var request_id: String = cmd.get("request_id", "")
var command: String = cmd.get("command", "")
var raw_params: Dictionary = cmd.get("params", {})
## Duplicate so the internal _request_id key we thread through doesn't
## mutate the queued command's params (which is the same dict we're
## about to JSON-log below, and which later readers like batch_execute
## shouldn't see dispatcher-internal metadata from).
var params: Dictionary = raw_params.duplicate()
params["_request_id"] = request_id
if mcp_logging:
_log_buffer.log("[recv] %s(%s)" % [command, JSON.stringify(raw_params)])
var result: Dictionary
if has_command(command):
result = _call_handler(command, params)
else:
result = ErrorCodes.make(ErrorCodes.UNKNOWN_COMMAND, "Unknown command: %s" % command)
if result.get("_deferred", false):
## A handler may attach `_deferred_timeout_ms` to its deferred sentinel
## to claim a per-request budget larger than its command's shared entry
## (e.g. game_command's `input_sequence`, which steps frames well past
## the 15s that suits one-shot game ops). 0/absent falls back to the
## per-command table.
_register_deferred(request_id, command, int(result.get("_deferred_timeout_ms", 0)))
if mcp_logging:
_log_buffer.log("[defer] %s (request %s)" % [command, request_id])
return result
result["request_id"] = request_id
if not result.has("status"):
result["status"] = "ok"
## Stamp live editor readiness onto every command-response envelope so
## the server's `Session.readiness` cache self-heals on the very next
## tool call. Without this, a single dropped `readiness_changed` event
## (or a one-frame race around `pause_processing`) leaves the cache
## stuck at "playing" / "importing" long after the editor has settled,
## and write tools fail with EDITOR_NOT_READY against a writable editor.
## See connection.gd::send_deferred_response for the deferred-response
## counterpart, which stamps the same field.
result["readiness"] = McpConnection.get_readiness()
_stamp_error_watermark(result)
if mcp_logging:
var status: String = result.get("status", "ok")
if status == "ok":
_log_buffer.log("[send] %s -> ok" % command)
else:
var err_msg: String = result.get("error", {}).get("message", "unknown")
_log_buffer.log("[send] %s -> error: %s" % [command, err_msg])
return result
## Truncate JSON-stringified args at this many chars when stuffing them into
## a malformed-result error message — large dicts shouldn't bloat the
## response, but a few hundred chars usually pinpoints which param was the
## wrong shape.
const _MALFORMED_ARGS_MAX := 400
func _call_handler(command: String, params: Dictionary) -> Dictionary:
if not _handlers.has(command):
var materialize_error := _materialize_lazy_command(command)
if not materialize_error.is_empty():
return materialize_error
## #712: a handler that crashes between pause_processing = true and its
## matching false leaves the pause depth unbalanced — GDScript swallows
## the error, the dispatcher reports "malformed result", and the
## transport stays paused FOREVER (no watchdog, no disconnect reset).
## Restore balance at this boundary: the depth a handler leaves behind
## must equal the depth it started with.
var pause_depth_before: int = pause_target.pause_depth() if pause_target != null else 0
var result: Dictionary = _handlers[command].call(params)
if pause_target != null and pause_target.pause_depth() > pause_depth_before:
var leaked: int = pause_target.pause_depth() - pause_depth_before
while pause_target.pause_depth() > pause_depth_before:
pause_target.resume()
if mcp_logging and _log_buffer != null:
_log_buffer.log(
"[error] %s leaked %d pause_processing level(s) — restored (handler crash?)"
% [command, leaked]
)
## Handlers must return {"data": ...} on success or {"error": ...} on failure.
## Anything else (null, empty, missing keys) means the handler crashed
## mid-call — GDScript swallows the error and returns an empty dict.
if result == null or not (result.has("data") or result.has("error") or result.has("_deferred")):
var safe_params := params.duplicate()
safe_params.erase("_request_id")
var args_json := JSON.stringify(safe_params)
if args_json.length() > _MALFORMED_ARGS_MAX:
args_json = args_json.substr(0, _MALFORMED_ARGS_MAX) + "..."
var backtrace := _capture_compact_backtrace()
var msg := (
"Handler '%s' returned malformed result — likely a runtime error in the handler "
+ "(e.g. param type mismatch). Args received: %s"
) % [command, args_json]
if not backtrace.is_empty():
msg += "\nBacktrace:\n%s" % backtrace
if mcp_logging and _log_buffer != null:
var compact_backtrace := backtrace.replace("\n", " | ")
_log_buffer.log(
"[error] %s -> malformed result; args=%s; backtrace=%s"
% [command, args_json, compact_backtrace]
)
return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, msg)
return result
## Resolve a lazily-registered command into a live Callable in `_handlers`.
## Loads + constructs the owning handler on first use (cached per handler
## key, so one load() covers every command the handler serves). Returns an
## empty dict on success or a protocol error dict on failure — a missing
## script or method is a plugin packaging bug and must surface loudly, not
## as a silent no-op.
func _materialize_lazy_command(command: String) -> Dictionary:
var command_spec: Dictionary = _lazy_commands.get(command, {})
if command_spec.is_empty():
return ErrorCodes.make(ErrorCodes.UNKNOWN_COMMAND, "Unknown command: %s" % command)
var handler_key: String = command_spec["handler"]
var instance = _lazy_handler_cache.get(handler_key)
if instance == null:
var handler_spec: Dictionary = _lazy_handler_specs.get(handler_key, {})
if handler_spec.is_empty():
return ErrorCodes.make(
ErrorCodes.INTERNAL_ERROR,
"No lazy handler '%s' declared for command '%s'" % [handler_key, command]
)
## Existence-check first so a missing script surfaces as one clean
## protocol error instead of also spraying engine load errors.
if not ResourceLoader.exists(handler_spec["path"]):
return ErrorCodes.make(
ErrorCodes.INTERNAL_ERROR,
"Missing handler script '%s' for command '%s'" % [handler_spec["path"], command]
)
var script := load(handler_spec["path"]) as GDScript
if script == null:
return ErrorCodes.make(
ErrorCodes.INTERNAL_ERROR,
"Failed to load handler script '%s' for command '%s'" % [handler_spec["path"], command]
)
instance = script.callv("new", handler_spec["args"])
if instance == null:
return ErrorCodes.make(
ErrorCodes.INTERNAL_ERROR,
"Failed to construct handler '%s' for command '%s'" % [handler_key, command]
)
_lazy_handler_cache[handler_key] = instance
var method: StringName = command_spec["method"]
if not instance.has_method(method):
return ErrorCodes.make(
ErrorCodes.INTERNAL_ERROR,
"Handler '%s' has no method '%s' for command '%s'" % [handler_key, method, command]
)
_handlers[command] = Callable(instance, method)
return {}
func _register_deferred(request_id: String, command: String, timeout_override_ms: int = 0) -> void:
if request_id.is_empty():
return
## A positive per-request override wins over the per-command table so a
## single deferred call can claim more headroom without globally widening
## the command's budget (see _dispatch: input_sequence needs ~30s, but the
## other game_command ops must keep their tight 15s).
var timeout_ms: int = (
timeout_override_ms if timeout_override_ms > 0
else _deferred_timeout_ms_for_command(command)
)
_pending_deferred[request_id] = {
"command": command,
"started_ms": Time.get_ticks_msec(),
"timeout_ms": timeout_ms,
}
func _deferred_timeout_ms_for_command(command: String) -> int:
if deferred_timeout_overrides_ms.has(command):
return int(deferred_timeout_overrides_ms[command])
return int(DEFERRED_TIMEOUT_MS_BY_COMMAND.get(command, DEFAULT_DEFERRED_TIMEOUT_MS))
func _collect_deferred_timeouts() -> Array[Dictionary]:
var responses: Array[Dictionary] = []
if _pending_deferred.is_empty():
return responses
var now := Time.get_ticks_msec()
for request_id in _pending_deferred.keys():
var entry: Dictionary = _pending_deferred[request_id]
var timeout_ms: int = entry.get("timeout_ms", DEFAULT_DEFERRED_TIMEOUT_MS)
var elapsed_ms := now - int(entry.get("started_ms", now))
if elapsed_ms < timeout_ms:
continue
_pending_deferred.erase(request_id)
var command: String = entry.get("command", "")
var response := ErrorCodes.make(
ErrorCodes.DEFERRED_TIMEOUT,
"Deferred response for '%s' timed out after %dms" % [command, timeout_ms]
)
response["request_id"] = request_id
response["error"]["data"] = {
"command": command,
"elapsed_ms": elapsed_ms,
"timeout_ms": timeout_ms,
}
## Same envelope-level readiness stamp as `_dispatch` — keep the
## self-heal channel symmetric across every reply shape the
## dispatcher emits so the server cache can't drift just because
## the editor happened to time out a deferred command.
response["readiness"] = McpConnection.get_readiness()
_stamp_error_watermark(response)
responses.append(response)
if mcp_logging and _log_buffer != null:
_log_buffer.log("[defer] %s (request %s) -> timeout" % [command, request_id])
return responses
func _stamp_error_watermark(response: Dictionary) -> void:
McpSurfacedErrorTracker.stamp_watermark(response, _surfaced_error_tracker)
static func _capture_compact_backtrace(max_frames: int = 8) -> String:
var traces: Array = Engine.capture_script_backtraces(false)
for bt in traces:
if bt != null and not bt.is_empty():
return _trim_backtrace_string(bt.format(0, 2), max_frames)
return _format_stack_frames(get_stack(), max_frames)
static func _trim_backtrace_string(text: String, max_frames: int) -> String:
var lines := text.strip_edges().split("\n")
var kept: Array[String] = []
for i in range(min(lines.size(), max_frames)):
kept.append(lines[i].strip_edges())
return "\n".join(kept)
static func _format_stack_frames(frames: Array, max_frames: int) -> String:
var lines: Array[String] = []
for i in range(min(frames.size(), max_frames)):
var frame: Dictionary = frames[i]
lines.append(
"%s:%s in %s"
% [
frame.get("source", "?"),
frame.get("line", 0),
frame.get("function", "?"),
]
)
return "\n".join(lines)
+1
View File
@@ -0,0 +1 @@
uid://ctldk7ivsoo3i
+100
View File
@@ -0,0 +1,100 @@
@tool
extends VBoxContainer
## Dock subpanel — renders the MCP request/response log buffer. Owns its own
## UI subtree, the line-count cursor, and the display-visibility toggle. Emits
## `logging_enabled_changed` so the dock can route the flag onto the
## connection dispatcher without the panel knowing the routing exists.
##
## Extracted from mcp_dock.gd as part of audit-v2 #360 — see the comment at
## the top of mcp_dock.gd for the broader extraction story.
signal logging_enabled_changed(enabled: bool)
const Dock := preload("res://addons/godot_ai/mcp_dock.gd")
## Preload (not the McpSettings class_name) for consistency with the parse
## hazard note on `_log_buffer` below.
const Settings := preload("res://addons/godot_ai/utils/settings.gd")
## Untyped: a `: McpLogBuffer` annotation hits the class_name registry at
## script-load and trips the self-update parse hazard (#398). The type fence
## stays on the `setup(log_buffer: McpLogBuffer)` parameter.
var _log_buffer
var _log_display: RichTextLabel
var _log_toggle: CheckButton
## Last `McpLogBuffer.total_logged()` value painted into the display. Tracking
## the buffer's monotonic sequence (rather than its bounded `total_count()`)
## keeps the viewer painting once the ring fills — a size-based cursor would
## freeze at MAX_LINES on every subsequent append. See PR #392 for the bug.
var _last_log_seq := 0
## Build the UI synchronously here so callers (and detached-tree tests that
## instantiate the dock with `McpDockScript.new()` and never enter the tree)
## can interact with the panel's controls right after `setup()`. Mirrors the
## pre-extraction inline-build behavior that test_dock.gd relies on.
##
## Idempotent: `_log_display == null` covers an unlikely double-`setup()` call
## without rebuilding (which would orphan the prior controls).
func setup(log_buffer: McpLogBuffer) -> void:
_log_buffer = log_buffer
if _log_display == null:
_build_ui()
func _build_ui() -> void:
add_child(HSeparator.new())
var log_header_row := HBoxContainer.new()
var log_header := Dock._make_header("MCP Log")
log_header.size_flags_horizontal = Control.SIZE_EXPAND_FILL
log_header_row.add_child(log_header)
_log_toggle = CheckButton.new()
_log_toggle.text = "Log"
## Restore the persisted choice — a hardcoded `true` here meant the
## toggle reset to noisy on every editor restart (#626).
_log_toggle.button_pressed = Settings.mcp_logging_enabled()
_log_toggle.toggled.connect(_on_log_toggled)
log_header_row.add_child(_log_toggle)
add_child(log_header_row)
_log_display = RichTextLabel.new()
_log_display.custom_minimum_size = Vector2(0, 80)
_log_display.scroll_following = true
_log_display.bbcode_enabled = false
_log_display.selection_enabled = true
_log_display.visible = _log_toggle.button_pressed
add_child(_log_display)
## Called from McpDock._process when the panel is visible. Appends any new
## log lines since the last tick.
func tick() -> void:
if _log_buffer == null or _log_display == null:
return
var seq: int = _log_buffer.total_logged()
if seq == _last_log_seq:
return
if seq < _last_log_seq:
## Buffer cleared via `McpLogBuffer.clear()` (the `clear_logs` MCP
## tool / `logs_clear` handler). The buffer resets `_total_logged`
## to 0, flipping the sequence backward. Without this branch the
## display would keep showing pre-clear lines forever — the viewer
## drifts permanently out of sync with the buffer. Reset display +
## cursor so the next append paints over a clean slate.
_log_display.clear()
_last_log_seq = 0
if seq == 0:
return
var new_lines: Array[String] = _log_buffer.get_recent(seq - _last_log_seq)
for line in new_lines:
_log_display.add_text(line + "\n")
_last_log_seq = seq
func _on_log_toggled(enabled: bool) -> void:
Settings.set_mcp_logging_enabled(enabled)
_log_display.visible = enabled
logging_enabled_changed.emit(enabled)
@@ -0,0 +1 @@
uid://cr5nbnd6vj3b8
@@ -0,0 +1,78 @@
@tool
extends VBoxContainer
## Dock subpanel — port-change escape hatch surfaced inside the spawn-failure
## crash panel when the HTTP port is contested (PORT_EXCLUDED, FOREIGN_PORT).
## Emits `port_apply_requested(new_port)` after range-validation; the dock
## handles writing the EditorSetting and reloading the plugin.
##
## Extracted from mcp_dock.gd as part of audit-v2 #360 — see the comment at
## the top of mcp_dock.gd for the broader extraction story.
const ClientConfigurator := preload("res://addons/godot_ai/client_configurator.gd")
signal port_apply_requested(new_port: int)
var _spinbox: SpinBox
## Build the UI synchronously here so callers (and detached-tree tests that
## instantiate the dock with `McpDockScript.new()` and never enter the tree)
## can interact with the panel's controls right after `setup()`. Mirrors the
## pre-extraction inline-build behavior that test_dock.gd relies on.
##
## Idempotent: `_spinbox == null` covers an unlikely double-`setup()` call
## without rebuilding (which would orphan the prior controls).
func setup() -> void:
if _spinbox == null:
_build_ui()
func _build_ui() -> void:
add_theme_constant_override("separation", 4)
visible = false
var picker_row := HBoxContainer.new()
picker_row.add_theme_constant_override("separation", 6)
_spinbox = SpinBox.new()
_spinbox.min_value = ClientConfigurator.MIN_PORT
_spinbox.max_value = ClientConfigurator.MAX_PORT
_spinbox.step = 1
_spinbox.value = ClientConfigurator.http_port()
_spinbox.size_flags_horizontal = Control.SIZE_EXPAND_FILL
picker_row.add_child(_spinbox)
var apply_btn := Button.new()
apply_btn.text = "Apply + Reload"
apply_btn.tooltip_text = (
"Saves godot_ai/http_port to Editor Settings and reloads the plugin so"
+ " the server spawns on the new port."
)
apply_btn.pressed.connect(_on_apply_pressed)
picker_row.add_child(apply_btn)
add_child(picker_row)
## Re-seed the spinbox with a fresh suggestion every time the panel surfaces,
## so a stale value from a previous spawn-failure round can't carry over. Note
## that this OVERWRITES any unsaved user input — fine in practice because the
## dock's `_update_crash_panel` only calls this on `server_status` transitions
## (`if server_status == _last_server_status: return` short-circuit), so a
## user typing into the spinbox between transitions keeps their value. If the
## state flips while the picker is visible (e.g. `PORT_EXCLUDED` → `FOREIGN_PORT`),
## the in-flight edit is clobbered — accept that, the suggestion is more current.
func seed_suggested_port() -> void:
if _spinbox == null:
return
_spinbox.value = ClientConfigurator.suggest_free_port(
ClientConfigurator.http_port() + 1
)
func _on_apply_pressed() -> void:
var new_port: int = int(_spinbox.value)
if new_port < ClientConfigurator.MIN_PORT or new_port > ClientConfigurator.MAX_PORT:
return
port_apply_requested.emit(new_port)
@@ -0,0 +1 @@
uid://hlggbo1q65eq
@@ -0,0 +1,69 @@
@tool
extends EditorExportPlugin
## Strips the MCP game-helper autoload from exported builds (#740).
##
## plugin.gd writes `autoload/_mcp_game_helper` into project.godot so the
## editor-spawned game process loads the helper. Exports bake project
## settings into the pack's project.binary, so without this plugin every
## export ships the autoload — and users who exclude addons/godot_ai/**
## in their export preset get three "Failed to instantiate an autoload"
## errors at game start. Even when the files ARE shipped, the helper is
## editor-tooling: no exported build should carry it.
##
## Strip mechanics: clear the in-memory ProjectSettings entry in
## _export_begin, restore it in _export_end. The export pipeline reads
## the live ProjectSettings when it bakes project.binary, which happens
## after _export_begin — verified end-to-end by
## script/ci-export-strip-smoke, which exports a real pack and asserts
## the autoload is absent inside it. We never call ProjectSettings.save()
## while stripped, so project.godot on disk keeps the autoload
## throughout; only the export snapshot loses it.
##
## Failure containment: if an export aborts so hard that _export_end
## never fires, the damage is bounded to the editor's in-memory settings
## — the running game reads project.godot from disk, and plugin.gd's
## _ensure_game_helper_autoload() re-asserts the entry on the next
## plugin enable / editor launch.
## Must equal "autoload/" + plugin.gd's GAME_HELPER_AUTOLOAD_NAME.
## Duplicated (not preloaded from plugin.gd) to avoid a cyclic preload —
## plugin.gd preloads this script. The pairing is locked by
## test_export_strip.gd's constants-contract test.
const AUTOLOAD_KEY := "autoload/_mcp_game_helper"
var _saved_value: Variant = null
var _stripped := false
func _get_name() -> String:
return "GodotAIStripAutoload"
func _export_begin(_features: PackedStringArray, _is_debug: bool, _path: String, _flags: int) -> void:
## `_stripped` guard: if a previous export died before _export_end,
## don't overwrite the genuinely-saved value with the already-cleared
## state — restore semantics stay anchored to the original value.
if _stripped:
return
if not ProjectSettings.has_setting(AUTOLOAD_KEY):
return
_saved_value = ProjectSettings.get_setting(AUTOLOAD_KEY)
## Setting a project setting to null erases it.
ProjectSettings.set_setting(AUTOLOAD_KEY, null)
_stripped = true
print("MCP | export: stripping %s from the exported pack (restored in the editor after export)" % AUTOLOAD_KEY)
func _export_end() -> void:
if not _stripped:
return
ProjectSettings.set_setting(AUTOLOAD_KEY, _saved_value)
## Mirror _ensure_game_helper_autoload()'s registration shape so the
## restored entry is indistinguishable from the original: initial
## value "" keeps project.godot diff-clean, basic keeps it visible in
## the non-advanced settings view.
ProjectSettings.set_initial_value(AUTOLOAD_KEY, "")
ProjectSettings.set_as_basic(AUTOLOAD_KEY, true)
_saved_value = null
_stripped = false
@@ -0,0 +1 @@
uid://np0cp6fwpim7
@@ -0,0 +1,71 @@
@tool
class_name McpNodeValidator
extends RefCounted
## Shared resolve-or-error helper that subsumes the 38+ sites where
## handlers each rolled their own "is the editor ready, does the path
## resolve, otherwise return EDITOR_NOT_READY / NODE_NOT_FOUND" guard.
##
## audit-v2 #20 (issue #364). Uses the audit-v2 #21 (issue #365) error
## vocabulary.
## Local const names alias the preloaded scripts. The naming choice is
## stylistic, not an upgrade-safety boundary: bare `McpErrorCodes.MEMBER`
## and `ErrorCodes.MEMBER` both depend on the Script object Godot has for
## `error_codes.gd`. The transient #398 parse errors were caused by the
## old runner scanning a mixed old/new plugin snapshot and seeing stale
## Script-object content; the runner now writes one v(N+1) snapshot before
## its scan.
const ScenePath := preload("res://addons/godot_ai/utils/scene_path.gd")
const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd")
## Resolve a scene-relative path to the live Node, or return a structured
## error dict.
##
## Success shape: `{"node": Node, "scene_root": Node, "path": String}`.
## Error shape: matches `ErrorCodes.make(...)` so callers can
## `return resolved` to propagate.
##
## Errors (in order checked):
## - `MISSING_REQUIRED_PARAM`: `node_path` is empty
## - `EDITOR_NOT_READY`: no scene open
## - `EDITED_SCENE_MISMATCH`: caller pinned `scene_file` and the open
## scene's path doesn't match
## - `NODE_NOT_FOUND`: `node_path` doesn't resolve under the scene root
##
## `param_name` is the agent-facing name reported in the
## `MISSING_REQUIRED_PARAM` message — handlers pass "node_path",
## "player_path", "target_path", etc. so the error reads like the
## hand-written messages it replaces.
static func resolve_or_error(
node_path: String,
param_name: String = "path",
scene_file: String = "",
) -> Dictionary:
if node_path.is_empty():
return ErrorCodes.make(
ErrorCodes.MISSING_REQUIRED_PARAM,
"Missing required param: %s" % param_name,
)
var scene_check := ScenePath.require_edited_scene(scene_file)
if scene_check.has("error"):
return scene_check
var scene_root: Node = scene_check.node
var node := ScenePath.resolve(node_path, scene_root)
if node == null:
return ErrorCodes.make(
ErrorCodes.NODE_NOT_FOUND,
ScenePath.format_node_error(node_path, scene_root),
)
return {"node": node, "scene_root": scene_root, "path": node_path}
## When the caller needs the scene root but no specific node yet — e.g.
## handlers that walk children or filter by group. Returns either
## `{"scene_root": Node}` or an `ErrorCodes.make(...)` error dict.
static func require_scene_or_error(scene_file: String = "") -> Dictionary:
var scene_check := ScenePath.require_edited_scene(scene_file)
if scene_check.has("error"):
return scene_check
return {"scene_root": scene_check.node}
@@ -0,0 +1 @@
uid://dn75jifad0ghx

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