@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: )" 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 `/` ## 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