first commit

This commit is contained in:
Leonid Pershin
2026-08-10 20:39:06 +03:00
commit f16f60445c
281 changed files with 43576 additions and 0 deletions
+206
View File
@@ -0,0 +1,206 @@
@tool
class_name McpAtomicWrite
extends RefCounted
## Write text to a file via temp + rename so a crash mid-write never leaves
## the user's MCP config truncated. Creates the parent dir if needed and
## keeps a one-shot `.backup` of the prior file.
##
## On filesystems where rename-over-existing fails (Windows under AV / lock
## pressure, some SMB shares), falls back to overwrite-copy plus a
## backup-restore on failure. The original file is never removed before the
## new bytes are verified on disk — if both the rename and the copy fail,
## the user's prior config is restored from the `.backup` snapshot. See
## issue #297 finding #10 for the data-loss scenario this guards against.
static func write(path: String, content: String) -> bool:
# If the target is a symlink (stow/chezmoi-managed dotfiles), rename-over
# would replace the LINK with a regular file, silently detaching the
# config from the user's dotfile repo (#534). Resolve the link chain and
# write to the real target so the symlink survives.
path = _resolve_symlink_target(path)
var dir_path := path.get_base_dir()
if not DirAccess.dir_exists_absolute(dir_path):
if DirAccess.make_dir_recursive_absolute(dir_path) != OK:
return false
# Decide the permission mode the final file (and its backup) must carry
# BEFORE we replace anything. A rewrite must preserve the prior file's
# mode: the Claude CLI creates ~/.claude.json as 0600 (it holds OAuth
# creds + history), and a naive FileAccess write + DirAccess copy would
# silently relax that to the umask default (0644) and leak it on shared
# machines. A brand-new config defaults to owner-only 0600 since these
# files routinely carry tokens. On platforms without POSIX permissions
# (Windows) the get/set calls no-op and this logic is inert. See #297
# finding TC-1.
var had_original := FileAccess.file_exists(path)
var target_mode := _resolve_target_mode(path, had_original)
# Suffix the temp name with this process's PID so two editors writing the
# same config concurrently (both clicking Configure) can't interleave
# bytes on a shared fixed ".tmp" path (#534). Each process stages its own
# temp file; the final rename remains the atomic commit point.
var tmp_path := "%s.tmp.%d" % [path, OS.get_process_id()]
var file := FileAccess.open(tmp_path, FileAccess.WRITE)
if file == null:
return false
# Lock the temp inode down BEFORE writing any bytes. FileAccess.open creates
# it at the umask default (often 0644); chmod'ing the still-empty file first
# means the config contents are never on disk under a world-readable mode in
# the create->chmod gap. rename preserves the inode mode, so the swapped-in
# file lands correct and is never briefly world-readable under the target name.
_apply_mode(tmp_path, target_mode)
file.store_string(content)
# Push Godot's internal buffer out to the OS before the rename. Godot
# exposes no fsync, so the bytes aren't guaranteed durable on the physical
# disk until the OS flushes its own cache — a power loss in that window can
# still lose the data. But flush() ensures the rename can't be ordered ahead
# of the write at the application layer, which is the failure this guards.
file.flush()
file.close()
# Re-assert the mode on the closed inode. The pre-write chmod above closes
# the world-readable window; this second apply is the authoritative one
# (a chmod issued while the FileAccess handle is still open doesn't reliably
# stick inside the editor) and guarantees the final mode before the rename,
# which preserves it.
_apply_mode(tmp_path, target_mode)
# Verify the staged temp landed intact before committing it anywhere. The
# copy-fallback path below already guards this (`_written_size_matches` at
# the rename-fallback check); the rename path was the one gap — under
# disk-full/quota the temp can be silently truncated, and an unverified
# rename would swap a truncated file over the live target while the
# caller is told the write succeeded (#687).
if not _written_size_matches(tmp_path, content):
DirAccess.remove_absolute(tmp_path)
return false
# Best-effort: snapshot the prior file before we touch the target so we
# can restore on a failed swap. The backup is also kept on success as a
# one-shot rollback aid for the user — give it the same (preserved) mode
# so a 0600 config's backup isn't itself a world-readable copy.
#
# copy_absolute creates the backup at the umask default and we can only
# chmod it afterward, so there's a sub-millisecond window where the backup
# carries default perms. Accepted: it duplicates bytes already sitting at
# `path` (which the caller created 0600) inside the user's own config dir,
# and Godot exposes no API to create the copy pre-chmod'd. Not worth
# reimplementing copy by hand to shave that window.
var backup_path := path + ".backup"
var backup_made := false
if had_original:
DirAccess.remove_absolute(backup_path)
if DirAccess.copy_absolute(path, backup_path) == OK:
backup_made = true
_apply_mode(backup_path, target_mode)
if DirAccess.rename_absolute(tmp_path, path) == OK:
return true
# Rename-over-existing rejected (Windows + AV / lock timing, some SMB
# shares). Use overwrite-copy as the recovery path: copy_absolute never
# removes the original before writing the new bytes, so a failure here
# leaves the user's prior config in place rather than nuking it.
if DirAccess.copy_absolute(tmp_path, path) == OK and _written_size_matches(path, content):
# copy_absolute creates the destination with the default mode, so
# re-apply the preserved/owner-only mode after the copy lands.
_apply_mode(path, target_mode)
DirAccess.remove_absolute(tmp_path)
return true
# Copy didn't land cleanly. Restore the destination to its pre-call state.
if backup_made:
# Restore the snapshot we took before the swap. `copy_absolute`
# overwrites the destination, so we don't pre-remove `path` — the
# pre-remove created a window where `path` was gone if the
# subsequent copy itself failed. If the restore copy fails now the
# user's prior bytes are still in `.backup` for manual recovery
# and the false return value tells the caller the swap didn't
# complete.
DirAccess.copy_absolute(backup_path, path)
_apply_mode(path, target_mode)
elif not had_original and FileAccess.file_exists(path):
# No prior file existed but copy_absolute landed partial bytes at
# `path`. Remove them so the failure leaves nothing on disk rather
# than a truncated/invalid new file. The `file_exists` guard keeps
# us off non-file destinations (a path that points at a directory
# yields `had_original=false` too, but we must not try to delete
# the directory). Issue #297 PR review.
DirAccess.remove_absolute(path)
# (If `had_original` is true but the snapshot couldn't be taken, the
# original on disk is whatever copy_absolute managed to write before
# failing. This is a best-effort path — the false return value tells the
# caller the swap didn't complete; recovery beyond that requires a
# backup we couldn't take.)
DirAccess.remove_absolute(tmp_path)
return false
## Follow a symlink chain at `path` and return the final real target, so the
## temp+rename lands on the linked-to file instead of replacing the link.
##
## Best-effort by design: DirAccess.is_link()/read_link() are only implemented
## on platforms with POSIX symlinks (Linux/macOS; on Windows and other
## platforms is_link() returns false), and opening the parent dir can fail for
## exotic paths. In every "can't tell" case we return `path` unchanged, which
## is exactly the pre-#534 behavior — never worse, symlink-preserving where
## the engine lets us detect one.
static func _resolve_symlink_target(path: String) -> String:
var resolved := path
# Bounded hops so a symlink cycle can't loop us forever.
for _hop in 8:
var base_dir := resolved.get_base_dir()
var da := DirAccess.open(base_dir)
if da == null or not da.is_link(resolved):
return resolved
var target := da.read_link(resolved)
if target.is_empty():
return resolved
if target.is_relative_path():
target = base_dir.path_join(target)
resolved = target.simplify_path()
return resolved
static func _resolve_target_mode(path: String, had_original: bool) -> int:
# Preserve the prior file's POSIX mode on a rewrite; default a brand-new
# config (or any case we can't read a mode for) to owner read+write (0600).
#
# get_unix_permissions returns 0 both on Windows (no POSIX perms) and for a
# genuine 0000 file. Treating 0 as "use the 0600 floor" is deliberate, not a
# missed case: these are config files the plugin must read and write, 0000 is
# unusable, and re-applying 0000 would lock the owner out next run. 0600 is
# still owner-only so this never widens access. (A genuinely-0000 file can't
# reach a rewrite through the config strategies anyway — their read-first
# guard fails to open it and refuses the write before we get here.)
if had_original:
var existing := FileAccess.get_unix_permissions(path)
if existing > 0:
return existing
return FileAccess.UNIX_READ_OWNER | FileAccess.UNIX_WRITE_OWNER
static func _apply_mode(path: String, mode: int) -> void:
# Best-effort. set_unix_permissions returns ERR_UNAVAILABLE on platforms
# without POSIX permissions (Windows); that's expected and ignored so the
# write still works there. mode <= 0 should never happen (resolve always
# returns >0) but is guarded so a future caller can't chmod a file to nothing.
if mode <= 0:
return
var err := FileAccess.set_unix_permissions(path, mode)
# Surface a real chmod failure (not the Windows no-op) so permission
# hardening on a sensitive config doesn't fail completely silently.
if err != OK and err != ERR_UNAVAILABLE:
push_warning("MCP | could not set permissions on %s (error %d)" % [path, err])
static func _written_size_matches(path: String, content: String) -> bool:
# `store_string` writes UTF-8 bytes with no BOM and no newline translation,
# so the byte length on disk must match `to_utf8_buffer().size()` exactly.
var f := FileAccess.open(path, FileAccess.READ)
if f == null:
return false
var on_disk := f.get_length()
f.close()
return on_disk == content.to_utf8_buffer().size()
@@ -0,0 +1 @@
uid://6fkb5uau0r4h
+427
View File
@@ -0,0 +1,427 @@
@tool
class_name McpClient
extends RefCounted
## Descriptor for one MCP client (Cursor, Claude Desktop, Codex, ...).
##
## Subclasses set fields in `_init()` and MUST NOT carry Callables — strategies
## (json/toml/cli) interpret the data. Enforced by
## `test_clients.gd::test_descriptors_are_data_only`.
##
## Why no Callables: per-client `.gd` files get hot-reloaded on disk-mtime
## change. A worker thread mid-call into a descriptor lambda races the
## bytecode swap and SEGVs (issue #229). Bonus: also obsoletes the stale-
## Callable workaround from #192.
## CONFIGURED_MISMATCH = an entry with our `SERVER_NAME` exists in the user's
## client config, but its URL or launch command doesn't match the current
## ports/version/exclusions — typical after a setting change or update.
## Distinguishing this from `NOT_CONFIGURED` lets the dock surface a "your
## saved client configuration is stale" banner instead of conflating it with
## "you never configured this client".
enum Status { NOT_CONFIGURED, CONFIGURED, CONFIGURED_MISMATCH, ERROR }
## Lowercase string label for a `Status` value. Single source of truth so the
## MCP `client_status` tool, the dock, and the verify-after-write diagnostic
## in `McpClientConfigurator` all emit the same names — agents pattern-match
## against this set, so a fifth value being silently introduced would break
## them.
static func status_label(status: McpClient.Status) -> String:
match status:
Status.CONFIGURED:
return "configured"
Status.NOT_CONFIGURED:
return "not_configured"
Status.CONFIGURED_MISMATCH:
return "configured_mismatch"
return "error"
## One-line configure success message, shared by every strategy so the dock
## and the `client_manage` tool describe the transport that was actually
## written. Command-shape clients register the stdio `godot-ai attach`
## bridge — the URL-era "(HTTP: <url>)" suffix would name a transport the
## write never touched (found live in the #838 Windows smoke).
static func configured_message(client: McpClient, server_url: String) -> String:
if client.command_shape != CommandShape.NONE:
return "%s configured (stdio attach)" % client.display_name
return "%s configured (HTTP: %s)" % [client.display_name, server_url]
var id: String = "" ## stable key, e.g. "cursor"
var display_name: String = "" ## "Cursor"
var config_type: String = "" ## "json" | "toml" | "yaml" | "cli"
# JSON / TOML clients ------------------------------------------------------
## {"darwin": "~/...", "windows": "$APPDATA/...", "linux": "$XDG_CONFIG_HOME/..."}
## Keys may also use "unix" as a shorthand for darwin+linux.
var path_template: Dictionary = {}
## Optional ordered path candidates by platform. Each value is an Array of
## templates; one `*` may appear in a directory segment so packaged-app roots
## can be discovered without hardcoding publisher hashes.
##
## Resolution contract:
## 1. Existing files win in descriptor order, except that a unique wildcard
## match is authoritative even before its config leaf exists. A matching
## package root therefore creates inside that package rather than writing
## a fallback path that may become invisible after copy-on-write. When
## that private leaf is new, Configure seeds it from the first later
## existing candidate so read-through content is not shadowed.
## 2. If no file or wildcard package match exists, the first non-wildcard
## template is the deterministic create target.
## 3. Multiple matches within any wildcard group are ambiguous and fail
## closed instead of choosing an arbitrary package.
##
## Exact-file and config-home environment overrides still have higher
## priority. When this map has no entry for the current platform,
## `path_template` remains the fallback.
var config_path_candidates: Dictionary = {}
## De-duplicate persistent path-ambiguity warnings across recurring status
## refreshes. The actionable message still returns on every resolution; only
## the editor-console echo is single-shot until the ambiguity clears/changes.
var _last_config_path_warning := ""
var _config_path_warning_mutex := Mutex.new()
## Path inside the config object where the per-server map lives.
## Cursor / Claude Desktop / most others: ["mcpServers"]
## VS Code: ["servers"]
## OpenCode: ["mcp"]
var server_key_path: PackedStringArray = PackedStringArray()
## Field inside the entry dict that holds our server URL.
## "url" by default; some clients use "serverUrl" or "httpUrl".
var entry_url_field: String = "url"
## Required entry fields — written on every Configure AND verified by the
## default verifier. Use this for transport pins (e.g. `type:
## "streamable-http"`) where a missing/wrong value breaks negotiation: a
## legacy entry without the pin fails verification and surfaces as drift.
##
## DO NOT put user-mutable state here (auto-approval lists, `disabled`
## flags, opt-in toggles). Verifying those treats every user customisation
## as drift, and Configure-All-Mismatched then silently overwrites them
## back to defaults — see the `entry_initial_fields` doc below.
var entry_extra_fields: Dictionary = {}
## Default fields written ONLY when the entry doesn't yet exist. Reconfigure
## preserves whatever the user (or the client itself) has set; the verifier
## ignores these keys entirely. Use for opt-in flags and user-state arrays —
## e.g. Roo / Cline / Kilo `alwaysAllow` / `autoApprove` lists, `disabled:
## false`, `isActive: true`. The pre-#229 behaviour was equivalent: per-
## client `entry_builder` lambdas seeded these as defaults but the
## per-client `verify_entry` lambdas only checked transport pins, so a
## user-customised array was `CONFIGURED`, not drift. Splitting the field
## restores that contract under the data-only descriptor model.
var entry_initial_fields: Dictionary = {}
## Client-owned stdio launch shape. Each strategy renders the shape in its
## config language:
##
## - FLAT — `command` string + `args` array as sibling keys. JSON and YAML
## strategies. A client whose docs require a type discriminator next to the
## flat keys (VS Code's `type: "stdio"`, Claude Code's fallback file) stays
## FLAT and declares it via `command_transport_key` / `command_transport_value`,
## so TYPED_FLAT remains reserved vocabulary.
## - COMMAND_ARRAY — the launch argv carried as one array. In the JSON
## strategy the entry's `command` field IS that array (OpenCode's
## `"command": ["uvx", …]`). In the TOML strategy the launcher renders as a
## `command = "…"` line plus an `args = […]` array (Codex, Grok) — the name
## refers to the argv-as-TOML-array body it emits.
## - NESTED_COMMAND — command/args nested inside a sub-object. Reserved; no
## current client needs it and strategies reject it with an actionable error.
##
## CLI-registered clients (`config_type == "cli"`) express the launch through
## `cli_register_template` tokens instead; their `command_shape` governs the
## JSON-fallback file rendering (Claude Code, #463).
##
## Values are data-only shared vocabulary; keeping them data-only avoids
## reintroducing the descriptor Callable race from #229.
enum CommandShape { NONE, FLAT, TYPED_FLAT, COMMAND_ARRAY, NESTED_COMMAND }
var command_shape: CommandShape = CommandShape.NONE
## Whether manual instructions may offer the client's native URL transport as
## an alternative to its command shape. This is capability metadata, not a
## consequence of `command_shape`: Codex supports a URL block, while Claude
## Desktop's local `claude_desktop_config.json` entries are stdio-only.
var command_supports_url_fallback: bool = false
## Optional discriminator required by a client's command transport shape
## (for example `type = "stdio"`). Empty means command+args are sufficient.
var command_transport_key: String = ""
var command_transport_value: Variant = null
## Whether this client's Windows stdio entry must launch through the
## GUI-subsystem pythonw bootstrap (#827). The bootstrap exists for clients
## that run console-subsystem MCP commands in a visible terminal (Codex);
## Electron-family spawners hide child consoles themselves, and at least one
## (Antigravity) hangs tool calls when handed a GUI-subsystem executable
## (#863). Set false to write the plain console launcher on Windows.
var needs_consoleless_launcher: bool = true
## Keys from the legacy transport that Configure must delete. Codex removes
## `url`, because Codex rejects a server entry containing both URL and stdio
## launch fields.
var command_legacy_keys: PackedStringArray = PackedStringArray()
## Keys inside a preserved JSON `env` object that belonged to a legacy launch
## shape and must be removed during migration. Other environment values remain
## user-owned and survive Configure. Currently consumed by the JSON strategy.
var command_env_legacy_keys: PackedStringArray = PackedStringArray()
## Defaults seeded only for a new entry. Reconfigure preserves user values.
## Codex uses this for enabled/startup/tool timeout defaults.
var command_initial_fields: Dictionary = {}
## Declarative documentation of fields owned by the user and timeout fields
## supported by this client. Strategies preserve these values and tests pin
## the descriptor contract; no control flow lives on the descriptor.
var command_user_fields: PackedStringArray = PackedStringArray()
var command_timeout_fields: PackedStringArray = PackedStringArray()
## Paths whose existence implies the user has this client installed.
## Used purely for the dock's "installed" badge. `is_installed()` additionally
## checks `resolved_config_path()`, so a config relocated via an environment
## override is detected without listing it here.
var detect_paths: PackedStringArray = PackedStringArray()
# Config-path env overrides --------------------------------------------------
## Some clients name the exact config file in an environment variable
## (OpenCode: `$OPENCODE_CONFIG`). When the variable is set and non-empty, it
## wins over directory-valued `config_home_env` and `path_template`. Relative
## values fail closed because the editor and client may have different working
## directories; auto-configuration cannot safely assume they resolve alike.
var config_file_env: String = ""
## Some clients honor an env var that relocates their entire config home
## (Codex: `$CODEX_HOME/config.toml`; Claude Code: `$CLAUDE_CONFIG_DIR/.claude.json`).
## When `config_home_env` names an env var that is set and non-empty,
## `resolved_config_path()` returns `<env value>/<config_home_env_subpath>`
## instead of resolving `path_template`. Both fields must be non-empty for the
## override to apply. Only declare a mapping when the client's docs guarantee
## the env var relocates the exact file we write — a wrong mapping writes the
## MCP entry somewhere the client never reads and Configure false-succeeds.
var config_home_env: String = ""
## Path of the config file relative to the env var's directory, e.g.
## "config.toml". Joined verbatim — no per-OS variants needed because the env
## value itself is already an absolute (or ~-prefixed) directory.
var config_home_env_subpath: String = ""
# CLI clients --------------------------------------------------------------
var cli_names: PackedStringArray = PackedStringArray()
## Argument templates with `{name}` and `{url}` tokens; the strategy
## substitutes them at call time. Tokens are matched verbatim — no escaping
## semantics, no shell expansion. Command-shape templates additionally use the
## whole-element tokens `{command}` / `{args...}` (see `McpCliStrategy.format_args`).
## Populated by CLI descriptors (currently `claude_code`; `kimi_code` moved to
## mcp.json in #813).
var cli_register_template: PackedStringArray = PackedStringArray()
var cli_unregister_template: PackedStringArray = PackedStringArray()
## Args run to read current state; stdout is scanned for the server name and
## URL. Presence of `name` AND `url` → CONFIGURED, name only → MISMATCH,
## neither → NOT_CONFIGURED.
var cli_status_args: PackedStringArray = PackedStringArray()
# Codex / TOML clients -----------------------------------------------------
## Dotted TOML path under which our entry lives, e.g. ["mcp_servers", "godot-ai"].
## Strategies build the [section."name"] header from this.
var toml_section_path: PackedStringArray = PackedStringArray()
var toml_legacy_section_aliases: PackedStringArray = PackedStringArray()
## Lines (without the [header]) emitted under the section, with `{url}`
## tokens. Substituted at call time.
var toml_body_template: PackedStringArray = PackedStringArray()
## Resolved absolute config path for this client on the current OS. Exact-file
## overrides win first, followed by directory-valued `config_home_env`, then
## ordered candidates / `path_template`. Ignoring either override can write a
## file the client never reads and false-succeed.
func resolved_config_path() -> String:
return str(resolved_config_path_details().get("path", ""))
## Detailed sibling used by status/configure/remove so safe resolution
## failures reach the dock instead of collapsing into NOT_CONFIGURED. `error`
## is empty for ordinary unsupported/missing path mappings to preserve the
## long-standing status behavior for clients not installed on this platform.
func resolved_config_path_details() -> Dictionary:
## Reflected reads: after an in-session self-update, an instance created
## before the update can answer Nil for vars the update added, and the
## typed calls below would each hard-error (Nil -> Dictionary, #850's
## per-row error wall). Fail with the
## one repair message instead; the registry's coherence probe drives the
## same text on the status path.
var candidates: Variant = get("config_path_candidates")
var template: Variant = get("path_template")
var file_env: Variant = get("config_file_env")
if not (candidates is Dictionary) or not (template is Dictionary) or not (file_env is String):
return {"path": "", "error": McpClientRegistry.RESTART_TO_FINISH_UPDATE}
var file_override := config_file_override_details()
if not str(file_override.get("path", "")).is_empty() or not str(file_override.get("error", "")).is_empty():
_clear_config_path_warning()
return file_override
var override := config_home_override()
if not override.is_empty():
_clear_config_path_warning()
return {"path": override, "error": ""}
var candidate_key := McpPathTemplate.platform_key(candidates)
if not candidate_key.is_empty():
return _resolve_ordered_config_path_candidates(candidates[candidate_key])
_clear_config_path_warning()
return {"path": McpPathTemplate.resolve(template), "error": ""}
## The exact-file env override plus any fail-closed diagnostic. Empty path and
## error means no override applies (no mapping, unset, or blank env var).
func config_file_override_details() -> Dictionary:
if config_file_env.is_empty():
return {"path": "", "error": ""}
## env_lookup, not OS.get_environment: this can run on dock workers (#691).
var raw_path := McpPathTemplate.env_lookup(config_file_env).strip_edges()
if raw_path.is_empty():
return {"path": "", "error": ""}
var expanded := McpPathTemplate.expand(raw_path)
if not expanded.is_absolute_path():
return {
"path": "",
"error": "%s's $%s override must be an absolute config-file path; got %s" % [
display_name, config_file_env, raw_path,
],
}
if DirAccess.dir_exists_absolute(expanded):
return {
"path": "",
"error": "%s's $%s override must point to a config file, not a directory: %s" % [
display_name, config_file_env, expanded,
],
}
return {"path": expanded, "error": ""}
func _resolve_ordered_config_path_candidates(templates: Variant) -> Dictionary:
if not (templates is Array or templates is PackedStringArray):
_clear_config_path_warning()
return {"path": "", "error": ""}
var ordered_templates: Array = []
for template_variant in templates:
ordered_templates.append(str(template_variant))
var fallback_create_path := ""
for index in range(ordered_templates.size()):
var template := str(ordered_templates[index])
var group := McpPathTemplate.expand_path_candidates(template)
if group.size() > 1:
var message := (
"%s has multiple matching config package paths for %s: %s. "
+ "Remove the stale package installation or edit the intended config manually."
) % [display_name, template, ", ".join(group)]
_warn_config_path_once(message)
return {"path": "", "error": message}
if group.is_empty():
continue
var path := String(group[0])
if FileAccess.file_exists(path):
_clear_config_path_warning()
return {"path": path, "error": ""}
# A wildcard only resolves when its package directory exists. Treat that
# installation evidence as authoritative and create its private config
# directly instead of relying on copy-on-write read-through. Preserve
# anything currently visible through read-through by naming the first
# later existing candidate as a one-time seed source.
if template.contains("*"):
var seed_path := _first_existing_later_candidate(ordered_templates, index + 1)
_clear_config_path_warning()
return {"path": path, "error": "", "seed_path": seed_path}
if fallback_create_path.is_empty():
fallback_create_path = path
_clear_config_path_warning()
return {"path": fallback_create_path, "error": ""}
func _first_existing_later_candidate(templates: Array, start_index: int) -> String:
for index in range(start_index, templates.size()):
var group := McpPathTemplate.expand_path_candidates(str(templates[index]))
# A seed is optional. Never choose among an ambiguous later wildcard;
# the authoritative target was already resolved by the earlier group.
if group.size() != 1:
continue
var path := String(group[0])
if FileAccess.file_exists(path):
return path
return ""
func _warn_config_path_once(message: String) -> void:
_config_path_warning_mutex.lock()
var should_warn := message != _last_config_path_warning
_last_config_path_warning = message
_config_path_warning_mutex.unlock()
if should_warn:
push_warning(message)
func _clear_config_path_warning() -> void:
_config_path_warning_mutex.lock()
_last_config_path_warning = ""
_config_path_warning_mutex.unlock()
## The env-var-relocated config path, or "" when no override applies
## (no mapping declared, env var unset, or env var empty/whitespace).
func config_home_override() -> String:
if config_home_env.is_empty() or config_home_env_subpath.is_empty():
return ""
## env_lookup, not OS.get_environment: this runs on dock worker threads,
## which must not race the spawn window's setenv/unsetenv (#691).
var home := McpPathTemplate.env_lookup(config_home_env).strip_edges()
if home.is_empty():
return ""
# Expand a leading ~ so `CODEX_HOME=~/codex-alt` behaves like the shell.
return McpPathTemplate.expand(home).path_join(config_home_env_subpath)
## True when a CLI client also declares where its config file lives, so it can
## fall back to writing that file directly when the CLI binary isn't on PATH.
## #463: Claude Code installed only as a VS Code / Cursor extension exposes no
## `claude` binary, but `claude mcp add --scope user` just writes `mcpServers`
## into ~/.claude.json — so we can produce the same entry ourselves.
func has_json_fallback() -> bool:
return config_type == "cli" and not path_template.is_empty() and not server_key_path.is_empty()
## True if the user appears to have this client installed locally.
func is_installed() -> bool:
if config_type == "cli":
if not McpCliFinder.find(_array_from_packed(cli_names)).is_empty():
return true
# CLI not on PATH. A cli client with a JSON fallback (Claude Code as a
# VS Code/Cursor extension, #463) still counts as installed if its
# fallback config file already exists.
if has_json_fallback():
var cfg := resolved_config_path()
return not cfg.is_empty() and FileAccess.file_exists(cfg)
return false
for p in detect_paths:
for resolved in McpPathTemplate.expand_path_candidates(p):
if FileAccess.file_exists(resolved) or DirAccess.dir_exists_absolute(resolved):
return true
# Fall back to "config file already exists" — usually means installed at some point.
var cfg := resolved_config_path()
return not cfg.is_empty() and FileAccess.file_exists(cfg)
static func _array_from_packed(packed: PackedStringArray) -> Array[String]:
var out: Array[String] = []
for s in packed:
out.append(s)
return out
## Slice a PackedStringArray into a new PackedStringArray over [from, to).
## Used by `_toml_strategy` and `_manual_command` to peel the section path
## apart for `[a.b."c"]` header rendering.
static func _packed_slice(packed: PackedStringArray, from: int, to: int) -> PackedStringArray:
var out := PackedStringArray()
for i in range(from, to):
out.append(packed[i])
return out
+1
View File
@@ -0,0 +1 @@
uid://cyowqr1x12ilg
+169
View File
@@ -0,0 +1,169 @@
@tool
class_name McpCliExec
extends RefCounted
## Wall-clock-bounded CLI invocation. Every dock shell-out to a per-client
## CLI (`claude mcp list`, `claude mcp add ...`, etc.) goes through here so
## a hung subprocess can't trap the calling thread forever.
##
## Without the timeout, a contended `claude mcp list` has been observed to
## hang for 6+ minutes (issues #238, #239) — wedging the dock's status
## refresh worker, and on the Configure / Remove paths the editor main
## thread itself.
##
## Why poll/kill instead of `OS.execute(..., true)`: GDScript can't
## interrupt a blocking `OS.execute`, so a hung CLI takes its caller's
## thread with it. `OS.execute_with_pipe` returns immediately with a PID;
## we drive the wait ourselves and `OS.kill` the orphan if budget
## expires. CLI registry commands have bounded output (a few hundred
## bytes), so we don't bother draining the pipe during the poll loop —
## the kernel buffer absorbs it.
##
## Returns a Dictionary with:
## exit_code: process exit code (0 = success). -1 on timeout / spawn failure.
## stdout: captured stdout text. May be partial on timeout.
## stderr: captured stderr text. May be partial on timeout. Empty when
## `capture_stderr` is false.
## output: stdout + (newline + stderr if non-empty). Convenience for
## the common case of "show whatever the CLI said when it
## failed" — `claude mcp add` writes its real diagnostics to
## stderr, so callers that only read `stdout` would surface
## a generic "exit code 1" instead.
## timed_out: true if we killed the process at the wall-clock budget.
## spawn_failed: true if `OS.execute_with_pipe` didn't return a usable PID.
const DEFAULT_TIMEOUT_MS := 8000
const _POLL_INTERVAL_MS := 50
const _KILL_GRACE_MS := 500
static func run(
exe: String,
args: Array,
timeout_ms: int = DEFAULT_TIMEOUT_MS,
capture_stderr: bool = true
) -> Dictionary:
if exe.is_empty():
return _spawn_failed_result()
return _run_piped(exe, args, timeout_ms, capture_stderr)
static func _run_piped(
exe: String,
args: Array,
timeout_ms: int,
capture_stderr: bool,
) -> Dictionary:
var spawn_exe := exe
var spawn_args := args
if OS.get_name() == "Windows":
var lower := exe.to_lower()
if lower.ends_with(".cmd") or lower.ends_with(".bat"):
## CreateProcessW can't launch `.cmd` / `.bat` scripts on its
## own — they're cmd.exe input, not PE binaries. Without this
## wrap, the moment `McpCliFinder` resolves a Node-style shim
## (npm's `claude.cmd`, pnpm's wrappers, …) the next
## `OS.execute_with_pipe` surfaces "Could not create child
## process: <path> ..." in Godot's output log (#251). Passing
## `exe` as a separate argv element keeps spaces in the path
## quoted by Godot's standard quoter — no manual escaping.
spawn_exe = "cmd.exe"
spawn_args = ["/c", exe]
spawn_args.append_array(args)
var info := OS.execute_with_pipe(spawn_exe, spawn_args)
if info.is_empty():
return _spawn_failed_result()
var pid: int = int(info.get("pid", -1))
var stdio: Variant = info.get("stdio", null)
var stderr_pipe: Variant = info.get("stderr", null)
if pid <= 0:
_close_pipes(stdio, stderr_pipe)
return _spawn_failed_result()
var deadline := Time.get_ticks_msec() + maxi(timeout_ms, _POLL_INTERVAL_MS)
while OS.is_process_running(pid):
if Time.get_ticks_msec() >= deadline:
## Kill before draining: a pipe read can block while the child is
## still alive. Once it exits, drain any buffered partial output.
OS.kill(pid)
var kill_deadline := Time.get_ticks_msec() + _KILL_GRACE_MS
while OS.is_process_running(pid) and Time.get_ticks_msec() < kill_deadline:
OS.delay_msec(_POLL_INTERVAL_MS)
var partial_stdout := ""
var partial_stderr := ""
if not OS.is_process_running(pid):
partial_stdout = _drain_pipe(stdio)
partial_stderr = _drain_pipe(stderr_pipe) if capture_stderr else ""
_close_pipes(stdio, stderr_pipe)
return {
"exit_code": -1,
"stdout": partial_stdout,
"stderr": partial_stderr,
"output": _join_streams(partial_stdout, partial_stderr),
"timed_out": true,
"spawn_failed": false,
}
OS.delay_msec(_POLL_INTERVAL_MS)
var stdout := _drain_pipe(stdio)
var stderr_text := _drain_pipe(stderr_pipe) if capture_stderr else ""
_close_pipes(stdio, stderr_pipe)
return {
"exit_code": OS.get_process_exit_code(pid),
"stdout": stdout,
"stderr": stderr_text,
"output": _join_streams(stdout, stderr_text),
"timed_out": false,
"spawn_failed": false,
}
static func _spawn_failed_result() -> Dictionary:
return {
"exit_code": -1,
"stdout": "",
"stderr": "",
"output": "",
"timed_out": false,
"spawn_failed": true,
}
static func _drain_pipe(pipe: Variant) -> String:
if not (pipe is FileAccess):
return ""
var f := pipe as FileAccess
var bytes := PackedByteArray()
var max_bytes := 1 << 20 # 1 MiB, far above expected client CLI output.
while bytes.size() < max_bytes:
var chunk := f.get_buffer(mini(4096, max_bytes - bytes.size()))
if chunk.is_empty():
break
bytes.append_array(chunk)
if f.eof_reached():
break
return bytes.get_string_from_utf8()
static func _join_streams(stdout: String, stderr_text: String) -> String:
## Most CLIs write their actionable diagnostics to one stream or the
## other, never both — so concatenation gives "the message" without
## the caller having to guess which key to read. Newline-separate so
## callers that grep don't see two lines run together.
if stderr_text.is_empty():
return stdout
if stdout.is_empty():
return stderr_text
return "%s\n%s" % [stdout, stderr_text]
static func _close_pipes(stdio: Variant, stderr_pipe: Variant) -> void:
if stdio is FileAccess:
(stdio as FileAccess).close()
if stderr_pipe is FileAccess:
(stderr_pipe as FileAccess).close()
+1
View File
@@ -0,0 +1 @@
uid://dhoe3ypkhm12v
+181
View File
@@ -0,0 +1,181 @@
@tool
class_name McpCliFinder
extends RefCounted
## Generic three-tier CLI resolution for clients whose binary lives somewhere
## a GUI-launched Godot's minimal PATH won't see:
## 1. Well-known install locations (~/.local/bin, /opt/homebrew/bin, ...)
## 2. Login shell lookup (`bash -lc 'command -v <exe>'`) — picks up .zshrc / .bashrc
## 3. Plain `which` / `where` against the inherited PATH
## Caches per-exe so repeated dock refreshes don't fork a shell every frame.
##
## Thread safety: `find()` runs on action-worker threads
## (`_run_client_action_worker` in `mcp_dock.gd`), and `invalidate()` runs on
## the main thread (manual Refresh path). Godot `Dictionary` is not safe for
## concurrent mutation, so `_cache` / `_searched` access is guarded by
## `_mutex`. The mutex is held only across dictionary read/write — the slow
## `_resolve()` path (FileAccess + bounded subprocess lookup) runs unlocked, so a
## main-thread `invalidate()` can never block on a worker's subprocess.
## Two workers racing the same exe both call `_resolve()` and both write
## back the same answer; that's wasted work, not corruption.
static var _mutex: Mutex = Mutex.new()
static var _cache: Dictionary = {} # exe_name -> resolved path (or "")
static var _searched: Dictionary = {}
const _LOOKUP_TIMEOUT_MS := 3000
## Find any of the supplied exe names; returns the first hit.
## On Windows pass the .exe variant in `exe_names` if relevant.
static func find(exe_names: Array[String]) -> String:
for name in exe_names:
var hit := _find_one(name)
if not hit.is_empty():
return hit
return ""
## Drop cache for one exe (call after the user installs / reinstalls).
static func invalidate(exe_name: String = "") -> void:
_mutex.lock()
if exe_name.is_empty():
_cache.clear()
_searched.clear()
else:
_cache.erase(exe_name)
_searched.erase(exe_name)
_mutex.unlock()
static func _find_one(exe_name: String) -> String:
_mutex.lock()
var already_searched: bool = _searched.get(exe_name, false)
var cached: String = _cache.get(exe_name, "")
_mutex.unlock()
if already_searched:
return cached
# `_resolve()` does FileAccess + bounded subprocess lookup (forks
# `bash -lc` / `which`), which can take 100ms-1s. Holding the mutex across that
# would let a concurrent `invalidate()` on the main thread freeze the
# editor for the duration of the subprocess — which defeats the whole
# point of running CLI lookup off the main thread.
var hit := _resolve(exe_name)
_mutex.lock()
_cache[exe_name] = hit
_searched[exe_name] = true
_mutex.unlock()
return hit
static func _resolve(exe_name: String) -> String:
var is_windows := OS.get_name() == "Windows"
# 1. Well-known locations
for dir in _well_known_dirs():
var full := dir.path_join(exe_name)
if FileAccess.file_exists(full):
return full
# 2. Login shell lookup (Unix only)
if not is_windows:
## env_lookup, not OS.get_environment: CLI resolution runs on dock
## worker threads (configure/remove actions) and must not race the
## spawn window's setenv/unsetenv (#691).
var shell := McpPathTemplate.env_lookup("SHELL")
if shell.is_empty():
shell = "/bin/bash"
var stripped := exe_name.trim_suffix(".exe")
var login_result := McpCliExec.run(shell, ["-lc", "command -v %s" % stripped], _LOOKUP_TIMEOUT_MS, false)
if int(login_result.get("exit_code", -1)) == 0:
var login_found: String = str(login_result.get("stdout", "")).strip_edges()
if not login_found.is_empty() and FileAccess.file_exists(login_found):
return login_found
# 3. which / where with inherited PATH
var lookup := "where" if is_windows else "which"
var result := McpCliExec.run(lookup, [exe_name], _LOOKUP_TIMEOUT_MS, false)
if int(result.get("exit_code", -1)) == 0:
var output := str(result.get("stdout", ""))
var lines := PackedStringArray(output.split("\n"))
var found := _pick_best_path(lines) if is_windows else lines[0].strip_edges()
if not found.is_empty():
return found
return ""
## Executable extensions Windows' CreateProcessW can launch from a path
## (after the cmd.exe wrap in `_cli_exec.gd`). Order is preference: `.exe`
## is a native PE binary; `.cmd` / `.bat` go through the shell; `.com` is
## the legacy COM-format executable that some shims still ship.
const _WINDOWS_EXEC_EXTS := [".exe", ".cmd", ".bat", ".com"]
## Pick the best path from `where` output on Windows.
##
## npm-installed Node CLIs ship as BOTH `<dir>/<name>` (a POSIX bash shim
## for WSL / Git Bash users) AND `<dir>/<name>.cmd` (the actual Windows
## wrapper). `where <name>` lists both. CreateProcessW — the underlying
## syscall behind `OS.execute_with_pipe` — refuses to launch the
## extensionless POSIX shim, surfacing as
## `ERROR: Could not create child process: "...\claude" mcp list`
## in Godot's output log (#251). Picking a path with a real executable
## extension dodges that entirely.
##
## Extension scan is the OUTER loop so the order in `_WINDOWS_EXEC_EXTS`
## drives preference — `.exe` wins over `.cmd` even when the `.cmd` shows
## up first in `where` output (one fewer process per shell-out). Falls
## back to the first non-empty line when no entry has a recognised
## extension, so we never come up empty when `where` returned *something*.
static func _pick_best_path(lines: PackedStringArray) -> String:
var stripped := PackedStringArray()
for raw in lines:
var line := raw.strip_edges()
if not line.is_empty():
stripped.append(line)
if stripped.is_empty():
return ""
for ext in _WINDOWS_EXEC_EXTS:
for candidate in stripped:
if candidate.to_lower().ends_with(ext):
return candidate
return stripped[0]
static func _well_known_dirs() -> Array[String]:
## env_lookup, not OS.get_environment — see _resolve()'s worker-thread
## note (#691).
var home := McpPathTemplate.env_lookup("HOME")
if home.is_empty():
home = McpPathTemplate.env_lookup("USERPROFILE")
match OS.get_name():
"macOS":
return [
home.path_join(".local/bin"),
home.path_join(".claude/local"),
home.path_join(".cargo/bin"),
"/opt/homebrew/bin",
"/usr/local/bin",
]
"Windows":
var local := McpPathTemplate.env_lookup("LOCALAPPDATA")
var prog := McpPathTemplate.env_lookup("ProgramFiles")
var paths: Array[String] = []
if not home.is_empty():
paths.append(home.path_join(".claude/local"))
paths.append(home.path_join(".local/bin"))
paths.append(home.path_join(".cargo/bin"))
paths.append(home.path_join("AppData/Local/Programs/uv"))
if not local.is_empty():
paths.append(local.path_join("Programs/uv"))
if not prog.is_empty():
paths.append(prog.path_join("uv"))
return paths
_:
return [
home.path_join(".local/bin"),
home.path_join(".claude/local"),
home.path_join(".cargo/bin"),
"/usr/local/bin",
]
@@ -0,0 +1 @@
uid://cnp5b6fcwou2y
+215
View File
@@ -0,0 +1,215 @@
@tool
class_name McpCliStrategy
extends RefCounted
## Strategy for MCP clients that own their own state via a CLI (e.g.
## `claude mcp add`). Reads `cli_register_template` / `cli_unregister_template`
## / `cli_status_args` from the descriptor and substitutes `{name}` / `{url}`
## tokens. Command-shape descriptors additionally use the whole-element launch
## tokens `{command}` / `{args...}` (see `format_args`). No descriptor-supplied
## Callables — see `_base.gd` for why.
##
## Every shell-out goes through `McpCliExec.run`, which wraps the call in a
## wall-clock timeout. A hung CLI (e.g. `claude mcp list` under
## inter-Claude-Code contention) gets killed at the budget instead of
## locking up the caller forever — see issues #238 / #239.
const _CONFIGURE_TIMEOUT_MS := 10000
const _REMOVE_TIMEOUT_MS := 10000
const _STATUS_TIMEOUT_MS := 6000
static func configure(
client: McpClient,
server_name: String,
server_url: String,
launch: Dictionary = {},
) -> Dictionary:
## Fail closed before any subprocess runs: a command-shape client without a
## verified attach launcher must not register anything (see
## docs/client-configuration.md — an ERROR beats an entry known to be broken).
var launch_error := command_launch_error(client, launch)
if not launch_error.is_empty():
return {"status": "error", "message": launch_error}
var cli := _resolve_cli(client)
if cli.is_empty():
return {"status": "error", "message": "%s not found" % client.display_name}
# Best-effort prior cleanup so re-configure is idempotent. Bounded to
# the same budget — a hung unregister shouldn't block the configure
# that follows.
if not client.cli_unregister_template.is_empty():
var pre_args := _format_args(client.cli_unregister_template, server_name, server_url)
McpCliExec.run(cli, pre_args, _REMOVE_TIMEOUT_MS)
if client.cli_register_template.is_empty():
return {"status": "error", "message": "%s descriptor missing cli_register_template" % client.display_name}
var args := _format_args(client.cli_register_template, server_name, server_url, launch)
var result := McpCliExec.run(cli, args, _CONFIGURE_TIMEOUT_MS)
if result.get("timed_out", false):
return {
"status": "error",
"message": "Configure %s timed out after %ds — see 'Run this manually' below to retry by hand" % [
client.display_name, _CONFIGURE_TIMEOUT_MS / 1000,
],
}
if result.get("spawn_failed", false):
return {"status": "error", "message": "Failed to spawn %s" % client.display_name}
if int(result.get("exit_code", -1)) == 0:
return {"status": "ok", "message": McpClient.configured_message(client, server_url)}
## `claude mcp add` writes its real failure diagnostics to stderr, so
## prefer `output` (stdout + stderr) over `stdout` alone — otherwise
## the user sees "exit code 1" instead of the actual error.
var combined := str(result.get("output", "")).strip_edges()
var err := combined if not combined.is_empty() else "exit code %d" % int(result.get("exit_code", -1))
return {"status": "error", "message": "Failed to configure %s: %s" % [client.display_name, err]}
## Run the descriptor's `cli_status_args`, scan stdout for `server_name` and
## the expected target. The matching rule is the only sensible one for "list
## MCP entries" output across CLI clients we currently support: name AND
## target present → CONFIGURED; name only → MISMATCH; neither →
## NOT_CONFIGURED. For URL descriptors the target is `server_url`; for
## command-shape descriptors it is the resolved attach launcher path (the
## listing prints the registered command line, not a URL). Command-shape CLI
## clients with a JSON fallback file get exact drift detection via the JSON
## strategy instead — the configurator prefers that path and only lands here
## for CLI clients whose state isn't file-readable.
static func check_status(
client: McpClient, server_name: String, server_url: String, launch: Dictionary = {}
) -> McpClient.Status:
return check_status_with_cli_path(client, server_name, server_url, _resolve_cli(client), launch)
static func check_status_with_cli_path(
client: McpClient, server_name: String, server_url: String, cli: String, launch: Dictionary = {}
) -> McpClient.Status:
return check_status_details(client, server_name, server_url, cli, launch).get("status", McpClient.Status.NOT_CONFIGURED)
## Detailed variant used by the dock's refresh worker so it can surface a
## "probe timed out" badge on the affected row instead of silently
## conflating the timeout with NOT_CONFIGURED. Returns
## `{"status": Status, "error_msg": String}`. The caller plumbs
## `error_msg` straight into `_apply_row_status`.
static func check_status_details(
client: McpClient, server_name: String, server_url: String, cli: String, launch: Dictionary = {}
) -> Dictionary:
if cli.is_empty():
return _status_details(McpClient.Status.NOT_CONFIGURED)
if client.cli_status_args.is_empty():
return _status_details(McpClient.Status.NOT_CONFIGURED)
var expected_target := server_url
if client.command_shape != McpClient.CommandShape.NONE:
## Same fail-closed contract as configure: without a verified launcher
## there is no target to compare against, and guessing would report a
## broken entry as green.
var launch_error := command_launch_error(client, launch)
if not launch_error.is_empty():
return _status_details(McpClient.Status.ERROR, launch_error)
expected_target = str(launch.get("command", ""))
var result := McpCliExec.run(
cli,
McpClient._array_from_packed(client.cli_status_args),
_STATUS_TIMEOUT_MS,
false
)
if result.get("timed_out", false):
return _status_details(McpClient.Status.ERROR, "probe timed out")
if result.get("spawn_failed", false):
return _status_details(McpClient.Status.NOT_CONFIGURED)
if int(result.get("exit_code", -1)) != 0:
return _status_details(McpClient.Status.NOT_CONFIGURED)
var text := str(result.get("stdout", ""))
if text.find(server_name) < 0:
return _status_details(McpClient.Status.NOT_CONFIGURED)
## Server registered, but pointing somewhere else — drift after a
## port change. Surface as mismatch so the dock offers Reconfigure.
if text.find(expected_target) < 0:
return _status_details(McpClient.Status.CONFIGURED_MISMATCH)
return _status_details(McpClient.Status.CONFIGURED)
## Empty string when this client's launch requirements are satisfied. A
## command-shape descriptor requires a successfully resolved attach launch;
## URL descriptors (`CommandShape.NONE`) never require one. Mirrors
## `McpJsonStrategy.command_launch_error` / the TOML equivalent.
static func command_launch_error(client: McpClient, launch: Dictionary) -> String:
if client.command_shape == McpClient.CommandShape.NONE:
return ""
if not bool(launch.get("ok", false)):
return str(launch.get("error", "No compatible attach launcher was found."))
return ""
static func _status_details(status: McpClient.Status, error_msg: String = "") -> Dictionary:
return {"status": status, "error_msg": error_msg}
static func remove(client: McpClient, server_name: String) -> Dictionary:
var cli := _resolve_cli(client)
if cli.is_empty():
return {"status": "error", "message": "%s not found" % client.display_name}
if client.cli_unregister_template.is_empty():
return {"status": "error", "message": "%s descriptor missing cli_unregister_template" % client.display_name}
var args := _format_args(client.cli_unregister_template, server_name, "")
var result := McpCliExec.run(cli, args, _REMOVE_TIMEOUT_MS)
if result.get("timed_out", false):
return {
"status": "error",
"message": "Remove %s timed out after %ds — see 'Run this manually' below to retry by hand" % [
client.display_name, _REMOVE_TIMEOUT_MS / 1000,
],
}
if result.get("spawn_failed", false):
return {"status": "error", "message": "Failed to spawn %s" % client.display_name}
if int(result.get("exit_code", -1)) == 0:
return {"status": "ok", "message": "%s configuration removed" % client.display_name}
## `claude mcp add` writes its real failure diagnostics to stderr, so
## prefer `output` (stdout + stderr) over `stdout` alone — otherwise
## the user sees "exit code 1" instead of the actual error.
var combined := str(result.get("output", "")).strip_edges()
var err := combined if not combined.is_empty() else "exit code %d" % int(result.get("exit_code", -1))
return {"status": "error", "message": "Failed to remove %s: %s" % [client.display_name, err]}
## Substitute `{name}` and `{url}` tokens in every template entry.
## Tokens match verbatim — `{name_suffix}` is NOT touched, so callers don't
## have to worry about partial-token collisions in their argv.
##
## Launch tokens are whole-element only: an element that is exactly
## `{command}` becomes the resolved attach launcher path, and an element that
## is exactly `{args...}` is spliced into the argv as one element per launch
## arg. Whole-element matching keeps a literal brace inside a path or flag
## from ever triggering an expansion.
static func format_args(
template: PackedStringArray, server_name: String, server_url: String, launch: Dictionary = {}
) -> Array[String]:
return _format_args(template, server_name, server_url, launch)
static func _format_args(
template: PackedStringArray, server_name: String, server_url: String, launch: Dictionary = {}
) -> Array[String]:
var out: Array[String] = []
for arg in template:
var s := String(arg)
if s == "{command}":
out.append(str(launch.get("command", "")))
continue
if s == "{args...}":
for launch_arg in launch.get("args", []):
out.append(str(launch_arg))
continue
s = s.replace("{name}", server_name)
s = s.replace("{url}", server_url)
out.append(s)
return out
static func _resolve_cli(client: McpClient) -> String:
return McpCliFinder.find(McpClient._array_from_packed(client.cli_names))
static func resolve_cli_path(client: McpClient) -> String:
return _resolve_cli(client)
@@ -0,0 +1 @@
uid://bvib7d8eabbcm
+341
View File
@@ -0,0 +1,341 @@
@tool
class_name McpJsonStrategy
extends RefCounted
## Readmergewrite strategy for JSON-backed MCP clients.
## All knobs come from the McpClient descriptor as plain data — no Callables.
## See `_base.gd` for why descriptors are data-only.
static func configure(
client: McpClient,
server_name: String,
server_url: String,
launch: Dictionary = {},
) -> Dictionary:
var resolution := client.resolved_config_path_details()
var path := str(resolution.get("path", ""))
var path_error := str(resolution.get("error", ""))
if not path_error.is_empty():
return {"status": "error", "message": path_error}
if path.is_empty():
return {"status": "error", "message": "Could not resolve config path for %s on this OS" % client.display_name}
var seed_path := str(resolution.get("seed_path", ""))
var read_path := seed_path if not FileAccess.file_exists(path) and not seed_path.is_empty() else path
var read := _read_or_init(read_path)
if not read["ok"]:
return {"status": "error", "message": "Refusing to overwrite %s: %s. Fix or move the file, then re-run Configure." % [read_path, read["error"]]}
var launch_error := command_launch_error(client, launch)
if not launch_error.is_empty():
return {"status": "error", "message": launch_error}
var config: Dictionary = read["data"]
var holder := _ensure_path(config, client.server_key_path)
## Pass the existing entry through so `build_entry` can preserve user-mutable
## state (auto-approval lists, `disabled` toggles) instead of resetting it
## to descriptor defaults on every Configure click. See `entry_initial_fields`
## docs in `_base.gd`.
var existing: Variant = holder.get(server_name, null)
holder[server_name] = build_entry(client, server_url, existing, launch)
if not McpAtomicWrite.write(path, JSON.stringify(_narrow_integral_numbers(config), "\t", false)):
return {"status": "error", "message": "Cannot write to %s" % path}
return {"status": "ok", "message": McpClient.configured_message(client, server_url)}
static func check_status(
client: McpClient,
server_name: String,
server_url: String,
launch: Dictionary = {},
) -> McpClient.Status:
return check_status_details(client, server_name, server_url, launch).get("status", McpClient.Status.NOT_CONFIGURED)
## Detailed variant feeding the dock's error_msg plumbing (#711): a config
## file that EXISTS but can't be read or parsed is Status.ERROR carrying the
## read/parse error, not NOT_CONFIGURED — the write path refuses to touch
## such a file (see `_read_or_init`), so the status dot must say "broken
## file", not "click Configure".
static func check_status_details(
client: McpClient,
server_name: String,
server_url: String,
launch: Dictionary = {},
) -> Dictionary:
var resolution := client.resolved_config_path_details()
var path := str(resolution.get("path", ""))
var path_error := str(resolution.get("error", ""))
if not path_error.is_empty():
return {"status": McpClient.Status.ERROR, "error_msg": path_error}
if path.is_empty() or not FileAccess.file_exists(path):
return {"status": McpClient.Status.NOT_CONFIGURED, "error_msg": ""}
var read := _read_or_init(path)
if not read["ok"]:
return {"status": McpClient.Status.ERROR, "error_msg": String(read["error"])}
var config: Dictionary = read["data"]
var holder := _walk_path(config, client.server_key_path)
if not (holder is Dictionary) or not holder.has(server_name):
return {"status": McpClient.Status.NOT_CONFIGURED, "error_msg": ""}
var entry = holder[server_name]
if not (entry is Dictionary):
return {"status": McpClient.Status.NOT_CONFIGURED, "error_msg": ""}
var launch_error := command_launch_error(client, launch)
if not launch_error.is_empty():
return {"status": McpClient.Status.ERROR, "error_msg": launch_error}
## An entry under `server_name` exists — if the URL doesn't match,
## that's drift (the user changed the port and the client config is stale),
## not "never configured". The dock surfaces that as an amber banner.
if verify_entry(client, entry, server_url, launch):
return {"status": McpClient.Status.CONFIGURED, "error_msg": ""}
return {"status": McpClient.Status.CONFIGURED_MISMATCH, "error_msg": ""}
static func remove(client: McpClient, server_name: String) -> Dictionary:
var resolution := client.resolved_config_path_details()
var path := str(resolution.get("path", ""))
var path_error := str(resolution.get("error", ""))
if not path_error.is_empty():
return {"status": "error", "message": path_error}
if path.is_empty() or not FileAccess.file_exists(path):
return {"status": "ok", "message": "Not configured"}
var read := _read_or_init(path)
if not read["ok"]:
return {"status": "error", "message": "Refusing to rewrite %s: %s." % [path, read["error"]]}
var config: Dictionary = read["data"]
var holder := _walk_path(config, client.server_key_path)
if holder is Dictionary and holder.has(server_name):
holder.erase(server_name)
if not McpAtomicWrite.write(path, JSON.stringify(_narrow_integral_numbers(config), "\t", false)):
return {"status": "error", "message": "Cannot write to %s" % path}
return {"status": "ok", "message": "%s configuration removed" % client.display_name}
## Synthesize the entry dict the strategy writes under
## `server_key_path[server_name]`. Both URL and command entries deep-copy the
## existing dict before overwriting strategy-owned fields, preserving unknown
## client additions as well as descriptor-documented user fields.
static func build_entry(
client: McpClient,
server_url: String,
existing: Variant = null,
launch: Dictionary = {},
) -> Dictionary:
if _is_supported_command_shape(client.command_shape):
var command_entry: Dictionary = (existing as Dictionary).duplicate(true) if existing is Dictionary else {}
if client.command_shape == McpClient.CommandShape.COMMAND_ARRAY:
## OpenCode-style: the entry's `command` field IS the argv array.
## A stale sibling `args` from a FLAT-style hand edit would be
## ambiguous next to it, so it is strategy-owned and removed.
command_entry["command"] = _launch_argv(launch)
command_entry.erase("args")
else:
command_entry["command"] = str(launch.get("command", ""))
command_entry["args"] = _array_copy(launch.get("args", []))
if not client.command_transport_key.is_empty():
command_entry[client.command_transport_key] = client.command_transport_value
for key in client.command_initial_fields:
if not command_entry.has(key):
command_entry[key] = client.command_initial_fields[key]
for key in client.command_legacy_keys:
command_entry.erase(String(key))
_remove_legacy_env_keys(command_entry, client.command_env_legacy_keys)
return command_entry
if client.command_shape != McpClient.CommandShape.NONE:
return {}
return build_url_entry(client, server_url, existing)
static func build_url_entry(client: McpClient, server_url: String, existing: Variant = null) -> Dictionary:
var entry: Dictionary = (existing as Dictionary).duplicate(true) if existing is Dictionary else {}
entry[client.entry_url_field] = server_url
for k in client.entry_extra_fields:
entry[k] = client.entry_extra_fields[k]
for k in client.entry_initial_fields:
if not entry.has(k):
entry[k] = client.entry_initial_fields[k]
return entry
## Default verifier for a stored entry. Command entries must match every
## launch-affecting value exactly; legacy URL or env keys are migration drift.
## For URL clients, assert `entry[entry_url_field] == url` AND every
## key in `entry_extra_fields` matches verbatim. Type-pinning for Cline /
## Roo / Kilo (`type: "streamable-http"` etc.) falls out of this — pre-fix
## entries that lack the type field fail verification and surface as drift.
static func verify_entry(
client: McpClient,
entry: Dictionary,
server_url: String,
launch: Dictionary = {},
) -> bool:
if client.command_shape != McpClient.CommandShape.NONE:
if not _is_supported_command_shape(client.command_shape) or not bool(launch.get("ok", false)):
return false
for key in client.command_legacy_keys:
if entry.has(String(key)):
return false
var env = entry.get("env", null)
if env is Dictionary:
for key in client.command_env_legacy_keys:
if env.has(String(key)):
return false
if client.command_shape == McpClient.CommandShape.COMMAND_ARRAY:
if not _arrays_equal(entry.get("command", null), _launch_argv(launch)):
return false
if entry.has("args"):
return false
else:
if entry.get("command") != launch.get("command"):
return false
if not _arrays_equal(entry.get("args", null), launch.get("args", null)):
return false
if not client.command_transport_key.is_empty():
if not entry.has(client.command_transport_key):
return false
if entry.get(client.command_transport_key) != client.command_transport_value:
return false
return true
if entry.get(client.entry_url_field, "") != server_url:
return false
for k in client.entry_extra_fields:
if entry.get(k) != client.entry_extra_fields[k]:
return false
return true
static func command_launch_error(client: McpClient, launch: Dictionary) -> String:
if client.command_shape == McpClient.CommandShape.NONE:
return ""
if not _is_supported_command_shape(client.command_shape):
return "%s uses a command shape not supported by JSON yet" % client.display_name
if not bool(launch.get("ok", false)):
return str(launch.get("error", "No compatible attach launcher was found."))
return ""
static func _is_supported_command_shape(shape: McpClient.CommandShape) -> bool:
return shape == McpClient.CommandShape.FLAT or shape == McpClient.CommandShape.COMMAND_ARRAY
## The full launch argv as one array: launcher path followed by every arg.
static func _launch_argv(launch: Dictionary) -> Array:
var argv: Array = [str(launch.get("command", ""))]
argv.append_array(_array_copy(launch.get("args", [])))
return argv
static func _remove_legacy_env_keys(entry: Dictionary, legacy_keys: PackedStringArray) -> void:
if legacy_keys.is_empty():
return
var existing_env = entry.get("env", null)
if not (existing_env is Dictionary):
return
var env: Dictionary = (existing_env as Dictionary).duplicate(true)
for key in legacy_keys:
env.erase(String(key))
if env.is_empty():
entry.erase("env")
else:
entry["env"] = env
static func _array_copy(value: Variant) -> Array:
if value is Array:
return (value as Array).duplicate(true)
if value is PackedStringArray:
return McpClient._array_from_packed(value)
return []
static func _arrays_equal(left: Variant, right: Variant) -> bool:
if not (left is Array or left is PackedStringArray):
return false
if not (right is Array or right is PackedStringArray):
return false
var left_array := _array_copy(left)
var right_array := _array_copy(right)
if left_array.size() != right_array.size():
return false
for i in range(left_array.size()):
if left_array[i] != right_array[i]:
return false
return true
## Returns {"ok": true, "data": Dictionary} when the file is absent or parses
## cleanly, and {"ok": false, "error": String} when the file exists with
## non-empty content we cannot safely round-trip. Callers must NOT fall back
## to an empty dict on the error path — doing so blows away the user's other
## MCP entries on the next write.
static func _read_or_init(path: String) -> Dictionary:
if not FileAccess.file_exists(path):
return {"ok": true, "data": {}}
var file := FileAccess.open(path, FileAccess.READ)
if file == null:
var err := FileAccess.get_open_error()
return {"ok": false, "error": "could not open for reading (error %d)" % err}
var content := file.get_as_text()
file.close()
# Strip a UTF-8 BOM if present — some editors (notably on Windows) save
# JSON with a leading , which Godot's JSON.parse rejects outright.
# Previously this landed on the "unparseable → wipe" path.
if content.begins_with(""):
content = content.substr(1)
if content.strip_edges().is_empty():
return {"ok": true, "data": {}}
var json := JSON.new()
if json.parse(content) != OK:
var msg := "JSON parse error on line %d: %s" % [json.get_error_line(), json.get_error_message()]
push_warning("MCP | %s in %s" % [msg, path])
return {"ok": false, "error": msg}
if not (json.data is Dictionary):
return {"ok": false, "error": "top-level value is %s, expected object" % type_string(typeof(json.data))}
return {"ok": true, "data": json.data}
## Walk a key path, creating intermediate Dicts as needed. Returns the leaf Dict.
static func _ensure_path(root: Dictionary, key_path: PackedStringArray) -> Dictionary:
var cur := root
for key in key_path:
var next = cur.get(key)
if not (next is Dictionary):
next = {}
cur[key] = next
cur = next
return cur
## Walk a key path, returning the leaf Dict if all hops exist; else null.
static func _walk_path(root: Dictionary, key_path: PackedStringArray) -> Variant:
var cur: Variant = root
for key in key_path:
if not (cur is Dictionary) or not cur.has(key):
return null
cur = cur[key]
return cur
## Godot's JSON.parse turns every JSON number into a float, so a later
## JSON.stringify re-emits the user's integer fields as "8080.0" — which strict
## consumers (Go's encoding/json into an int field, etc.) reject, and which
## needlessly rewrites every number across the user's *other* entries. Re-narrow
## exactly-representable integral floats back to int so they serialize without
## the ".0". Walks dicts/arrays in place and returns the (same) value.
##
## Integers above 2^53 already lost precision when Godot parsed them to double,
## so they're left as the float Godot produced rather than faking exactness —
## byte-perfect preservation would require not parsing the file at all, and such
## magnitudes don't occur in MCP client configs.
static func _narrow_integral_numbers(value: Variant) -> Variant:
match typeof(value):
TYPE_FLOAT:
if is_finite(value) and value == floor(value) and absf(value) <= 9007199254740992.0:
return int(value)
TYPE_DICTIONARY:
for k in value:
value[k] = _narrow_integral_numbers(value[k])
TYPE_ARRAY:
for i in value.size():
value[i] = _narrow_integral_numbers(value[i])
return value
@@ -0,0 +1 @@
uid://g8a4iijpk22w
+264
View File
@@ -0,0 +1,264 @@
@tool
class_name McpManualCommand
extends RefCounted
const SHELL_POSIX := "posix"
const SHELL_POWERSHELL := "powershell"
## Keep this intersection deliberately small. PowerShell treats a leading `@`
## as splatting syntax and commas as list separators, while POSIX shells accept
## both literally; quoting either is safer than trying to infer token position.
const _SHELL_BARE_SAFE_CHARS := "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_+=:./-"
## Synthesize the "Run this manually" string the dock surfaces when
## auto-configure can't find a CLI / write a file. Generated from the
## descriptor's declarative fields — there is no per-client builder
## Callable. See `_base.gd` for why descriptors are data-only.
static func build(
client: McpClient,
server_name: String,
server_url: String,
resolved_path: String,
launch: Dictionary = {},
) -> String:
match client.config_type:
"cli":
return _build_cli(client, server_name, server_url, resolved_path, launch)
"json":
return _build_json(client, server_name, server_url, resolved_path, launch)
"toml":
return _build_toml(client, server_name, server_url, resolved_path, launch)
"yaml":
return _build_yaml(client, server_name, server_url, resolved_path, launch)
return ""
## CLI clients: format the register template against the *short* CLI name so
## the user can paste it into a terminal regardless of where their binary
## lives. (The auto-configure path resolves to an absolute uvx-style path;
## that's noise for a paste-into-terminal hint. The attach launcher path
## inside a command-shape line stays absolute — status verification compares
## the registered command against the resolved launcher verbatim.)
static func _build_cli(
client: McpClient,
server_name: String,
server_url: String,
resolved_path: String = "",
launch: Dictionary = {},
) -> String:
if client.cli_register_template.is_empty() or client.cli_names.is_empty():
return ""
var shell_kind := _shell_kind_for_platform()
var short_name: String = String(client.cli_names[0])
# Prefer the non-.exe form for a cross-platform-looking command line.
for n in client.cli_names:
if not String(n).ends_with(".exe"):
short_name = String(n)
break
var cmd := ""
if client.command_shape != McpClient.CommandShape.NONE:
var launch_error := McpCliStrategy.command_launch_error(client, launch)
if not launch_error.is_empty():
cmd = "Attach launch command unavailable: %s" % launch_error
else:
var args := McpCliStrategy.format_args(client.cli_register_template, server_name, server_url, launch)
var parts: Array[String] = [short_name]
for arg in args:
parts.append(String(arg))
cmd = _format_shell_command(parts, shell_kind)
else:
var args := McpCliStrategy.format_args(client.cli_register_template, server_name, server_url)
var parts: Array[String] = [short_name]
for arg in args:
parts.append(String(arg))
cmd = _format_shell_command(parts, shell_kind)
# #463: a CLI client with a JSON fallback (Claude Code) may have no `claude`
# binary at all — e.g. installed only as a VS Code/Cursor extension. The CLI
# line above is useless to that user, so also show the config-file edit that
# auto-configure falls back to writing.
if client.has_json_fallback() and not resolved_path.is_empty():
return "%s\n\nNo `%s` CLI (e.g. installed as a VS Code/Cursor extension)? %s" % [
cmd, short_name, _build_json(client, server_name, server_url, resolved_path, launch),
]
return cmd
static func _shell_kind_for_platform() -> String:
return SHELL_POWERSHELL if OS.get_name() == "Windows" else SHELL_POSIX
## Render a command for one explicitly named shell. The label is load-bearing:
## POSIX and PowerShell use different escaping for embedded single quotes, so
## presenting the command without its target shell invites a bad copy/paste.
static func _format_shell_command(parts: Array[String], shell_kind: String) -> String:
var rendered: Array[String] = []
for part in parts:
rendered.append(_shell_display_arg(part, shell_kind))
var label := "Run in PowerShell:" if shell_kind == SHELL_POWERSHELL else "Run in a POSIX shell:"
return "%s\n%s" % [label, " ".join(rendered)]
## Quote one argv element for the paste-into-terminal hint. Single-quoted
## strings are literal in both supported shells, but embedded single quotes
## have shell-specific spellings. Backslashes, double quotes, dollar signs,
## and PowerShell backticks remain byte-for-byte unchanged inside the quotes.
static func _shell_display_arg(arg: String, shell_kind: String) -> String:
if arg.is_empty():
return "''"
var stays_bare := true
for index in range(arg.length()):
if _SHELL_BARE_SAFE_CHARS.find(arg.substr(index, 1)) < 0:
stays_bare = false
break
if stays_bare:
return arg
if shell_kind == SHELL_POWERSHELL:
return "'%s'" % arg.replace("'", "''")
return "'%s'" % arg.replace("'", "'\"'\"'")
static func _build_json(
client: McpClient,
server_name: String,
server_url: String,
resolved_path: String,
launch: Dictionary = {},
) -> String:
var key := client.server_key_path[0] if client.server_key_path.size() > 0 else "mcpServers"
if client.command_shape != McpClient.CommandShape.NONE:
var lines: Array[String] = []
var launch_error := McpJsonStrategy.command_launch_error(client, launch)
if launch_error.is_empty():
var command_entry := McpJsonStrategy.build_entry(client, server_url, null, launch)
lines.append("Edit %s and add under \"%s\":" % [resolved_path, key])
lines.append(" \"%s\": %s" % [server_name, _format_entry_inline(command_entry)])
else:
lines.append("Attach launch command unavailable: %s" % launch_error)
if client.command_supports_url_fallback:
lines.append("")
lines.append("Advanced fallback — use this URL-mode entry instead; never configure both shapes together. URL mode depends on your client's own reconnect behavior. If the server is down when the client starts, restarting the client may be required.")
lines.append("Edit %s and add under \"%s\":" % [resolved_path, key])
var fallback_entry := McpJsonStrategy.build_url_entry(client, server_url)
lines.append(" \"%s\": %s" % [server_name, _format_entry_inline(fallback_entry)])
return "\n".join(lines)
var entry := McpJsonStrategy.build_entry(client, server_url)
return "Edit %s and add under \"%s\":\n \"%s\": %s" % [resolved_path, key, server_name, _format_entry_inline(entry)]
static func _build_toml(
client: McpClient,
_server_name: String,
server_url: String,
resolved_path: String,
launch: Dictionary = {},
) -> String:
var header := _toml_header(client)
if client.command_shape != McpClient.CommandShape.NONE:
var lines: Array[String] = []
var rendered := McpTomlStrategy.render_body(client, server_url, launch)
if bool(rendered.get("ok", false)):
lines.append("Edit %s and add:" % resolved_path)
lines.append(" %s" % header)
for body_line in rendered.get("lines", []):
lines.append(" %s" % str(body_line))
else:
lines.append("Attach launch command unavailable: %s" % str(rendered.get("error", "no compatible launcher found")))
if client.command_supports_url_fallback:
lines.append("")
lines.append("Advanced fallback — replace the command/args block above with this URL-mode block; never configure both shapes together. URL mode depends on your client's own reconnect behavior. If the server is down when the client starts, restarting the client may be required.")
lines.append("Edit %s and add:" % resolved_path)
lines.append(" %s" % header)
lines.append(" url = %s" % McpTomlStrategy.encode_basic_string(server_url))
return "\n".join(lines)
var body := McpTomlStrategy.format_body(client.toml_body_template, server_url)
var lines: Array[String] = ["Edit %s and add:" % resolved_path, " %s" % header]
for b in body:
lines.append(" %s" % String(b))
return "\n".join(lines)
static func _build_yaml(
client: McpClient,
server_name: String,
server_url: String,
resolved_path: String,
launch: Dictionary = {},
) -> String:
var key := client.server_key_path[0] if client.server_key_path.size() > 0 else "mcp_servers"
if client.command_shape != McpClient.CommandShape.NONE:
var lines: Array[String] = []
var launch_error := McpYamlStrategy.command_launch_error(client, launch)
if launch_error.is_empty():
var command_entry := McpYamlStrategy.build_entry(client, server_url, null, launch)
lines.append("Edit %s and add under '%s':" % [resolved_path, key])
for entry_line in McpYamlStrategy.render_entry_lines(server_name, command_entry):
lines.append(String(entry_line))
else:
lines.append("Attach launch command unavailable: %s" % launch_error)
if client.command_supports_url_fallback:
lines.append("")
lines.append("Advanced fallback — use this URL-mode entry instead; never configure both shapes together. URL mode depends on your client's own reconnect behavior. If the server is down when the client starts, restarting the client may be required.")
lines.append("Edit %s and add under '%s':" % [resolved_path, key])
var fallback_entry := {client.entry_url_field: server_url}
for entry_line in McpYamlStrategy.render_entry_lines(server_name, fallback_entry):
lines.append(String(entry_line))
return "\n".join(lines)
var entry := McpYamlStrategy.build_entry(client, server_url)
var lines: Array[String] = [
"Edit %s and add under '%s':" % [resolved_path, key],
" %s:" % server_name,
]
for k in entry:
lines.append(" %s: %s" % [k, str(entry[k])])
return "\n".join(lines)
## Mirrors the [section."name"] header `_toml_strategy._primary_header`
## emits, kept here so the manual-command text matches the file we'd write.
static func _toml_header(client: McpClient) -> String:
var parts := client.toml_section_path
if parts.size() < 2:
return "[%s]" % ".".join(parts)
var section := ".".join(McpClient._array_from_packed(McpClient._packed_slice(parts, 0, parts.size() - 1)))
var name := parts[parts.size() - 1]
return "[%s.\"%s\"]" % [section, name]
## Format an entry dict as a single inline JSON-ish string, matching the
## pre-refactor manual-command style: `{ "k": v, "k": v }` with spaces.
## Pre-existing manual-command tests assert the exact substring shape; this
## keeps them stable.
##
## Uses `JSON.stringify` for every leaf String (key OR value) so paths
## containing backslashes / quotes / newlines render as syntactically valid
## JSON. A Windows uvx path like `C:\Users\foo\uvx.exe` would otherwise be
## emitted as `"C:\Users\foo\uvx.exe"` — invalid JSON, unsafe to paste.
static func _format_entry_inline(entry: Dictionary) -> String:
var parts: Array[String] = []
for k in entry:
parts.append("%s: %s" % [JSON.stringify(String(k)), _format_value(entry[k])])
if parts.is_empty():
return "{}"
return "{ %s }" % ", ".join(parts)
static func _format_value(value: Variant) -> String:
# Strings, bools, numbers, null all round-trip correctly through JSON.stringify
# without spurious quoting of non-string scalars (true → `true`, 5 → `5`).
# Arrays and Dictionaries are formatted manually so the inline ` { k: v } `
# spacing matches the pre-refactor manual-command output shape that tests
# pin with assert_contains.
if value is Array:
var arr_parts: Array[String] = []
for v in value:
arr_parts.append(_format_value(v))
return "[%s]" % ", ".join(arr_parts)
if value is Dictionary:
var d_parts: Array[String] = []
for k in value:
d_parts.append("%s: %s" % [JSON.stringify(String(k)), _format_value(value[k])])
if d_parts.is_empty():
return "{}"
return "{ %s }" % ", ".join(d_parts)
return JSON.stringify(value)
@@ -0,0 +1 @@
uid://ct1wmgfk408x0
+206
View File
@@ -0,0 +1,206 @@
@tool
class_name McpPathTemplate
extends RefCounted
## Expands ~ / $HOME / $APPDATA / $XDG_CONFIG_HOME / $LOCALAPPDATA / $USERPROFILE
## inside path templates so per-client descriptors can declare paths declaratively
## without hand-rolling per-OS lookups.
## #691: dock worker threads (client-status refresh, configure/remove
## actions) and the #678 startup walk's discovery worker expand these
## templates off the main thread, while the spawn step mutates the
## process-global environment around `OS.create_process`
## (`GODOT_AI_OWNER_PID`, `GODOT_AI_PLUGIN_SPAWNED`, `PYTHONPATH`,
## `GODOT_AI_DISABLE_TELEMETRY`). A glibc `getenv` racing a concurrent
## `setenv` can return a freed pointer — rare but process-fatal. All env
## reads in this layer therefore go through `env_lookup`: on the MAIN
## thread it reads live and refreshes a mutex-guarded snapshot; off the
## main thread it serves from the snapshot, so no `OS.get_environment`
## runs concurrently with the spawn window's mutations. Callers pre-warm
## every var their workers can touch via `warm_env_snapshot` (plugin
## `_enter_tree` and the dock's phase-1 refresh prep, both main-thread,
## both before any worker starts).
static var _env_snapshot := {}
static var _env_snapshot_mutex := Mutex.new()
## Every var this layer and its sibling consumers (`_base.gd`
## `config_file_override_details`, `config_home_override`, `_cli_finder.gd` lookups,
## `client_configurator.gd` mode/trace reads) can touch off-main.
## Descriptor-declared config-file/config-home env names are passed as extras
## by the warm callers.
const _BASE_ENV_VARS: Array[String] = [
"HOME",
"USERPROFILE",
"XDG_CONFIG_HOME",
"APPDATA",
"LOCALAPPDATA",
"SHELL",
"ProgramFiles",
"GODOT_AI_MODE",
"GODOT_AI_STARTUP_TRACE",
## #804 (#752 adoption): _find_venv_python reads this via env_lookup on
## the dock's worker path; without pre-warming, a set override reads as
## empty there and is silently ignored — the exact misconfiguration the
## push_warning in client_configurator.gd exists to surface.
"GODOT_AI_VENV_PYTHON",
]
## Thread-safe env read (#691). Main thread: live read + snapshot refresh.
## Worker thread: snapshot only, so it can never race a main-thread
## setenv/unsetenv. A worker read of a never-warmed var returns "" — the
## same value an unset var reads as — never a live OS.get_environment,
## which would reintroduce the race for exactly the vars nobody thought
## to warm. Missing warm-up degrades resolution; it must not touch the
## process-global environment off-main.
static func env_lookup(name: String) -> String:
if OS.get_thread_caller_id() == OS.get_main_thread_id():
var live := OS.get_environment(name)
_env_snapshot_mutex.lock()
_env_snapshot[name] = live
_env_snapshot_mutex.unlock()
return live
_env_snapshot_mutex.lock()
var cached: Variant = _env_snapshot.get(name, null)
_env_snapshot_mutex.unlock()
if cached != null:
return str(cached)
return ""
## Main-thread pre-warm so subsequent worker reads never touch the real
## environment. Idempotent; safe to call before every worker dispatch.
static func warm_env_snapshot(extra_vars: PackedStringArray = PackedStringArray()) -> void:
for var_name in _BASE_ENV_VARS:
env_lookup(var_name)
for var_name in extra_vars:
if not String(var_name).is_empty():
env_lookup(String(var_name))
## Pick the right entry from a {"darwin": ..., "windows": ..., "linux": ...} map.
static func resolve(template_map: Dictionary) -> String:
var key := platform_key(template_map)
if key.is_empty():
return ""
var template: String = template_map[key]
return expand(template)
## Return the platform-specific key present in a descriptor map. `unix` is a
## shorthand for macOS and Linux. Public so descriptors can use the same
## platform selection for ordered path-candidate arrays as for one path.
static func platform_key(template_map: Dictionary) -> String:
var key := _os_key()
if template_map.has(key):
return key
if (key == "darwin" or key == "linux") and template_map.has("unix"):
return "unix"
return ""
## Expand one path template into zero or more concrete paths. A single `*` is
## allowed inside one DIRECTORY segment (for example `Packages/Claude_*`). The
## wildcard is resolved by enumerating that segment's parent; the remaining
## suffix may name a file that does not exist yet, which lets callers derive a
## deterministic create target for a fresh packaged-app install.
##
## Multiple wildcards fail closed and return no candidates. A wildcard final
## segment may identify an installation directory for `detect_paths`. Returned
## paths are sorted for deterministic tests and diagnostics; callers still
## reject ambiguous config groups rather than picking one.
static func expand_path_candidates(template: String) -> PackedStringArray:
var expanded := expand(template)
if expanded.is_empty():
return PackedStringArray()
var star := expanded.find("*")
if star < 0:
return PackedStringArray([expanded])
if expanded.find("*", star + 1) >= 0:
return PackedStringArray()
var slash_before := maxi(expanded.rfind("/", star), expanded.rfind("\\", star))
var forward_after := expanded.find("/", star)
var backward_after := expanded.find("\\", star)
var slash_after := forward_after
if slash_after < 0 or (backward_after >= 0 and backward_after < slash_after):
slash_after = backward_after
if slash_before < 0:
return PackedStringArray()
var parent := expanded.substr(0, slash_before)
var pattern := (
expanded.substr(slash_before + 1)
if slash_after < 0
else expanded.substr(slash_before + 1, slash_after - slash_before - 1)
)
var suffix := "" if slash_after < 0 else expanded.substr(slash_after + 1)
var pattern_star := pattern.find("*")
if pattern_star < 0:
return PackedStringArray()
var prefix := pattern.substr(0, pattern_star)
var ending := pattern.substr(pattern_star + 1)
var dir := DirAccess.open(parent)
if dir == null:
return PackedStringArray()
var matches := PackedStringArray()
for child in dir.get_directories():
if _wildcard_segment_matches(String(child), prefix, ending):
var matched_path := parent.path_join(String(child))
matches.append(matched_path if suffix.is_empty() else matched_path.path_join(suffix))
matches.sort()
return matches
## Substitute env vars and ~ in a single template string.
static func expand(template: String) -> String:
if template.is_empty():
return ""
var out := template
if out.begins_with("~/") or out == "~":
var home := _home()
out = home if out == "~" else home.path_join(out.substr(2))
# $HOME, $APPDATA, $LOCALAPPDATA, $USERPROFILE, $XDG_CONFIG_HOME
for var_name in ["XDG_CONFIG_HOME", "LOCALAPPDATA", "USERPROFILE", "APPDATA", "HOME"]:
var token := "$%s" % var_name
if out.find(token) >= 0:
var value := env_lookup(var_name)
if value.is_empty() and var_name == "XDG_CONFIG_HOME":
value = _home().path_join(".config")
if value.is_empty() and var_name == "APPDATA":
value = _home().path_join("AppData/Roaming")
if value.is_empty() and var_name == "LOCALAPPDATA":
value = _home().path_join("AppData/Local")
if value.is_empty() and var_name == "HOME":
value = _home()
out = out.replace(token, value)
return out
static func _os_key() -> String:
match OS.get_name():
"macOS":
return "darwin"
"Windows":
return "windows"
_:
return "linux"
static func _wildcard_segment_matches(value: String, prefix: String, ending: String) -> bool:
# Prefix/suffix tests alone allow the two fixed portions to overlap inside a
# too-short value. Glob semantics require room for both portions even when
# `*` matches an empty string.
if value.length() < prefix.length() + ending.length():
return false
if OS.get_name() == "Windows":
return value.to_lower().begins_with(prefix.to_lower()) and value.to_lower().ends_with(ending.to_lower())
return value.begins_with(prefix) and value.ends_with(ending)
static func _home() -> String:
var h := env_lookup("HOME")
if h.is_empty():
h = env_lookup("USERPROFILE")
return h
@@ -0,0 +1 @@
uid://5pd418va35ms
+151
View File
@@ -0,0 +1,151 @@
@tool
class_name McpClientRegistry
extends RefCounted
## Central enumeration of every supported MCP client. Adding a new client
## means: drop a file in clients/, then append one path below.
##
## Paths, not preloads (#736): a preload array pulled all client descriptor
## scripts into the boot-time compile closure of everything that preloads
## this registry (plugin.gd via client_configurator.gd and mcp_dock.gd),
## stalling "Initializing plugins" on every editor boot. Descriptors are
## only needed when the dock refreshes client statuses or a client_*
## command runs, so they load lazily on first registry access.
const _CLIENT_SCRIPT_PATHS := [
"res://addons/godot_ai/clients/claude_code.gd",
"res://addons/godot_ai/clients/claude_desktop.gd",
"res://addons/godot_ai/clients/codex.gd",
"res://addons/godot_ai/clients/grok.gd",
"res://addons/godot_ai/clients/antigravity.gd",
"res://addons/godot_ai/clients/cursor.gd",
"res://addons/godot_ai/clients/windsurf.gd",
"res://addons/godot_ai/clients/vscode.gd",
"res://addons/godot_ai/clients/vscode_insiders.gd",
"res://addons/godot_ai/clients/zed.gd",
"res://addons/godot_ai/clients/gemini_cli.gd",
"res://addons/godot_ai/clients/cline.gd",
"res://addons/godot_ai/clients/kilo_code.gd",
"res://addons/godot_ai/clients/roo_code.gd",
"res://addons/godot_ai/clients/zoo_code.gd",
"res://addons/godot_ai/clients/kiro.gd",
"res://addons/godot_ai/clients/trae.gd",
"res://addons/godot_ai/clients/cherry_studio.gd",
"res://addons/godot_ai/clients/opencode.gd",
"res://addons/godot_ai/clients/qwen_code.gd",
"res://addons/godot_ai/clients/kimi_code.gd",
"res://addons/godot_ai/clients/hermes.gd",
]
static var _instances: Array[McpClient] = []
static var _by_id: Dictionary = {}
## First registry access can come from the dock's client-status refresh
## worker thread while the main thread hits it via a client_* command —
## serialize the one-time load so a racing thread can never observe a
## half-built registry. load() itself is thread-safe via ResourceLoader.
static var _load_mutex := Mutex.new()
## True when even a fresh rebuild yields instances missing base-schema
## fields — the deep stale-script state after an in-session self-update
## (#850; docs/releasing.md release-shape rules). Only an editor restart
## heals it; callers surface RESTART_TO_FINISH_UPDATE instead of erroring
## per client. Never reset within a session: rebuilding again cannot help,
## it would only repeat the load work and warning on every dock sweep.
static var _stale_session := false
const RESTART_TO_FINISH_UPDATE := (
"Godot AI was updated in this editor session. Restart the editor to finish the update."
)
static func all() -> Array[McpClient]:
_ensure_loaded()
return _instances
static func get_by_id(id: String) -> McpClient:
_ensure_loaded()
return _by_id.get(id, null)
static func ids() -> PackedStringArray:
var out := PackedStringArray()
for c in all():
out.append(c.id)
return out
static func has_id(id: String) -> bool:
_ensure_loaded()
return _by_id.has(id)
## True when this editor session is running a self-update whose script
## reloads left descriptor state unusable. Client operations short-circuit
## with RESTART_TO_FINISH_UPDATE rather than spamming per-field errors.
static func stale_session_detected() -> bool:
_ensure_loaded()
return _stale_session
## An instance is coherent when fields added to the CURRENT McpClient schema
## read back with their declared types. After an in-session self-update,
## hot-patched or pre-update instances answer Nil for vars the update added
## (#850: `config_path_candidates` and
## `config_file_env` read as Nil, crashing platform_key / String()). The
## reflected `get()` avoids typed-access errors on such instances.
static func _instance_is_coherent(inst: Object) -> bool:
if inst == null:
return false
return (
inst.get("config_path_candidates") is Dictionary
and inst.get("config_file_env") is String
and inst.get("path_template") is Dictionary
)
static func _cache_is_coherent() -> bool:
return not _instances.is_empty() and _instance_is_coherent(_instances[0])
static func _ensure_loaded() -> void:
if _stale_session:
return
if _cache_is_coherent():
return
_load_mutex.lock()
## Re-check under the lock: another thread may have rebuilt (or concluded
## staleness) while this one waited.
if not _stale_session and not _cache_is_coherent():
## Covers both the first load and the post-self-update rebuild: this
## registry file can survive an update unchanged, so its statics keep
## serving pre-update instances to freshly reloaded callers. A rebuild
## instantiates from the reloaded descriptor scripts, which repairs
## every case except a stale base Script object itself.
_load()
if not _instances.is_empty() and not _cache_is_coherent():
_stale_session = true
push_warning("MCP | %s" % RESTART_TO_FINISH_UPDATE)
_load_mutex.unlock()
static func _load() -> void:
## Build into locals and publish whole containers last, so the lock-free
## fast path in _ensure_loaded can never see a partially-filled registry.
var instances: Array[McpClient] = []
var by_id: Dictionary = {}
for path in _CLIENT_SCRIPT_PATHS:
var script := load(path) as GDScript
if script == null:
push_warning("MCP | failed to load client descriptor %s" % path)
continue
var inst: McpClient = script.new()
if inst.id.is_empty():
push_warning("MCP | client descriptor %s has empty id" % path)
continue
if by_id.has(inst.id):
push_warning("MCP | duplicate client id: %s" % inst.id)
continue
instances.append(inst)
by_id[inst.id] = inst
_by_id = by_id
_instances = instances
+1
View File
@@ -0,0 +1 @@
uid://bxougoq8xwg1
+730
View File
@@ -0,0 +1,730 @@
@tool
class_name McpTomlStrategy
extends RefCounted
## TOML upsert for URL entries and client-owned command entries.
##
## This remains deliberately smaller than a general TOML parser, but the
## parts that affect migration are semantic: assignments retain their whole
## value span (including multiline arrays/strings), and command/args status
## verification decodes TOML strings and arrays rather than comparing text.
static func configure(
client: McpClient,
_server_name: String,
server_url: String,
launch: Dictionary = {},
) -> Dictionary:
var resolution := client.resolved_config_path_details()
var path := str(resolution.get("path", ""))
var path_error := str(resolution.get("error", ""))
if not path_error.is_empty():
return {"status": "error", "message": path_error}
if path.is_empty():
return {"status": "error", "message": "Could not resolve config path for %s" % client.display_name}
var seed_path := str(resolution.get("seed_path", ""))
var read_path := seed_path if not FileAccess.file_exists(path) and not seed_path.is_empty() else path
var read := _read_or_init(read_path)
if not read["ok"]:
return {"status": "error", "message": "Refusing to overwrite %s: %s. Fix or move the file, then re-run Configure." % [read_path, read["error"]]}
var rendered := render_body(client, server_url, launch)
if not bool(rendered.get("ok", false)):
return {"status": "error", "message": str(rendered.get("error", "Could not build the TOML entry."))}
var lines: Array[String] = _split_lines(String(read["data"]))
var body: Array[String] = rendered["lines"]
var pinned_keys: Dictionary = rendered["pinned_keys"]
var initial_keys: Dictionary = rendered["initial_keys"]
var removed_keys: Dictionary = rendered["removed_keys"]
var section := _find_section(lines, _all_headers(client))
var header := _primary_header(client)
var new_lines: Array[String] = [header]
if section.is_empty():
new_lines.append_array(body)
var output_fresh: Array[String] = []
output_fresh.append_array(lines)
if not output_fresh.is_empty() and not output_fresh[-1].strip_edges().is_empty():
output_fresh.append("")
output_fresh.append_array(new_lines)
if not McpAtomicWrite.write(path, "\n".join(output_fresh)):
return {"status": "error", "message": "Cannot write to %s" % path}
return {"status": "ok", "message": McpClient.configured_message(client, server_url)}
var old_items := _value_items(lines, int(section["start"]) + 1, int(section["end"]))
var old_by_key := {}
for item in old_items:
var old_key := str(item.get("key", ""))
if not old_key.is_empty() and not old_by_key.has(old_key):
old_by_key[old_key] = item
var body_items := _value_items(body, 0, body.size())
var emitted_keys := {}
for item in body_items:
var key := str(item.get("key", ""))
if key.is_empty():
new_lines.append_array(item["lines"])
continue
emitted_keys[key] = true
if initial_keys.has(key) and old_by_key.has(key):
new_lines.append_array(old_by_key[key]["lines"])
else:
## Pinned keys always use the freshly rendered span. A generated key
## that is neither pinned nor initial is also rendered deterministically.
new_lines.append_array(item["lines"])
## Carry unknown/user-owned assignments and standalone comments verbatim.
## Whole item spans prevent multiline arrays/strings from being truncated.
for item in old_items:
var key := str(item.get("key", ""))
if not key.is_empty():
if emitted_keys.has(key) or pinned_keys.has(key) or removed_keys.has(key):
continue
new_lines.append_array(item["lines"])
continue
var item_lines: Array = item.get("lines", [])
for line in item_lines:
if not str(line).strip_edges().is_empty():
new_lines.append(str(line))
var output: Array[String] = []
output.append_array(_slice(lines, 0, int(section["start"])))
output.append_array(new_lines)
output.append_array(_slice(lines, int(section["end"]), lines.size()))
output = _rewrite_legacy_descendant_headers(output, client)
if not McpAtomicWrite.write(path, "\n".join(output)):
return {"status": "error", "message": "Cannot write to %s" % path}
return {"status": "ok", "message": McpClient.configured_message(client, server_url)}
static func check_status(
client: McpClient,
server_name: String,
server_url: String,
launch: Dictionary = {},
) -> McpClient.Status:
return check_status_details(client, server_name, server_url, launch).get("status", McpClient.Status.NOT_CONFIGURED)
static func check_status_details(
client: McpClient,
_server_name: String,
server_url: String,
launch: Dictionary = {},
) -> Dictionary:
var resolution := client.resolved_config_path_details()
var path := str(resolution.get("path", ""))
var path_error := str(resolution.get("error", ""))
if not path_error.is_empty():
return {"status": McpClient.Status.ERROR, "error_msg": path_error}
if path.is_empty() or not FileAccess.file_exists(path):
return {"status": McpClient.Status.NOT_CONFIGURED, "error_msg": ""}
var read := _read_or_init(path)
if not read["ok"]:
return {"status": McpClient.Status.ERROR, "error_msg": String(read["error"])}
var lines: Array[String] = _split_lines(String(read["data"]))
var section := _find_section(lines, _all_headers(client))
if section.is_empty():
return {"status": McpClient.Status.NOT_CONFIGURED, "error_msg": ""}
var items := _value_items(lines, int(section["start"]) + 1, int(section["end"]))
var by_key := {}
for item in items:
var key := str(item.get("key", ""))
if not key.is_empty() and not by_key.has(key):
by_key[key] = item
if client.command_shape != McpClient.CommandShape.NONE:
if not bool(launch.get("ok", false)):
return {
"status": McpClient.Status.ERROR,
"error_msg": str(launch.get("error", "No compatible attach launcher was found.")),
}
for legacy_key in client.command_legacy_keys:
if by_key.has(String(legacy_key)):
return {"status": McpClient.Status.CONFIGURED_MISMATCH, "error_msg": ""}
if not by_key.has("command") or not by_key.has("args"):
return {"status": McpClient.Status.CONFIGURED_MISMATCH, "error_msg": ""}
var command_value := _decode_toml_string(_item_value(by_key["command"]))
var args_value := _decode_toml_string_array(_item_value(by_key["args"]))
if not bool(command_value.get("ok", false)) or not bool(args_value.get("ok", false)):
return {"status": McpClient.Status.CONFIGURED_MISMATCH, "error_msg": ""}
if str(command_value.get("value", "")) != str(launch.get("command", "")):
return {"status": McpClient.Status.CONFIGURED_MISMATCH, "error_msg": ""}
if not _string_arrays_equal(args_value.get("value", []), launch.get("args", [])):
return {"status": McpClient.Status.CONFIGURED_MISMATCH, "error_msg": ""}
if not client.command_transport_key.is_empty():
var transport_key := client.command_transport_key
if not by_key.has(transport_key):
return {"status": McpClient.Status.CONFIGURED_MISMATCH, "error_msg": ""}
var decoded_transport := _decode_toml_scalar(_item_value(by_key[transport_key]))
if not bool(decoded_transport.get("ok", false)) or decoded_transport.get("value") != client.command_transport_value:
return {"status": McpClient.Status.CONFIGURED_MISMATCH, "error_msg": ""}
return {"status": McpClient.Status.CONFIGURED, "error_msg": ""}
if not by_key.has("url"):
return {"status": McpClient.Status.CONFIGURED_MISMATCH, "error_msg": ""}
var url_value := _decode_toml_string(_item_value(by_key["url"]))
if not bool(url_value.get("ok", false)) or str(url_value.get("value", "")) != server_url:
return {"status": McpClient.Status.CONFIGURED_MISMATCH, "error_msg": ""}
return {"status": McpClient.Status.CONFIGURED, "error_msg": ""}
static func remove(client: McpClient, _server_name: String) -> Dictionary:
var resolution := client.resolved_config_path_details()
var path := str(resolution.get("path", ""))
var path_error := str(resolution.get("error", ""))
if not path_error.is_empty():
return {"status": "error", "message": path_error}
if path.is_empty() or not FileAccess.file_exists(path):
return {"status": "ok", "message": "Not configured"}
var read := _read_or_init(path)
if not read["ok"]:
return {"status": "error", "message": "Refusing to rewrite %s: %s." % [path, read["error"]]}
var lines: Array[String] = _split_lines(String(read["data"]))
var headers := _all_headers(client)
var subtable_prefixes := _subtable_prefixes(headers)
var output: Array[String] = []
var i := 0
while i < lines.size():
if _matches_any_header(lines[i], headers) or _matches_subtable_prefix(lines[i], subtable_prefixes):
i += 1
while i < lines.size() and not _is_any_section_header(lines[i]):
i += 1
continue
output.append(lines[i])
i += 1
if not McpAtomicWrite.write(path, "\n".join(output)):
return {"status": "error", "message": "Cannot write to %s" % path}
return {"status": "ok", "message": "%s configuration removed" % client.display_name}
## Substitute `{url}` in every legacy URL body-template line.
static func format_body(template: PackedStringArray, server_url: String) -> PackedStringArray:
var out := PackedStringArray()
for line in template:
out.append(String(line).replace("{url}", server_url))
return out
## Encode a TOML basic string. This is intentionally public for the
## cross-language fixture test that parses the rendered sample with tomllib.
static func encode_basic_string(value: String) -> String:
return '"%s"' % value.replace("\\", "\\\\").replace('"', '\\"').replace("\b", "\\b").replace("\t", "\\t").replace("\n", "\\n").replace("\f", "\\f").replace("\r", "\\r")
## Multi-line string-array encoding used by command-shape entries.
static func encode_string_array(values: Variant) -> Array[String]:
var out: Array[String] = ["["]
for value in values:
out.append(" %s," % encode_basic_string(str(value)))
out.append("]")
return out
static func render_body(client: McpClient, server_url: String, launch: Dictionary) -> Dictionary:
if client.command_shape == McpClient.CommandShape.NONE:
if client.toml_body_template.is_empty():
return {"ok": false, "error": "%s descriptor missing toml_body_template" % client.display_name}
var legacy_body := format_body(client.toml_body_template, server_url)
var legacy_lines: Array[String] = []
var pinned := {}
var initial := {}
for idx in range(legacy_body.size()):
legacy_lines.append(String(legacy_body[idx]))
var key := _line_key(String(client.toml_body_template[idx]))
if key.is_empty():
continue
if String(client.toml_body_template[idx]).contains("{url}"):
pinned[key] = true
else:
initial[key] = true
return {
"ok": true,
"lines": legacy_lines,
"pinned_keys": pinned,
"initial_keys": initial,
"removed_keys": {},
}
if client.command_shape != McpClient.CommandShape.COMMAND_ARRAY:
return {"ok": false, "error": "%s uses a command shape not supported by TOML yet" % client.display_name}
if not bool(launch.get("ok", false)):
return {"ok": false, "error": str(launch.get("error", "No compatible attach launcher was found."))}
var command_lines: Array[String] = [
"command = %s" % encode_basic_string(str(launch.get("command", ""))),
]
command_lines.append_array(_encode_assignment("args", launch.get("args", [])))
var pinned_keys := {"command": true, "args": true}
if not client.command_transport_key.is_empty():
var encoded_transport := _encode_scalar(client.command_transport_value)
if encoded_transport.is_empty():
return {
"ok": false,
"error": "Unsupported TOML transport `%s` for %s" % [
client.command_transport_key, client.display_name,
],
}
command_lines.append("%s = %s" % [client.command_transport_key, encoded_transport])
pinned_keys[client.command_transport_key] = true
var initial_keys := {}
for key in client.command_initial_fields:
var encoded := _encode_assignment(str(key), client.command_initial_fields[key])
if encoded.is_empty():
return {"ok": false, "error": "Unsupported TOML default `%s` for %s" % [key, client.display_name]}
command_lines.append_array(encoded)
initial_keys[str(key)] = true
var removed_keys := {}
for key in client.command_legacy_keys:
removed_keys[String(key)] = true
return {
"ok": true,
"lines": command_lines,
"pinned_keys": pinned_keys,
"initial_keys": initial_keys,
"removed_keys": removed_keys,
}
static func _encode_assignment(key: String, value: Variant) -> Array[String]:
if value is Array or value is PackedStringArray:
var lines := encode_string_array(value)
lines[0] = "%s = %s" % [key, lines[0]]
return lines
var encoded := _encode_scalar(value)
var lines: Array[String] = []
if not encoded.is_empty():
lines.append("%s = %s" % [key, encoded])
return lines
static func _encode_scalar(value: Variant) -> String:
if value is String:
return encode_basic_string(value)
if value is bool:
return "true" if value else "false"
if value is int or value is float:
return str(value)
return ""
# --- span-aware merge helpers -------------------------------------------
static func _value_items(lines: Array[String], from: int, to: int) -> Array[Dictionary]:
var out: Array[Dictionary] = []
var i := from
while i < to:
var key := _line_key(lines[i])
if key.is_empty():
out.append({"key": "", "lines": [lines[i]]})
i += 1
continue
var end := _value_span_end(lines, i, to)
out.append({"key": key, "lines": _slice(lines, i, end)})
i = end
return out
static func _value_span_end(lines: Array[String], start: int, limit: int) -> int:
var state := {"quote": "", "square": 0, "curly": 0, "escaped": false}
for i in range(start, limit):
var begin := 0
if i == start:
var eq := _assignment_equal(lines[i])
begin = eq + 1 if eq >= 0 else 0
_scan_toml_value_line(lines[i], begin, state)
if str(state["quote"]).is_empty() and int(state["square"]) == 0 and int(state["curly"]) == 0:
return i + 1
return limit
static func _scan_toml_value_line(line: String, begin: int, state: Dictionary) -> void:
var i := begin
while i < line.length():
var quote := str(state["quote"])
if quote == '"""' or quote == "'''":
if line.substr(i).begins_with(quote):
state["quote"] = ""
i += 3
continue
if quote == '"""' and line.unicode_at(i) == 92 and not bool(state["escaped"]):
state["escaped"] = true
i += 1
continue
state["escaped"] = false
i += 1
continue
if quote == '"' or quote == "'":
var c := line.unicode_at(i)
if quote == '"' and c == 92 and not bool(state["escaped"]):
state["escaped"] = true
i += 1
continue
if c == quote.unicode_at(0) and not bool(state["escaped"]):
state["quote"] = ""
state["escaped"] = false
i += 1
continue
if line.substr(i).begins_with('"""'):
state["quote"] = '"""'
i += 3
continue
if line.substr(i).begins_with("'''"):
state["quote"] = "'''"
i += 3
continue
var c := line.unicode_at(i)
if c == 34:
state["quote"] = '"'
elif c == 39:
state["quote"] = "'"
elif c == 35:
break
elif c == 91:
state["square"] = int(state["square"]) + 1
elif c == 93:
state["square"] = maxi(0, int(state["square"]) - 1)
elif c == 123:
state["curly"] = int(state["curly"]) + 1
elif c == 125:
state["curly"] = maxi(0, int(state["curly"]) - 1)
i += 1
static func _line_key(line: String) -> String:
var eq := _assignment_equal(line)
if eq <= 0:
return ""
return line.substr(0, eq).strip_edges()
static func _assignment_equal(line: String) -> int:
var quote := 0
var escaped := false
for i in range(line.length()):
var c := line.unicode_at(i)
if quote != 0:
if quote == 34 and c == 92 and not escaped:
escaped = true
continue
if c == quote and not escaped:
quote = 0
escaped = false
continue
if c == 34 or c == 39:
quote = c
elif c == 35:
return -1
elif c == 61:
return i
return -1
# --- semantic value decoding --------------------------------------------
static func _item_value(item: Dictionary) -> String:
var item_lines: Array = item.get("lines", [])
if item_lines.is_empty():
return ""
var first := str(item_lines[0])
var eq := _assignment_equal(first)
if eq < 0:
return ""
var parts: Array[String] = [first.substr(eq + 1)]
for i in range(1, item_lines.size()):
parts.append(str(item_lines[i]))
return "\n".join(parts)
static func _decode_toml_scalar(raw: String) -> Dictionary:
var cleaned := _without_comments(raw).strip_edges()
if cleaned == "true":
return {"ok": true, "value": true}
if cleaned == "false":
return {"ok": true, "value": false}
var string_value := _decode_toml_string(cleaned)
if bool(string_value.get("ok", false)):
return string_value
if cleaned.is_valid_int():
return {"ok": true, "value": cleaned.to_int()}
if cleaned.is_valid_float():
return {"ok": true, "value": cleaned.to_float()}
return {"ok": false}
static func _decode_toml_string(raw: String) -> Dictionary:
var cleaned := _without_comments(raw).strip_edges()
if cleaned.length() >= 2 and cleaned.begins_with("'") and cleaned.ends_with("'"):
return {"ok": true, "value": cleaned.substr(1, cleaned.length() - 2)}
if cleaned.length() < 2 or not cleaned.begins_with('"') or not cleaned.ends_with('"'):
return {"ok": false}
var parsed: Variant = JSON.parse_string(cleaned)
if parsed is String:
return {"ok": true, "value": parsed}
return {"ok": false}
static func _decode_toml_string_array(raw: String) -> Dictionary:
var cleaned := _without_comments(raw).strip_edges()
if not cleaned.begins_with("["):
return {"ok": false}
var i := 1
var values: Array[String] = []
while true:
i = _skip_space(cleaned, i)
if i >= cleaned.length():
return {"ok": false}
if cleaned.unicode_at(i) == 93:
i = _skip_space(cleaned, i + 1)
return {"ok": i == cleaned.length(), "value": values}
var parsed := _parse_string_at(cleaned, i)
if not bool(parsed.get("ok", false)):
return {"ok": false}
values.append(str(parsed.get("value", "")))
i = _skip_space(cleaned, int(parsed.get("next", i)))
if i >= cleaned.length():
return {"ok": false}
var c := cleaned.unicode_at(i)
if c == 44:
i += 1
continue
if c == 93:
continue
return {"ok": false}
return {"ok": false} # Unreachable; keeps GDScript's return analysis explicit.
static func _parse_string_at(text: String, start: int) -> Dictionary:
if start >= text.length():
return {"ok": false}
var quote := text.unicode_at(start)
if quote != 34 and quote != 39:
return {"ok": false}
var i := start + 1
var escaped := false
while i < text.length():
var c := text.unicode_at(i)
if quote == 34 and c == 92 and not escaped:
escaped = true
i += 1
continue
if c == quote and not escaped:
var raw := text.substr(start, i - start + 1)
if quote == 39:
return {"ok": true, "value": raw.substr(1, raw.length() - 2), "next": i + 1}
var parsed: Variant = JSON.parse_string(raw)
if parsed is String:
return {"ok": true, "value": parsed, "next": i + 1}
return {"ok": false}
escaped = false
i += 1
return {"ok": false}
static func _without_comments(raw: String) -> String:
var out: Array[String] = []
for line in raw.split("\n"):
var quote := 0
var escaped := false
var kept := ""
for i in range(line.length()):
var c := line.unicode_at(i)
if quote != 0:
kept += line.substr(i, 1)
if quote == 34 and c == 92 and not escaped:
escaped = true
continue
if c == quote and not escaped:
quote = 0
escaped = false
continue
if c == 34 or c == 39:
quote = c
kept += line.substr(i, 1)
elif c == 35:
break
else:
kept += line.substr(i, 1)
out.append(kept)
return "\n".join(out)
static func _skip_space(text: String, start: int) -> int:
var i := start
while i < text.length() and text.substr(i, 1) in [" ", "\t", "\r", "\n"]:
i += 1
return i
static func _string_arrays_equal(left: Variant, right: Variant) -> bool:
if not (left is Array or left is PackedStringArray):
return false
if not (right is Array or right is PackedStringArray):
return false
if left.size() != right.size():
return false
for i in range(left.size()):
if str(left[i]) != str(right[i]):
return false
return true
# --- file / section helpers ---------------------------------------------
static func _read_or_init(path: String) -> Dictionary:
if not FileAccess.file_exists(path):
return {"ok": true, "data": ""}
var f := FileAccess.open(path, FileAccess.READ)
if f == null:
var err := FileAccess.get_open_error()
return {"ok": false, "error": "could not open for reading (error %d)" % err}
var text := f.get_as_text()
f.close()
return {"ok": true, "data": text}
static func _split_lines(content: String) -> Array[String]:
var out: Array[String] = []
for line in content.split("\n"):
out.append(line)
return out
static func _slice(lines: Array[String], from: int, to: int) -> Array[String]:
var out: Array[String] = []
for i in range(from, to):
out.append(lines[i])
return out
static func _primary_header(client: McpClient) -> String:
var parts := client.toml_section_path
if parts.size() < 2:
return "[%s]" % ".".join(parts)
var section := ".".join(McpClient._packed_slice(parts, 0, parts.size() - 1))
var name := parts[parts.size() - 1]
return "[%s.\"%s\"]" % [section, name]
static func _all_headers(client: McpClient) -> Array[String]:
var primary := _primary_header(client)
var out: Array[String] = [primary]
var bare := _bare_key_header(client)
if not bare.is_empty() and bare != primary:
out.append(bare)
for legacy in client.toml_legacy_section_aliases:
out.append("[%s]" % legacy)
return out
static func _bare_key_header(client: McpClient) -> String:
var parts := client.toml_section_path
if parts.is_empty():
return ""
for part in parts:
if not _is_bare_key(String(part)):
return ""
return "[%s]" % ".".join(parts)
static func _is_bare_key(value: String) -> bool:
if value.is_empty():
return false
for i in range(value.length()):
var c := value.unicode_at(i)
var alpha := (c >= 65 and c <= 90) or (c >= 97 and c <= 122)
var digit := c >= 48 and c <= 57
if not (alpha or digit or c == 45 or c == 95):
return false
return true
static func _subtable_prefixes(headers: Array[String]) -> Array[String]:
var out: Array[String] = []
for header in headers:
if header.length() > 2 and header.ends_with("]"):
out.append(header.substr(0, header.length() - 1) + ".")
return out
static func _matches_subtable_prefix(line: String, prefixes: Array[String]) -> bool:
var trimmed := line.strip_edges()
for prefix in prefixes:
if not trimmed.begins_with(prefix):
continue
var rest := trimmed.substr(prefix.length())
var bracket := rest.find("]")
if bracket < 0:
continue
var remainder := rest.substr(bracket + 1).strip_edges()
if remainder.is_empty() or remainder.begins_with("#"):
return true
return false
static func _matches_any_header(line: String, headers: Array[String]) -> bool:
var trimmed := line.strip_edges()
for header in headers:
if not trimmed.begins_with(header):
continue
var remainder := trimmed.substr(header.length()).strip_edges()
if remainder.is_empty() or remainder.begins_with("#"):
return true
return false
static func _find_section(lines: Array[String], headers: Array[String]) -> Dictionary:
for i in range(lines.size()):
if _matches_any_header(lines[i], headers):
var end := lines.size()
for j in range(i + 1, lines.size()):
if _is_any_section_header(lines[j]):
end = j
break
return {"start": i, "end": end}
return {}
static func _is_any_section_header(line: String) -> bool:
var trimmed := line.strip_edges()
if not trimmed.begins_with("["):
return false
var bracket := trimmed.find("]")
if bracket < 0:
return false
var remainder := trimmed.substr(bracket + 1).strip_edges()
return remainder.is_empty() or remainder.begins_with("#")
static func _rewrite_legacy_descendant_headers(
lines: Array[String], client: McpClient
) -> Array[String]:
if client.toml_legacy_section_aliases.is_empty():
return lines
var primary := _primary_header(client)
var primary_prefix := primary.substr(0, primary.length() - 1) + "."
var out: Array[String] = []
for line in lines:
var rewritten := line
var trimmed := line.strip_edges()
var indent_length := line.find("[")
var indent := line.substr(0, indent_length) if indent_length >= 0 else ""
for alias in client.toml_legacy_section_aliases:
var legacy_prefix := "[%s." % String(alias)
if trimmed.begins_with(legacy_prefix):
rewritten = indent + primary_prefix + trimmed.substr(legacy_prefix.length())
break
out.append(rewritten)
return out
@@ -0,0 +1 @@
uid://cwdvxgn0aurqv
+544
View File
@@ -0,0 +1,544 @@
@tool
class_name McpYamlStrategy
extends RefCounted
## Minimal YAML upsert for Hermes Agent MCP config.
##
## Hermes reads MCP servers from ~/.hermes/config.yaml under the
## `mcp_servers` key (snake_case, YAML). HTTP entries are transport-inferred:
## just `url` (plus optional `headers`), no `type` field. We only parse the
## `mcp_servers` block and re-emit it; other top-level keys in the user's
## config.yaml are preserved verbatim by round-tripping the raw lines around
## that block. No general YAML parser — Godot has none in stdlib, and Hermes
## only needs this one shape. See issue #640.
const INDENT := " " # YAML forbids tab indentation; match the 2-space style of ~/.hermes/config.yaml
static func configure(
client: McpClient,
server_name: String,
server_url: String,
launch: Dictionary = {},
) -> Dictionary:
var resolution := client.resolved_config_path_details()
var path := str(resolution.get("path", ""))
var path_error := str(resolution.get("error", ""))
if not path_error.is_empty():
return {"status": "error", "message": path_error}
if path.is_empty():
return {"status": "error", "message": "Could not resolve config path for %s on this OS" % client.display_name}
## Fail closed before touching the file — same contract as JSON/TOML.
var launch_error := command_launch_error(client, launch)
if not launch_error.is_empty():
return {"status": "error", "message": launch_error}
var seed_path := str(resolution.get("seed_path", ""))
var read_path := seed_path if not FileAccess.file_exists(path) and not seed_path.is_empty() else path
var read := _read(read_path)
if not read["ok"]:
return {"status": "error", "message": "Refusing to overwrite %s: %s. Fix or move the file, then re-run Configure." % [read_path, read["error"]]}
var text: String = read["data"]
var block := _extract_block(text)
var entries: Dictionary = block["entries"]
# Preserve existing entry's user-mutable keys; force the transport keys.
var existing: Dictionary = entries.get(server_name, {})
var new_entry := build_entry(client, server_url, existing, launch)
entries[server_name] = new_entry
var out := _assemble(text, block["prefix_lines"], entries, block["suffix_lines"])
if not McpAtomicWrite.write(path, out):
return {"status": "error", "message": "Cannot write to %s" % path}
return {"status": "ok", "message": McpClient.configured_message(client, server_url)}
static func check_status(
client: McpClient, server_name: String, server_url: String, launch: Dictionary = {}
) -> McpClient.Status:
return check_status_details(client, server_name, server_url, launch)["status"]
## Same contract as the JSON/TOML strategies (#711): {status, error_msg}.
## An existing-but-unreadable config is ERROR with the diagnostic — not
## NOT_CONFIGURED — so the dock row can tell "no config" from "config the
## editor can't read" instead of offering a Configure that would fail.
static func check_status_details(
client: McpClient, server_name: String, server_url: String, launch: Dictionary = {}
) -> Dictionary:
var resolution := client.resolved_config_path_details()
var path := str(resolution.get("path", ""))
var path_error := str(resolution.get("error", ""))
if not path_error.is_empty():
return {"status": McpClient.Status.ERROR, "error_msg": path_error}
if path.is_empty() or not FileAccess.file_exists(path):
return {"status": McpClient.Status.NOT_CONFIGURED, "error_msg": ""}
var read := _read(path)
if not read["ok"]:
return {
"status": McpClient.Status.ERROR,
"error_msg": "Cannot read %s: %s" % [path, read["error"]],
}
var block := _extract_block(String(read["data"]))
var entries: Dictionary = block["entries"]
if not entries.has(server_name):
return {"status": McpClient.Status.NOT_CONFIGURED, "error_msg": ""}
var entry: Variant = entries[server_name]
if not (entry is Dictionary):
return {"status": McpClient.Status.NOT_CONFIGURED, "error_msg": ""}
## An entry exists but no verified launcher does — mirror JSON/TOML: this
## is an environment ERROR, not entry drift.
var launch_error := command_launch_error(client, launch)
if not launch_error.is_empty():
return {"status": McpClient.Status.ERROR, "error_msg": launch_error}
if verify_entry(client, entry, server_url, launch):
return {"status": McpClient.Status.CONFIGURED, "error_msg": ""}
return {"status": McpClient.Status.CONFIGURED_MISMATCH, "error_msg": ""}
static func remove(client: McpClient, server_name: String) -> Dictionary:
var resolution := client.resolved_config_path_details()
var path := str(resolution.get("path", ""))
var path_error := str(resolution.get("error", ""))
if not path_error.is_empty():
return {"status": "error", "message": path_error}
if path.is_empty() or not FileAccess.file_exists(path):
return {"status": "ok", "message": "Not configured"}
var read := _read(path)
if not read["ok"]:
return {"status": "error", "message": "Refusing to rewrite %s: %s." % [path, read["error"]]}
var text: String = read["data"]
var block := _extract_block(text)
var entries: Dictionary = block["entries"]
if not entries.has(server_name):
return {"status": "ok", "message": "%s configuration removed" % client.display_name}
entries.erase(server_name)
var out := _assemble(text, block["prefix_lines"], entries, block["suffix_lines"])
if not McpAtomicWrite.write(path, out):
return {"status": "error", "message": "Cannot write to %s" % path}
return {"status": "ok", "message": "%s configuration removed" % client.display_name}
## Build the entry dict written under mcp_servers[server_name].
##
## URL mode (`CommandShape.NONE`): Hermes HTTP entries are transport-inferred
## — { url: <url> } plus whatever user-mutable keys (headers, enabled, tools,
## ...) the existing entry carries. Stdio-bridge keys are the one exception
## (see _STDIO_BRIDGE_KEYS). No `type` field.
##
## Command mode (`CommandShape.FLAT`, #838): flat `command` + `args` keys,
## transport inferred the same way — which is exactly why the descriptor's
## `command_legacy_keys` (url, headers) must be scrubbed: a Hermes entry with
## both a url and a command picks the wrong transport. User keys survive.
static func build_entry(
client: McpClient,
server_url: String,
existing: Variant = null,
launch: Dictionary = {},
) -> Dictionary:
if client.command_shape == McpClient.CommandShape.FLAT:
var command_entry: Dictionary = (existing as Dictionary).duplicate(true) if existing is Dictionary else {}
command_entry["command"] = str(launch.get("command", ""))
command_entry["args"] = _array_copy(launch.get("args", []))
if not client.command_transport_key.is_empty():
command_entry[client.command_transport_key] = client.command_transport_value
for key in client.command_initial_fields:
if not command_entry.has(key):
command_entry[key] = client.command_initial_fields[key]
for key in client.command_legacy_keys:
command_entry.erase(String(key))
return command_entry
if client.command_shape != McpClient.CommandShape.NONE:
## Every production caller checks command_launch_error first. Keep this
## builder defensive too so a future unsupported shape cannot silently
## degrade into a flat YAML entry.
return {}
var entry: Dictionary = {}
if existing is Dictionary:
## User-mutable keys (headers, enabled, tools, ...) survive a
## reconfigure — the same preservation contract the JSON strategy's
## entry_initial_fields split implements. Only the stdio-bridge keys
## are scrubbed (see _STDIO_BRIDGE_KEYS), then the url is repointed.
entry = (existing as Dictionary).duplicate(true)
for stale_key in _STDIO_BRIDGE_KEYS:
entry.erase(stale_key)
entry[client.entry_url_field] = server_url
return entry
## Keys a prior stdio-bridge entry (e.g. `command: uvx mcp-proxy`) may carry.
## These must NOT survive a URL reconfigure: a Hermes entry with both a url
## and a command picks the wrong transport.
const _STDIO_BRIDGE_KEYS := ["command", "args", "env"]
## Empty string when this client's launch requirements are satisfied.
## Mirrors `McpJsonStrategy.command_launch_error`; YAML supports FLAT only.
static func command_launch_error(client: McpClient, launch: Dictionary) -> String:
if client.command_shape == McpClient.CommandShape.NONE:
return ""
if client.command_shape != McpClient.CommandShape.FLAT:
return "%s uses a command shape not supported by YAML yet" % client.display_name
if not bool(launch.get("ok", false)):
return str(launch.get("error", "No compatible attach launcher was found."))
return ""
## Verify a stored entry matches.
##
## URL mode: Hermes entries have no transport type pin, so verification is:
## url matches. Extra keys (headers, enabled, tools) are user-mutable and
## intentionally NOT checked (mirrors json entry_initial_fields).
##
## Command mode: every launch-affecting value must match exactly and legacy
## URL-transport keys must be gone — their presence is migration drift.
static func verify_entry(
client: McpClient,
entry: Dictionary,
server_url: String,
launch: Dictionary = {},
) -> bool:
if client.command_shape != McpClient.CommandShape.NONE:
if client.command_shape != McpClient.CommandShape.FLAT or not bool(launch.get("ok", false)):
return false
for key in client.command_legacy_keys:
if entry.has(String(key)):
return false
if entry.get("command") != launch.get("command"):
return false
if not _arrays_equal(entry.get("args", null), launch.get("args", null)):
return false
if not client.command_transport_key.is_empty():
if entry.get(client.command_transport_key, null) != client.command_transport_value:
return false
return true
return entry.get(client.entry_url_field, "") == server_url
static func _array_copy(value: Variant) -> Array:
if value is Array:
return (value as Array).duplicate(true)
if value is PackedStringArray:
return McpClient._array_from_packed(value)
return []
static func _arrays_equal(left: Variant, right: Variant) -> bool:
if not (left is Array or left is PackedStringArray):
return false
if not (right is Array or right is PackedStringArray):
return false
var left_array := _array_copy(left)
var right_array := _array_copy(right)
if left_array.size() != right_array.size():
return false
for i in range(left_array.size()):
if left_array[i] != right_array[i]:
return false
return true
# --- YAML block handling (scoped to mcp_servers) -------------------------
## Parse the file into three regions:
## prefix_lines — everything before `mcp_servers:` (may be empty)
## entries — the map of server_name -> {url, ...} under mcp_servers
## suffix_lines — everything after the mcp_servers block (may be empty)
## This lets us rewrite only the mcp_servers block and keep the rest of the
## user's config.yaml byte-for-byte intact.
static func _extract_block(text: String) -> Dictionary:
## allow_empty must stay true: dropping empty splits would silently strip
## the user's blank lines from the preserved prefix/suffix regions on
## every rewrite. The parse loops below already skip blank lines.
var lines := text.split("\n")
var prefix: PackedStringArray = []
var entries: Dictionary = {}
var suffix: PackedStringArray = []
var header_idx := -1
for i in range(lines.size()):
if lines[i].strip_edges().begins_with("mcp_servers:"):
header_idx = i
break
if header_idx < 0:
# No mcp_servers yet — whole file is prefix; block will be appended.
prefix = lines.duplicate()
return {"prefix_lines": prefix, "entries": entries, "suffix_lines": [], "header_idx": -1}
for i in range(0, header_idx):
prefix.append(lines[i])
# Determine the indent of the first entry so we can tell sibling
# entries (same indent) apart from nested keys (deeper indent) and
# parent-level keys (less indent). All server entries under
# `mcp_servers:` share one indent level; breaking on 0-indent alone
# mis-nests 2-space-indented siblings under the first entry.
var entry_indent := -1
var probe := header_idx + 1
while probe < lines.size() and _is_blank_or_comment(lines[probe]):
probe += 1
if probe < lines.size():
entry_indent = _indent_of(lines[probe])
# Empty block guard: the first nonblank line after the header must sit
# DEEPER than the header itself to be an entry. At or above the header's
# indent it is a sibling/parent key — parsing it as an entry would
# swallow the user's next top-level key and re-emit it nested under
# mcp_servers, corrupting the file.
if probe < lines.size() and entry_indent <= _indent_of(lines[header_idx]):
for j in range(header_idx + 1, lines.size()):
suffix.append(lines[j])
return {"prefix_lines": prefix, "entries": entries, "suffix_lines": suffix, "header_idx": header_idx}
var i := header_idx + 1
while i < lines.size():
var raw := lines[i]
## Comment-only lines inside the block are skipped like blanks —
## treating one as an entry header would re-emit it as a bogus
## `# comment:` server on rewrite. (Comments INSIDE the rewritten
## block are consequently dropped; comments outside the block live
## in prefix/suffix and survive verbatim.)
if _is_blank_or_comment(raw):
i += 1
continue
# Stop at any line indented less than a sibling entry (parent key
# or a new top-level section), or at the header's own level.
if _indent_of(raw) < entry_indent:
break
var entry := _parse_entry(raw, lines, i, entry_indent)
if not entry["name"].is_empty():
entries[entry["name"]] = entry["data"]
i = entry["next_idx"]
for j in range(i, lines.size()):
suffix.append(lines[j])
return {"prefix_lines": prefix, "entries": entries, "suffix_lines": suffix, "header_idx": header_idx}
## Parse one ` name:` entry starting at `lines[start]`. Consumes all deeper-
## indented sublines (url, headers, etc.) and returns the next sibling index.
static func _parse_entry(raw: String, lines: PackedStringArray, start: int, entry_indent: int) -> Dictionary:
var name := raw.strip_edges().trim_suffix(":").strip_edges()
var data: Dictionary = {}
var i := start + 1
while i < lines.size():
var l := lines[i]
## Comments inside an entry (e.g. ` # auth for CI`) would parse
## as a `# auth for CI` key — skip them like blanks.
if _is_blank_or_comment(l):
i += 1
continue
# A line at or above the entry's indent is a sibling/parent key.
if _indent_of(l) <= entry_indent:
break
var stripped := l.strip_edges()
var colon := stripped.find(":")
if colon < 0:
i += 1
continue
var key := stripped.substr(0, colon).strip_edges()
var val := stripped.substr(colon + 1).strip_edges()
if val.is_empty():
# Nested block (e.g. headers:). Parse as raw sub-dict lines for
# preservation; we don't introspect deeper than url at the top.
var sub := _parse_subblock(lines, i + 1, entry_indent)
data[key] = sub["value"]
i = sub["next_idx"]
else:
data[key] = _coerce_scalar(val)
i += 1
return {"name": name, "data": data, "next_idx": i}
## Parse a nested block (e.g. headers:) as a preserved sub-dictionary of
## scalar key/values. Deeper nesting is flattened into scalar strings — fine
## for Hermes' known shape (headers are flat key: value).
static func _parse_subblock(lines: PackedStringArray, start: int, entry_indent: int) -> Dictionary:
var sub: Dictionary = {}
var i := start
while i < lines.size():
var l := lines[i]
if _is_blank_or_comment(l):
i += 1
continue
# A line at or above the parent entry's indent ends the nested block.
if _indent_of(l) <= entry_indent:
break
var stripped := l.strip_edges()
var colon := stripped.find(":")
if colon < 0:
i += 1
continue
var key := stripped.substr(0, colon).strip_edges()
var val := stripped.substr(colon + 1).strip_edges()
if val.is_empty():
i += 1
continue
sub[key] = _coerce_scalar(val)
i += 1
return {"value": sub, "next_idx": i}
## Reassemble the full file text from prefix + a freshly built mcp_servers
## block + suffix. If the block didn't exist before, it is appended.
static func _assemble(_text: String, prefix: PackedStringArray, entries: Dictionary, suffix: PackedStringArray) -> String:
var out: PackedStringArray = []
for l in prefix:
out.append(l)
# Trim trailing blank lines from prefix so we don't stack double blanks.
while out.size() > 0 and out[out.size() - 1].strip_edges().is_empty():
out.remove_at(out.size() - 1)
if not _text.contains("mcp_servers:"):
# File existed but had no mcp_servers block — append it.
if out.size() > 0:
out.append("")
out.append("mcp_servers:")
for name in entries:
out.append_array(_emit_entry(name, entries[name]))
else:
out.append("mcp_servers:")
for name in entries:
out.append_array(_emit_entry(name, entries[name]))
# Suffix: keep as-is.
for l in suffix:
out.append(l)
return "\n".join(out)
## Public rendering seam for the dock's manual-instruction text, so the
## pasted YAML matches what Configure would write byte-for-byte.
static func render_entry_lines(name: String, data: Dictionary) -> PackedStringArray:
return _emit_entry(name, data)
## Emit one ` name:` entry with its scalar keys (top level only; headers
## sub-dict is re-emitted as nested scalars; arrays — the command entry's
## `args` — are emitted in flow style on one line).
static func _emit_entry(name: String, data: Dictionary) -> PackedStringArray:
var lines: PackedStringArray = []
lines.append(INDENT + "%s:" % name)
for key in data:
var val = data[key]
if val is Dictionary:
lines.append(INDENT + INDENT + "%s:" % key)
for sk in val:
lines.append(INDENT + INDENT + INDENT + "%s: %s" % [sk, _emit_scalar(val[sk])])
elif val is Array or val is PackedStringArray:
lines.append(INDENT + INDENT + "%s: %s" % [key, _emit_flow_array(_array_copy(val))])
else:
lines.append(INDENT + INDENT + "%s: %s" % [key, _emit_scalar(val)])
return lines
## Flow-style sequence with every item double-quoted. JSON string quoting is
## valid YAML double-quote style (shared escape set), so the same encoding
## both writes the file and — via JSON.parse_string in `_coerce_scalar` —
## reads it back for verification.
static func _emit_flow_array(values: Array) -> String:
var parts: Array[String] = []
for v in values:
parts.append(JSON.stringify(str(v)))
return "[%s]" % ", ".join(parts)
static func _emit_scalar(v: Variant) -> String:
match typeof(v):
TYPE_BOOL:
return "true" if bool(v) else "false"
TYPE_INT:
return str(int(v))
TYPE_FLOAT:
return str(float(v))
_:
return _emit_string_scalar(str(v))
## Plain YAML scalars cannot safely carry ": ", " #", quotes, flow
## indicators, or leading indicator characters — a Windows launcher path with
## spaces would silently corrupt the entry. Quote exactly when needed so
## existing plain values (urls, bools-as-strings) keep their current
## byte-shape on rewrite.
static func _emit_string_scalar(s: String) -> String:
if s.is_empty():
return "\"\""
var needs_quote := s.begins_with(" ") or s.ends_with(" ")
if not needs_quote:
for needle in [": ", " #", "\"", "'", "\n", "\t", "{", "}", "[", "]", ","]:
if s.contains(needle):
needs_quote = true
break
if not needs_quote:
for prefix in ["#", "-", "?", "&", "*", "!", "|", ">", "%", "@", "`"]:
if s.begins_with(prefix):
needs_quote = true
break
return JSON.stringify(s) if needs_quote else s
## Blank and comment-only lines carry no structure — every scan loop skips
## them the same way so a `# comment` can never be mistaken for an entry
## header or a key/value line.
static func _is_blank_or_comment(line: String) -> bool:
var stripped := line.strip_edges()
return stripped.is_empty() or stripped.begins_with("#")
## Returns the leading-whitespace indent width of a line (spaces + tabs
## counted as 1 each). Used to distinguish sibling entries (same indent)
## from nested keys (deeper indent) and parent-level keys (less indent).
static func _indent_of(line: String) -> int:
var n := 0
while n < line.length() and (line[n] == " " or line[n] == "\t"):
n += 1
return n
## Minimal scalar coercion for parsed YAML values. Quotes are stripped;
## bare true/false/numbers are typed; double-quoted flow sequences (the
## command entry's `args`) parse back into an Array. Good enough for Hermes'
## url/headers/command/args. A hand-edited args in block style or with
## unquoted items doesn't JSON-parse — it stays a raw string, compares
## unequal, and surfaces as CONFIGURED_MISMATCH, which Reconfigure
## normalizes back to the flow form.
static func _coerce_scalar(s: String) -> Variant:
var t := s.strip_edges()
if t.begins_with("[") and t.ends_with("]"):
var parsed_array: Variant = JSON.parse_string(t)
if parsed_array is Array:
return parsed_array
return t
if t.begins_with("\"") and t.ends_with("\""):
var parsed_string: Variant = JSON.parse_string(t)
if parsed_string is String:
return parsed_string
return t.substr(1, t.length() - 2)
if t.begins_with("'") and t.ends_with("'"):
return t.substr(1, t.length() - 2)
if t == "true":
return true
if t == "false":
return false
if t.is_valid_int():
return t.to_int()
if t.is_valid_float():
return t.to_float()
return t
## Returns {"ok": true, "data": String} when the file is absent or readable,
## and {"ok": false, "error": String} when unreadable. Callers must NOT fall
## back to an empty string on the error path — doing so blows away the user's
## other config.yaml entries on the next write.
static func _read(path: String) -> Dictionary:
if not FileAccess.file_exists(path):
return {"ok": true, "data": ""}
var f := FileAccess.open(path, FileAccess.READ)
if f == null:
var err := FileAccess.get_open_error()
return {"ok": false, "error": "could not open for reading (%s)" % error_string(err)}
var t := f.get_as_text()
f.close()
return {"ok": true, "data": t}
@@ -0,0 +1 @@
uid://cfnw4oe71ra1l
+39
View File
@@ -0,0 +1,39 @@
@tool
extends McpClient
func _init() -> void:
id = "antigravity"
display_name = "Antigravity"
config_type = "json"
## Antigravity moved its shared MCP config from `~/.gemini/antigravity/`
## to `~/.gemini/config/` (IDE + CLI now read the same file there); the
## old path is left in `detect_paths` below so an existing install is
## still recognized, but new/updated entries write to the current path.
path_template = {
"unix": "~/.gemini/config/mcp_config.json",
"windows": "$USERPROFILE/.gemini/config/mcp_config.json",
}
server_key_path = PackedStringArray(["mcpServers"])
entry_url_field = "serverUrl"
## `disabled` is user-state (they may have flipped the entry off in the
## UI); seeded on first Configure but preserved across reconfigure.
entry_initial_fields = {"disabled": false}
## Attach migration (#838). Antigravity stdio entries are flat
## command/args/env with no type discriminator — transport is inferred
## from `command` vs `serverUrl` presence (antigravity.google/docs/mcp),
## so the legacy `serverUrl` must not survive next to a command.
command_shape = McpClient.CommandShape.FLAT
command_legacy_keys = PackedStringArray(["serverUrl"])
command_initial_fields = {"disabled": false}
command_user_fields = PackedStringArray(["disabled", "disabledTools", "authProviderType", "env"])
command_supports_url_fallback = true
## Antigravity's spawner hangs stdio tool calls when the entry launches a
## GUI-subsystem pythonw.exe (#863), and it hides child console windows
## itself, so the visible-terminal problem the bootstrap solves (#827)
## never applies. Write the plain console launcher on Windows.
needs_consoleless_launcher = false
detect_paths = PackedStringArray(path_template.values() + [
"~/.gemini/antigravity/mcp_config.json",
"$USERPROFILE/.gemini/antigravity/mcp_config.json",
])
@@ -0,0 +1 @@
uid://b4l1g0apa2hch
+18
View File
@@ -0,0 +1,18 @@
@tool
extends McpClient
func _init() -> void:
id = "cherry_studio"
display_name = "Cherry Studio"
config_type = "json"
path_template = {
"darwin": "~/Library/Application Support/CherryStudio/mcp_servers.json",
"windows": "$APPDATA/CherryStudio/mcp_servers.json",
"linux": "$XDG_CONFIG_HOME/CherryStudio/mcp_servers.json",
}
server_key_path = PackedStringArray(["mcpServers"])
entry_extra_fields = {"type": "streamableHttp"}
## `isActive` is user-state (they may have toggled the server off in the UI).
## Seed on first Configure but preserve across reconfigure.
entry_initial_fields = {"isActive": true}
@@ -0,0 +1 @@
uid://dwbuykxvbv5f7
+53
View File
@@ -0,0 +1,53 @@
@tool
extends McpClient
func _init() -> void:
id = "claude_code"
display_name = "Claude Code"
config_type = "cli"
cli_names = PackedStringArray(["claude", "claude.exe"] if OS.get_name() == "Windows" else ["claude"])
## Stdio registration through the client-owned `godot-ai attach` bridge
## (#838). `--` stops claude's own flag parsing so the attach argv passes
## through verbatim; stdio is the CLI's default transport. Scope stays
## `user` — the same ~/.claude.json the pre-attach HTTP entry lived in.
cli_register_template = PackedStringArray(
["mcp", "add", "--scope", "user", "{name}", "--", "{command}", "{args...}"]
)
## Explicit scope: an unscoped `mcp remove` deletes from whichever scope
## matches first, which could eat a project-local entry the user made.
cli_unregister_template = PackedStringArray(["mcp", "remove", "--scope", "user", "{name}"])
cli_status_args = PackedStringArray(["mcp", "list"])
## #463: JSON fallback for when the `claude` binary isn't on PATH — e.g.
## Claude Code installed only as a VS Code / Cursor extension. The CLI is
## still preferred for Configure whenever it resolves; this is what gets
## written otherwise. `claude mcp add --scope user <name> -- <cmd> <args>`
## produces exactly this shape under `mcpServers` in ~/.claude.json
## (verified live against claude CLI in an isolated CLAUDE_CONFIG_DIR):
## "godot-ai": { "type": "stdio", "command": "<cmd>", "args": [...], "env": {} }
## The fallback writer omits the empty `env`; the verifier accepts both.
## Status always reads this file — it is the CLI's own store for user
## scope, and file reads give exact launch-drift detection that `mcp list`
## stdout scanning cannot.
path_template = {"unix": "~/.claude.json", "windows": "~/.claude.json"}
server_key_path = PackedStringArray(["mcpServers"])
## URL-mode shape, used only for the manual-instruction fallback text —
## `claude mcp add --scope user --transport http` writes {type: http, url}.
entry_extra_fields = {"type": "http"}
command_shape = McpClient.CommandShape.FLAT
command_transport_key = "type"
command_transport_value = "stdio"
## Legacy HTTP entries carried a `url`; Claude Code rejects an entry mixing
## url with command fields, and the stale `type: "http"` is repinned to
## "stdio" by the transport key above.
command_legacy_keys = PackedStringArray(["url"])
command_user_fields = PackedStringArray(["env"])
command_supports_url_fallback = true
## Documented: $CLAUDE_CONFIG_DIR relocates Claude Code's config home,
## including .claude.json ($CLAUDE_CONFIG_DIR/.claude.json). The preferred
## CLI path needs no help — the spawned `claude` binary inherits the
## editor's environment and resolves the dir itself — but the JSON
## fallback above would otherwise write ~/.claude.json that a relocated
## install never reads (#617).
config_home_env = "CLAUDE_CONFIG_DIR"
config_home_env_subpath = ".claude.json"
@@ -0,0 +1 @@
uid://cp1u1hdpa6f8d
+38
View File
@@ -0,0 +1,38 @@
@tool
extends McpClient
## Claude Desktop's mcpServers entries launch a local stdio process. The
## client-owned `godot-ai attach` bridge keeps that stdio session stable while
## adopting or starting the shared HTTP backend as Godot editors come and go.
func _init() -> void:
id = "claude_desktop"
display_name = "Claude Desktop"
config_type = "json"
path_template = {
"darwin": "~/Library/Application Support/Claude/claude_desktop_config.json",
"windows": "$APPDATA/Claude/claude_desktop_config.json",
"linux": "$XDG_CONFIG_HOME/Claude/claude_desktop_config.json",
}
## Store-installed Claude runs inside MSIX AppData virtualization. Godot is
## outside that container, so `%APPDATA%` names a different physical file
## once Claude has created its private copy. A unique Store package root is
## authoritative even before the config leaf exists: create the private file
## directly so a later copy-on-write cannot hide an entry written to roaming.
## With no Store package, use the conventional roaming path. The wildcard
## avoids coupling to the publisher-hash suffix.
config_path_candidates = {
"windows": [
"$LOCALAPPDATA/Packages/Claude_*/LocalCache/Roaming/Claude/claude_desktop_config.json",
"$APPDATA/Claude/claude_desktop_config.json",
],
}
detect_paths = PackedStringArray([
"$LOCALAPPDATA/Packages/Claude_*",
])
server_key_path = PackedStringArray(["mcpServers"])
command_shape = McpClient.CommandShape.FLAT
command_legacy_keys = PackedStringArray(["url"])
command_env_legacy_keys = PackedStringArray(["UV_LINK_MODE"])
command_user_fields = PackedStringArray(["env", "disabled"])
@@ -0,0 +1 @@
uid://bilntn5n8oqe3
+43
View File
@@ -0,0 +1,43 @@
@tool
extends McpClient
## Cline is a VS Code extension. Its MCP settings live in VS Code's
## globalStorage under the extension id `saoudrizwan.claude-dev`.
func _init() -> void:
id = "cline"
display_name = "Cline"
config_type = "json"
path_template = {
"darwin": "~/Library/Application Support/Code/User/globalStorage/saoudrizwan.claude-dev/settings/cline_mcp_settings.json",
"windows": "$APPDATA/Code/User/globalStorage/saoudrizwan.claude-dev/settings/cline_mcp_settings.json",
"linux": "$XDG_CONFIG_HOME/Code/User/globalStorage/saoudrizwan.claude-dev/settings/cline_mcp_settings.json",
}
server_key_path = PackedStringArray(["mcpServers"])
## Cline (like Roo) defaults a typeless entry to SSE transport, which
## returns HTTP 400 against our streamable-http endpoint on `/mcp`. Pin
## the type explicitly. Cline's schema uses "streamableHttp" (camelCase,
## see src/services/mcp/schemas.ts in the cline repo) — distinct from
## Roo's "streamable-http" string. Parallel to the Roo fix in #190.
entry_extra_fields = {"type": "streamableHttp"}
## `disabled` and `autoApprove` are user-state (they may have flipped the
## entry off, or auto-approved specific tools). Seed on first Configure
## but preserve across reconfigure — see `entry_initial_fields` in `_base.gd`.
entry_initial_fields = {"disabled": false, "autoApprove": []}
## Attach migration (#838). Cline stdio entries are flat command/args/env;
## its schema accepts `type: "stdio"` and normalizes typeless command
## entries to it (apps/vscode/src/services/mcp/schemas.ts), so pin the
## type — that also repins the legacy "streamableHttp" value instead of
## letting it survive the deep-copy and misroute the transport.
command_shape = McpClient.CommandShape.FLAT
command_transport_key = "type"
command_transport_value = "stdio"
command_legacy_keys = PackedStringArray(["url", "headers"])
command_initial_fields = {"disabled": false, "autoApprove": []}
command_user_fields = PackedStringArray([
"disabled", "autoApprove", "timeout", "oauth", "metadata",
"remoteConfigured", "env", "cwd",
])
command_timeout_fields = PackedStringArray(["timeout"])
command_supports_url_fallback = true
+1
View File
@@ -0,0 +1 @@
uid://d36nywn2nkgts
+45
View File
@@ -0,0 +1,45 @@
@tool
extends McpClient
func _init() -> void:
id = "codex"
display_name = "Codex"
config_type = "toml"
path_template = {"unix": "~/.codex/config.toml", "windows": "$USERPROFILE/.codex/config.toml"}
## Documented: when $CODEX_HOME is set, Codex reads config.toml directly
## from it instead of ~/.codex (#617).
config_home_env = "CODEX_HOME"
config_home_env_subpath = "config.toml"
toml_section_path = PackedStringArray(["mcp_servers", "godot-ai"])
# Older Codex builds used the unquoted form with underscore-substituted ids.
toml_legacy_section_aliases = PackedStringArray(["mcp_servers.godot_ai"])
command_shape = McpClient.CommandShape.COMMAND_ARRAY
command_supports_url_fallback = true
command_legacy_keys = PackedStringArray(["url"])
## Initial-only: users may disable the entry or tune either timeout and
## Configure preserves that choice. Codex currently defaults to 10s for
## startup and 60s per tool; test_run legitimately has a 300s server
## budget, so the generated config leaves transport margin at the client.
command_initial_fields = {
"enabled": true,
"startup_timeout_sec": 60,
"tool_timeout_sec": 360,
}
command_timeout_fields = PackedStringArray([
"startup_timeout_sec",
"tool_timeout_sec",
])
command_user_fields = PackedStringArray([
"enabled",
"required",
"startup_timeout_sec",
"tool_timeout_sec",
"enabled_tools",
"disabled_tools",
"default_tools_approval_mode",
"env",
"env_vars",
"cwd",
])
detect_paths = PackedStringArray(path_template.values())
+1
View File
@@ -0,0 +1 @@
uid://hdlwcfdr8mdk
+21
View File
@@ -0,0 +1,21 @@
@tool
extends McpClient
func _init() -> void:
id = "cursor"
display_name = "Cursor"
config_type = "json"
path_template = {"unix": "~/.cursor/mcp.json", "windows": "$USERPROFILE/.cursor/mcp.json"}
server_key_path = PackedStringArray(["mcpServers"])
## Attach migration (#838). Cursor's stdio entries are flat command/args/env
## (cursor.com/docs/context/mcp). The docs' reference table documents
## `type: "stdio"`; pinning it also repins any hand-added `type: "http"`
## left on the legacy URL entry, which would otherwise survive the
## deep-copy migration and misroute the transport.
command_shape = McpClient.CommandShape.FLAT
command_transport_key = "type"
command_transport_value = "stdio"
command_legacy_keys = PackedStringArray(["url"])
command_user_fields = PackedStringArray(["env", "envFile"])
command_supports_url_fallback = true
+1
View File
@@ -0,0 +1 @@
uid://bvpbssfanukef
+25
View File
@@ -0,0 +1,25 @@
@tool
extends McpClient
func _init() -> void:
id = "gemini_cli"
display_name = "Gemini CLI"
config_type = "json"
path_template = {
"unix": "~/.gemini/settings.json",
"windows": "$USERPROFILE/.gemini/settings.json",
}
server_key_path = PackedStringArray(["mcpServers"])
entry_url_field = "httpUrl"
## Attach migration (#838). Gemini CLI stdio entries are flat
## command/args/env(+cwd); the config is one-of `command` | `url` (SSE) |
## `httpUrl` (docs/tools/mcp-server.md), so BOTH URL keys are legacy next
## to a command. `trust` bypasses tool confirmations — never seed it.
command_shape = McpClient.CommandShape.FLAT
command_legacy_keys = PackedStringArray(["httpUrl", "url"])
command_user_fields = PackedStringArray([
"timeout", "trust", "includeTools", "excludeTools", "env", "cwd",
])
command_timeout_fields = PackedStringArray(["timeout"])
command_supports_url_fallback = true
@@ -0,0 +1 @@
uid://b8288pxninajy
+35
View File
@@ -0,0 +1,35 @@
@tool
extends McpClient
func _init() -> void:
id = "grok"
display_name = "Grok Build"
config_type = "toml"
# Grok Build reads MCP servers from ~/.grok/config.toml
# (https://x.ai / Grok user guide: MCP servers section).
path_template = {
"unix": "~/.grok/config.toml",
"windows": "$USERPROFILE/.grok/config.toml",
}
toml_section_path = PackedStringArray(["mcp_servers", "godot-ai"])
# Some docs / older notes used an underscore form.
toml_legacy_section_aliases = PackedStringArray(["mcp_servers.godot_ai"])
## Attach migration (#838). Grok's stdio sections are flat command/args/env
## with no type discriminator (docs.x.ai/build/features/mcp-servers);
## url/headers are the HTTP form and must not survive next to a command.
## Docs default startup_timeout_sec to 30 — a cold `uvx` install of the
## pinned package can exceed that, so new entries seed 60 (preserved once
## the user tunes it). tool_timeout_sec's documented 6000s default already
## clears test_run's 300s budget, so it is left alone. The old body
## template's `enabled = true` line was never documented for Grok — it is
## no longer seeded, and an existing value survives as a user key.
command_shape = McpClient.CommandShape.COMMAND_ARRAY
command_legacy_keys = PackedStringArray(["url", "headers"])
command_initial_fields = {"startup_timeout_sec": 60}
command_user_fields = PackedStringArray([
"env", "enabled", "startup_timeout_sec", "tool_timeout_sec",
])
command_timeout_fields = PackedStringArray(["startup_timeout_sec", "tool_timeout_sec"])
command_supports_url_fallback = true
detect_paths = PackedStringArray(path_template.values())
+1
View File
@@ -0,0 +1 @@
uid://ckchsj5s3q1b0
+46
View File
@@ -0,0 +1,46 @@
@tool
extends McpClient
func _init() -> void:
id = "hermes"
display_name = "Hermes Agent"
config_type = "yaml"
# Hermes reads MCP config from ~/.hermes/config.yaml (YAML), NOT mcp.json.
# Verified against the official docs:
# https://hermes-agent.nousresearch.com/docs/user-guide/features/mcp
# Windows: Hermes stores config under $LOCALAPPDATA/hermes (NOT $APPDATA,
# which is Roaming) — confirmed by where the running Hermes process reads.
# NOTE: _path_template.expand() only substitutes $VAR tokens, not %VAR%.
path_template = {
"unix": "~/.hermes/config.yaml",
"windows": "$LOCALAPPDATA/hermes/config.yaml"
}
# Hermes uses the snake_case `mcp_servers` key (not `mcpServers`).
# PackedStringArray explicitly, matching every other descriptor — an
# untyped Array literal relies on implicit conversion that newer Godot
# builds enforce more strictly (the #722 CI lesson for Array[String]).
server_key_path = PackedStringArray(["mcp_servers"])
# HTTP entries use `url` (+ optional `headers`); transport is inferred —
# there is no `type` field in Hermes MCP config.
entry_url_field = "url"
# No transport pin: Hermes infers streamable-http from the URL.
entry_extra_fields = {}
entry_initial_fields = {}
## Attach migration (#838). Hermes stdio entries are flat command/args/env
## (hermes-agent.nousresearch.com/docs/user-guide/features/mcp), transport
## inferred exactly like the URL form — which is why `url` and the
## HTTP-only `headers` must not survive next to a command: an entry with
## both picks the wrong transport. `enabled`/`tools`/`env` stay user-owned.
command_shape = McpClient.CommandShape.FLAT
command_legacy_keys = PackedStringArray(["url", "headers"])
command_user_fields = PackedStringArray(["enabled", "tools", "env"])
command_supports_url_fallback = true
# Hermes is "installed" wherever the config.yaml lives; presence of the
# file is sufficient for the dock's installed badge.
detect_paths = PackedStringArray()
+1
View File
@@ -0,0 +1 @@
uid://ewmadhrvs5d7
+38
View File
@@ -0,0 +1,38 @@
@tool
extends McpClient
func _init() -> void:
id = "kilo_code"
display_name = "Kilo Code"
config_type = "json"
path_template = {
"darwin": "~/Library/Application Support/Code/User/globalStorage/kilocode.kilo-code/settings/mcp_settings.json",
"windows": "$APPDATA/Code/User/globalStorage/kilocode.kilo-code/settings/mcp_settings.json",
"linux": "$XDG_CONFIG_HOME/Code/User/globalStorage/kilocode.kilo-code/settings/mcp_settings.json",
}
server_key_path = PackedStringArray(["mcpServers"])
## Kilo Code (like Roo) defaults a typeless entry to SSE transport, which
## returns HTTP 400 against our streamable-http endpoint on `/mcp`. Pin
## the type explicitly. Parallel to the Roo fix in #190.
entry_extra_fields = {"type": "streamable-http"}
## `disabled` and `alwaysAllow` are user-state (they may have flipped the
## entry off, or auto-approved specific tools). Seed on first Configure
## but preserve across reconfigure — see `entry_initial_fields` in `_base.gd`.
entry_initial_fields = {"disabled": false, "alwaysAllow": []}
## Attach migration (#838). UNLIKE its Roo siblings the stdio entry must be
## TYPELESS: Kilo's v7 platform treats this file as a migration source and
## routes on `type` — any http type value sends the entry down the remote
## branch where it is dropped for lack of a url, and only a bare
## command/args/env entry is verified to work in BOTH the legacy extension
## and the v7 migrator (packages/opencode/src/kilocode/mcp-migrator.ts).
## `type` therefore joins the legacy keys instead of being repinned.
command_shape = McpClient.CommandShape.FLAT
command_legacy_keys = PackedStringArray(["url", "type", "headers"])
command_initial_fields = {"disabled": false, "alwaysAllow": []}
command_user_fields = PackedStringArray([
"disabled", "alwaysAllow", "timeout", "cwd", "watchPaths",
"disabledTools", "env",
])
command_timeout_fields = PackedStringArray(["timeout"])
command_supports_url_fallback = true
+1
View File
@@ -0,0 +1 @@
uid://dc1x77i1cmb6w
+34
View File
@@ -0,0 +1,34 @@
@tool
extends McpClient
func _init() -> void:
id = "kimi_code"
display_name = "Kimi Code"
## Kimi Code has no `mcp` CLI subcommand (verified against v0.28.1 —
## `kimi mcp` falls through to the root --help, and the docs at
## moonshotai.github.io/kimi-code/en/customization/mcp confirm servers are
## managed via ~/.kimi-code/mcp.json, not a CLI verb). JSON is therefore
## the only working config method, not a fallback.
config_type = "json"
path_template = {"unix": "~/.kimi-code/mcp.json", "windows": "~/.kimi-code/mcp.json"}
server_key_path = PackedStringArray(["mcpServers"])
entry_extra_fields = {"transport": "http"}
## Documented: `$KIMI_CODE_HOME/mcp.json` relocates the config
## (moonshotai.github.io/kimi-code/en/customization/mcp) — same
## false-success-write class as CODEX_HOME (#617).
config_home_env = "KIMI_CODE_HOME"
config_home_env_subpath = "mcp.json"
## Attach migration (#838). Kimi Code stdio entries are flat
## command/args/env(+cwd): "Entries with a `command` field are stdio
## servers". `transport` is only defined for SSE-with-url, so the legacy
## `transport: "http"` must be removed alongside `url`. Timeout fields are
## camelCase, unlike Codex's snake_case.
command_shape = McpClient.CommandShape.FLAT
command_legacy_keys = PackedStringArray(["url", "transport"])
command_user_fields = PackedStringArray([
"enabled", "startupTimeoutMs", "toolTimeoutMs", "enabledTools",
"disabledTools", "env", "cwd",
])
command_timeout_fields = PackedStringArray(["startupTimeoutMs", "toolTimeoutMs"])
command_supports_url_fallback = true
+1
View File
@@ -0,0 +1 @@
uid://d2whd6a5fofhg
+23
View File
@@ -0,0 +1,23 @@
@tool
extends McpClient
func _init() -> void:
id = "kiro"
display_name = "Kiro"
config_type = "json"
path_template = {
"unix": "~/.kiro/settings/mcp.json",
"windows": "$USERPROFILE/.kiro/settings/mcp.json",
}
server_key_path = PackedStringArray(["mcpServers"])
## `disabled` is user-state — preserved across reconfigure.
entry_initial_fields = {"disabled": false}
## Attach migration (#838). Kiro stdio entries are flat command/args/env
## with no type discriminator (kiro.dev/docs/mcp/configuration).
## `autoApprove` is user-state, same contract as the URL entry's fields.
command_shape = McpClient.CommandShape.FLAT
command_legacy_keys = PackedStringArray(["url"])
command_initial_fields = {"disabled": false}
command_user_fields = PackedStringArray(["disabled", "autoApprove", "disabledTools", "env"])
command_supports_url_fallback = true
+1
View File
@@ -0,0 +1 @@
uid://dqdmd2jw5qen7
+39
View File
@@ -0,0 +1,39 @@
@tool
extends McpClient
## OpenCode stores MCP servers under `mcp.<name>` (not the typical mcpServers
## map) and uses `type: "remote"` for HTTP servers.
func _init() -> void:
id = "opencode"
display_name = "OpenCode"
config_type = "json"
## `$HOME` on Windows is deliberate: OpenCode reads ~/.config/... on ALL
## platforms (verified via `opencode debug paths`), and
## McpPathTemplate._home() falls back to USERPROFILE when HOME is unset —
## pinned by test_opencode_client_uses_home_config_on_windows. The documented
## `OPENCODE_CONFIG` override names an exact file and must win over this
## default for configure, status, remove, and manual instructions.
path_template = {
"unix": "~/.config/opencode/opencode.json",
"windows": "$HOME/.config/opencode/opencode.json",
}
config_file_env = "OPENCODE_CONFIG"
server_key_path = PackedStringArray(["mcp"])
entry_extra_fields = {"type": "remote"}
## `enabled` is user-state (they may have toggled the server off).
entry_initial_fields = {"enabled": true}
## Attach migration (#838). OpenCode local entries carry the launch as ONE
## argv array — `"command": ["uvx", …]` with no separate args key — plus a
## schema-REQUIRED `type: "local"` (McpLocalConfig in opencode.ai/config.json;
## env lives under `environment`, not `env`). The pin rewrites the legacy
## `type: "remote"` in place; url/headers are remote-only and must go.
command_shape = McpClient.CommandShape.COMMAND_ARRAY
command_transport_key = "type"
command_transport_value = "local"
command_legacy_keys = PackedStringArray(["url", "headers"])
command_initial_fields = {"enabled": true}
command_user_fields = PackedStringArray(["enabled", "timeout", "environment", "cwd"])
command_timeout_fields = PackedStringArray(["timeout"])
command_supports_url_fallback = true
+1
View File
@@ -0,0 +1 @@
uid://s8n0vfirf2pj
+26
View File
@@ -0,0 +1,26 @@
@tool
extends McpClient
func _init() -> void:
id = "qwen_code"
display_name = "Qwen Code"
config_type = "json"
path_template = {
"unix": "~/.qwen/settings.json",
"windows": "$USERPROFILE/.qwen/settings.json",
}
server_key_path = PackedStringArray(["mcpServers"])
entry_url_field = "httpUrl"
## Attach migration (#838). Qwen Code is a gemini-cli fork with the same
## flat stdio shape and one-of `command` | `url` | `httpUrl` rule
## (docs/users/features/mcp.md). Qwen adds `discoveryTimeoutMs` (stdio
## discovery handshake cap, default 30s).
command_shape = McpClient.CommandShape.FLAT
command_legacy_keys = PackedStringArray(["httpUrl", "url"])
command_user_fields = PackedStringArray([
"timeout", "trust", "includeTools", "excludeTools", "env", "cwd",
"discoveryTimeoutMs",
])
command_timeout_fields = PackedStringArray(["timeout", "discoveryTimeoutMs"])
command_supports_url_fallback = true
+1
View File
@@ -0,0 +1 @@
uid://qwb5udkf423q
+42
View File
@@ -0,0 +1,42 @@
@tool
extends McpClient
func _init() -> void:
id = "roo_code"
display_name = "Roo Code"
config_type = "json"
path_template = {
"darwin": "~/Library/Application Support/Code/User/globalStorage/rooveterinaryinc.roo-cline/settings/mcp_settings.json",
"windows": "$APPDATA/Code/User/globalStorage/rooveterinaryinc.roo-cline/settings/mcp_settings.json",
"linux": "$XDG_CONFIG_HOME/Code/User/globalStorage/rooveterinaryinc.roo-cline/settings/mcp_settings.json",
}
server_key_path = PackedStringArray(["mcpServers"])
## Roo defaults an entry with no "type" to SSE transport — which returns
## HTTP 400 against our streamable-http endpoint on `/mcp`. Pin the type
## explicitly so Roo negotiates streamable-http (the current MCP spec's
## recommended remote transport). See issue #189. The default verifier
## requires every entry_extra_fields key to match, so a pre-#189 typeless
## entry surfaces as drift instead of silently passing as configured.
entry_extra_fields = {"type": "streamable-http"}
## `disabled` and `alwaysAllow` are user-state (they may have flipped the
## entry off, or auto-approved specific tools like `session_manage`).
## Seed on first Configure but preserve across reconfigure — without this
## split, the Configure-All-Mismatched sweep silently wipes the user's
## auto-approval list every time the type pin or URL drifts.
entry_initial_fields = {"disabled": false, "alwaysAllow": []}
## Attach migration (#838). Roo stdio entries are flat command/args/env
## (+cwd); docs state `type` defaults to "stdio" for command configs and
## the stdio schema forbids url/headers. Pin type=stdio — it is documented
## and repins the legacy "streamable-http" value in place.
command_shape = McpClient.CommandShape.FLAT
command_transport_key = "type"
command_transport_value = "stdio"
command_legacy_keys = PackedStringArray(["url", "headers"])
command_initial_fields = {"disabled": false, "alwaysAllow": []}
command_user_fields = PackedStringArray([
"disabled", "alwaysAllow", "timeout", "disabledTools", "watchPaths",
"env", "cwd",
])
command_timeout_fields = PackedStringArray(["timeout"])
command_supports_url_fallback = true
+1
View File
@@ -0,0 +1 @@
uid://denjdf50qrf66
+23
View File
@@ -0,0 +1,23 @@
@tool
extends McpClient
func _init() -> void:
id = "trae"
display_name = "Trae"
config_type = "json"
path_template = {
"darwin": "~/Library/Application Support/Trae/User/mcp.json",
"windows": "$APPDATA/Trae/User/mcp.json",
"linux": "$XDG_CONFIG_HOME/Trae/User/mcp.json",
}
server_key_path = PackedStringArray(["mcpServers"])
## Attach migration (#838). Trae stdio entries are flat command/args/env
## with no type discriminator (docs.trae.cn/ide/add-mcp-servers); transport
## is inferred from `command` vs `url`, so url/headers are legacy next to a
## command. Entry stays minimal — Trae manages enable/disable in its UI,
## and its startup/run timeouts ride user-owned env vars, which survive.
command_shape = McpClient.CommandShape.FLAT
command_legacy_keys = PackedStringArray(["url", "headers"])
command_user_fields = PackedStringArray(["env"])
command_supports_url_fallback = true
+1
View File
@@ -0,0 +1 @@
uid://cwpu48772vfj1
+30
View File
@@ -0,0 +1,30 @@
@tool
extends McpClient
## VS Code (stable) reads MCP servers from per-user mcp.json under
## `servers.<name>` with `{ "type": "http", "url": ... }`.
func _init() -> void:
id = "vscode"
display_name = "VS Code"
config_type = "json"
path_template = {
"darwin": "~/Library/Application Support/Code/User/mcp.json",
"windows": "$APPDATA/Code/User/mcp.json",
"linux": "$XDG_CONFIG_HOME/Code/User/mcp.json",
}
server_key_path = PackedStringArray(["servers"])
entry_extra_fields = {"type": "http"}
## Attach migration (#838). VS Code stdio entries are flat command/args/env
## under `servers` with a documented `type: "stdio"` discriminator
## (code.visualstudio.com/docs/agents/reference/mcp-configuration). The
## stdio schema is `additionalProperties: false` (mcpConfiguration.ts), so
## removing the legacy url/headers is load-bearing — leftovers invalidate
## the whole entry, and the pin flips the legacy `type: "http"` in place.
command_shape = McpClient.CommandShape.FLAT
command_transport_key = "type"
command_transport_value = "stdio"
command_legacy_keys = PackedStringArray(["url", "headers"])
command_user_fields = PackedStringArray(["env", "envFile", "cwd", "sandboxEnabled", "dev"])
command_supports_url_fallback = true
+1
View File
@@ -0,0 +1 @@
uid://dl6cm044pihub
@@ -0,0 +1,23 @@
@tool
extends McpClient
func _init() -> void:
id = "vscode_insiders"
display_name = "VS Code Insiders"
config_type = "json"
path_template = {
"darwin": "~/Library/Application Support/Code - Insiders/User/mcp.json",
"windows": "$APPDATA/Code - Insiders/User/mcp.json",
"linux": "$XDG_CONFIG_HOME/Code - Insiders/User/mcp.json",
}
server_key_path = PackedStringArray(["servers"])
entry_extra_fields = {"type": "http"}
## Attach migration (#838). Identical format to vscode.gd — Insiders ships
## the same mcpConfiguration.ts schema; see that descriptor for citations.
command_shape = McpClient.CommandShape.FLAT
command_transport_key = "type"
command_transport_value = "stdio"
command_legacy_keys = PackedStringArray(["url", "headers"])
command_user_fields = PackedStringArray(["env", "envFile", "cwd", "sandboxEnabled", "dev"])
command_supports_url_fallback = true
@@ -0,0 +1 @@
uid://cad5w4ofyg8a2
+29
View File
@@ -0,0 +1,29 @@
@tool
extends McpClient
func _init() -> void:
# #623: Windsurf was rebranded to Devin Desktop by Cognition (June 2026).
# The id stays "windsurf" — it is the stable registry key used for
# configured-status lookups. The MCP config path is unchanged by the
# rebrand: per the official docs (docs.devin.ai/desktop/cascade/mcp) the
# global config still lives under the platform's `.codeium/windsurf/`
# directory (~/.codeium/windsurf/ on unix, $USERPROFILE/.codeium/windsurf/
# on Windows), and migrated installs carry their settings over in place.
id = "windsurf"
display_name = "Devin Desktop (Windsurf)"
config_type = "json"
path_template = {
"unix": "~/.codeium/windsurf/mcp_config.json",
"windows": "$USERPROFILE/.codeium/windsurf/mcp_config.json",
}
server_key_path = PackedStringArray(["mcpServers"])
entry_url_field = "serverUrl"
## Attach migration (#838). Stdio entries are flat command/args/env
## (docs.devin.ai/desktop/cascade/mcp); transport is inferred from
## `command` vs `serverUrl` presence and no type field is documented —
## do not write one.
command_shape = McpClient.CommandShape.FLAT
command_legacy_keys = PackedStringArray(["serverUrl"])
command_user_fields = PackedStringArray(["env"])
command_supports_url_fallback = true
+1
View File
@@ -0,0 +1 @@
uid://b6pqiok2mlsmg
+29
View File
@@ -0,0 +1,29 @@
@tool
extends McpClient
## Zed registers MCP servers under `context_servers.<name>` and supports both
## stdio and streamable http transports.
func _init() -> void:
id = "zed"
display_name = "Zed"
config_type = "json"
path_template = {
"darwin": "~/.config/zed/settings.json",
"linux": "$XDG_CONFIG_HOME/zed/settings.json",
"windows": "$APPDATA/Zed/settings.json",
}
server_key_path = PackedStringArray(["context_servers"])
## Attach migration (#838). Current Zed's context_servers entries are an
## untagged serde enum discriminated by shape: `command` (string) + args/env
## → stdio, `url` → HTTP (zed.dev/docs/ai/mcp; settings_content/project.rs).
## BECAUSE the enum is untagged, HTTP-only keys left next to `command`
## (`url`, `headers`, `oauth`) make the entry match no variant and break it —
## removing them on migration is load-bearing, not cosmetic. `enabled`,
## `remote`, and `timeout` are user-state on the stdio variant.
command_shape = McpClient.CommandShape.FLAT
command_legacy_keys = PackedStringArray(["url", "headers", "oauth"])
command_user_fields = PackedStringArray(["enabled", "remote", "timeout", "env"])
command_timeout_fields = PackedStringArray(["timeout"])
command_supports_url_fallback = true
+1
View File
@@ -0,0 +1 @@
uid://d152l0u0r6fsc
+36
View File
@@ -0,0 +1,36 @@
@tool
extends McpClient
func _init() -> void:
id = "zoo_code"
display_name = "Zoo Code"
config_type = "json"
path_template = {
"darwin": "~/Library/Application Support/Code/User/globalStorage/zoocodeorganization.zoo-code/settings/mcp_settings.json",
"windows": "$APPDATA/Code/User/globalStorage/zoocodeorganization.zoo-code/settings/mcp_settings.json",
"linux": "$XDG_CONFIG_HOME/Code/User/globalStorage/zoocodeorganization.zoo-code/settings/mcp_settings.json",
}
server_key_path = PackedStringArray(["mcpServers"])
## Local validation against the installed extension shows Zoo stores MCP
## entries in `settings/mcp_settings.json` under `mcpServers`, matching Roo's
## shape. Its changelog also references Streamable HTTP support, so pin the
## transport explicitly to avoid any typeless entry falling back to SSE.
entry_extra_fields = {"type": "streamable-http"}
## Preserve user-controlled state across reconfigure, parallel to Roo/Kilo.
entry_initial_fields = {"disabled": false, "alwaysAllow": []}
## Attach migration (#838). Zoo's stdio zod schema is Roo's: flat
## command/args/env(+cwd), `type: z.enum(["stdio"]).optional()`, and
## url/headers explicitly forbidden on stdio entries (McpHub.ts) — so both
## are legacy keys and the documented type pin repins "streamable-http".
command_shape = McpClient.CommandShape.FLAT
command_transport_key = "type"
command_transport_value = "stdio"
command_legacy_keys = PackedStringArray(["url", "headers"])
command_initial_fields = {"disabled": false, "alwaysAllow": []}
command_user_fields = PackedStringArray([
"disabled", "alwaysAllow", "timeout", "watchPaths", "disabledTools",
"env", "cwd",
])
command_timeout_fields = PackedStringArray(["timeout"])
command_supports_url_fallback = true
+1
View File
@@ -0,0 +1 @@
uid://fmp0nlzcm3ukl