@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" | "dsh" ## False when the client's settings format cannot be round-tripped safely by ## the matching strategy. Status checks remain read-only; Configure and Remove ## return the existing manual instructions without touching the file. var automatic_config_edits: bool = true ## True when the config file is JSONC (Zed ships a `//` header). Honored by the ## JSON strategy's reads only — comments are stripped from a throwaway parse ## copy, so the row shows a real status instead of a permanent parse error ## (#914). Requires `automatic_config_edits = false`; the strategy ignores it ## otherwise rather than let a re-serializing Configure drop the comments. var config_allows_comments: bool = false # 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 create target is the ## non-wildcard template whose own directories already exist, because the ## client has run from there (`_evident_create_path` gives the order), ## and otherwise the first non-wildcard template. This is what sends a ## Flatpak-only IDE's entry to `~/.var/app//config/...` instead of a ## `~/.config` it never reads. ## 3. Multiple matches within any wildcard group are ambiguous and fail ## closed instead of choosing an arbitrary package. ## 4. A template inside another Flatpak app's `~/.var/app/` is never a ## create target while this editor's own Flatpak sandbox hides that ## directory (`McpPathTemplate.hidden_flatpak_app_dir`). When rule 2 then ## finds no existing directory among the rest, the resolution carries ## `create_error` and the unseen `hidden_paths`: status still reads the ## first template, and Configure refuses rather than create a file the ## client may never read. ## ## Exact-file and config-home environment overrides still have higher ## priority. When this map has no entry for the current platform, ## `path_template` remains the fallback. var config_path_candidates: Dictionary = {} ## Optional global JSON config files merged by the client. Unlike ## `config_path_candidates`, every existing file participates. By default the ## client keeps the LAST definition: Configure updates the effective last ## definition, status verifies it, and Remove clears every global definition ## transactionally. Clients that keep the FIRST definition instead set ## `config_merge_first_wins` and declare tiers in the client's read order; ## the folds then target the first tier defining the server, so a write ## updates the file that is already effective and a fresh entry lands on ## tiers[0] — the client's own primary file. var config_merge_path_templates: Dictionary = {} ## Project-relative tiers that may override the global files. Their root is the ## external client's working directory, which the Godot process cannot know. ## The strategy checks plausible roots and fails closed instead of mutating them. var config_merge_project_paths: PackedStringArray = PackedStringArray() ## True when the client keeps the FIRST definition of a duplicated server and ## ignores later ones (omp-native: project `.omp/mcp.json` precedes ## `.omp/.mcp.json`, then the user scope's `mcp.json` precedes `.mcp.json`). ## Flips every merge-tier fold — Configure target, status verification, and ## the manual/Open-Reveal target resolution — from last-definition-wins to ## first. With tiers declared in read order, Configure updates the tier that ## already owns the server instead of creating a higher-priority entry that ## shadows a lower tier's user state (#1085). var config_merge_first_wins: bool = false ## Directory-glob templates whose matches make the user-scope config ## destination unknowable from the editor (omp: `~/.omp/profiles/*`). One `*` ## per directory segment, expanded by `McpPathTemplate.expand_path_candidates`. ## Any existing match means a named profile may own the user scope: the active ## profile is chosen per client launch and never persisted where the editor can ## read it, so Configure and Remove fail closed with the matched paths and ## status reports the ambiguity instead of green-lighting the default file. var config_scope_globs: PackedStringArray = PackedStringArray() ## Environment names that make the effective config destination unknown. ## Configure, Remove and status refuse while any is set; values are not reported. var config_scope_envs: PackedStringArray = PackedStringArray() ## Top-level server-name denylist in the first declared merge tier (the default ## primary file), independent of which tier owns the entry. Configure and status ## refuse a suppressed name; Remove preserves this user state. Empty disables it. var config_denylist_key: String = "" ## Effective-entry enable toggle and primary force-enable list. Boolean false ## and strings "false"/"0" suppress the entry unless the allowlist names it. ## The denylist still wins. Empty keys disable these checks. var config_enabled_key: String = "" var config_allowlist_key: String = "" ## De-duplicate persistent path-ambiguity warnings across recurring status ## refreshes. The actionable message still returns on every resolution; only ## the editor-console echo is single-shot until the ambiguity clears/changes. var _last_config_path_warning := "" var _config_path_warning_mutex := Mutex.new() ## Path inside the config object where the per-server map lives. ## Cursor / Claude Desktop / most others: ["mcpServers"] ## VS Code: ["servers"] ## OpenCode: ["mcp"] var server_key_path: PackedStringArray = PackedStringArray() ## Alternative server-map paths accepted by the client, in precedence order ## after `server_key_path`. JSON strategy operations select the first non-null ## existing path so Configure, status, and Remove agree with the client parser. var server_key_path_aliases: Array[PackedStringArray] = [] ## Field inside the entry dict that holds our server URL. ## "url" by default; some clients use "serverUrl" or "httpUrl". var entry_url_field: String = "url" ## Required entry fields — written on every Configure AND verified by the ## default verifier. Use this for transport pins (e.g. `type: ## "streamable-http"`) where a missing/wrong value breaks negotiation: a ## legacy entry without the pin fails verification and surfaces as drift. ## ## DO NOT put user-mutable state here (auto-approval lists, `disabled` ## flags, opt-in toggles). Verifying those treats every user customisation ## as drift, and Configure-All-Mismatched then silently overwrites them ## back to defaults — see the `entry_initial_fields` doc below. var entry_extra_fields: Dictionary = {} ## Default fields written ONLY when the entry doesn't yet exist. Reconfigure ## preserves whatever the user (or the client itself) has set; the verifier ## ignores these keys entirely. Use for opt-in flags and user-state arrays — ## e.g. Roo / Cline / Kilo `alwaysAllow` / `autoApprove` lists, `disabled: ## false`, `isActive: true`. The pre-#229 behaviour was equivalent: per- ## client `entry_builder` lambdas seeded these as defaults but the ## per-client `verify_entry` lambdas only checked transport pins, so a ## user-customised array was `CONFIGURED`, not drift. Splitting the field ## restores that contract under the data-only descriptor model. var entry_initial_fields: Dictionary = {} ## Client-owned stdio launch shape. Each strategy renders the shape in its ## config language: ## ## - FLAT — `command` string + `args` array as sibling keys. JSON and YAML ## strategies. A client whose docs require a type discriminator next to the ## flat keys (VS Code's `type: "stdio"`, Claude Code's fallback file) stays ## FLAT and declares it via `command_transport_key` / `command_transport_value`, ## so TYPED_FLAT remains reserved vocabulary. ## - COMMAND_ARRAY — the launch argv carried as one array. In the JSON ## strategy the entry's `command` field IS that array (OpenCode's ## `"command": ["uvx", …]`). In the TOML strategy the launcher renders as a ## `command = "…"` line plus an `args = […]` array (Codex, Grok) — the name ## refers to the argv-as-TOML-array body it emits. ## - NESTED_COMMAND — command/args nested inside a sub-object. Reserved; no ## current client needs it and strategies reject it with an actionable error. ## ## CLI-registered clients (`config_type == "cli"`) express the launch through ## `cli_register_template` tokens instead; their `command_shape` governs the ## JSON-fallback file rendering (Claude Code, #463). ## ## Values are data-only shared vocabulary; keeping them data-only avoids ## reintroducing the descriptor Callable race from #229. enum CommandShape { NONE, FLAT, TYPED_FLAT, COMMAND_ARRAY, NESTED_COMMAND } var command_shape: CommandShape = CommandShape.NONE ## 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. ## Relative values fail closed for the same reason `config_file_env`'s do. var config_home_env: String = "" ## Path of the config file relative to the env var's directory, e.g. ## "config.toml". Joined verbatim — no per-OS variants needed because the env ## value itself is already an absolute (or ~-prefixed) directory. var config_home_env_subpath: String = "" # CLI clients -------------------------------------------------------------- var cli_names: PackedStringArray = PackedStringArray() ## Argument templates with `{name}` and `{url}` tokens; the strategy ## substitutes them at call time. Tokens are matched verbatim — no escaping ## semantics, no shell expansion. Command-shape templates additionally use the ## whole-element tokens `{command}` / `{args...}` (see `McpCliStrategy.format_args`). ## Populated by CLI descriptors (currently `claude_code`; `kimi_code` moved to ## mcp.json in #813). var cli_register_template: PackedStringArray = PackedStringArray() var cli_unregister_template: PackedStringArray = PackedStringArray() ## Args run to read current state; stdout is scanned for the server name and ## URL. Presence of `name` AND `url` → CONFIGURED, name only → MISMATCH, ## neither → NOT_CONFIGURED. var cli_status_args: PackedStringArray = PackedStringArray() ## Optional scope-aware status template with a `{name}` token, for descriptors ## that also take `{scope}` in their register template. `cli_status_args` lists ## whatever the CLI resolves for the current directory and cannot say WHICH ## scope an entry came from, so a leftover user-scope entry reads as CONFIGURED ## while the selected project scope is empty — the dock then shows green over ## exactly the "loaded in every workspace" state the scope setting exists to ## end (#872). This template must print a `Scope: …` line ## (`claude mcp get ` does) and exit non-zero when the server is absent. ## Only consulted when the selected scope isn't the one `path_template` reads. var cli_scope_status_template: PackedStringArray = PackedStringArray() # Codex / TOML clients ----------------------------------------------------- ## Dotted TOML path under which our entry lives, e.g. ["mcp_servers", "godot-ai"]. ## Strategies build the [section."name"] header from this. var toml_section_path: PackedStringArray = PackedStringArray() var toml_legacy_section_aliases: PackedStringArray = PackedStringArray() ## Lines (without the [header]) emitted under the section, with `{url}` ## tokens. Substituted at call time. var toml_body_template: PackedStringArray = PackedStringArray() ## Resolved absolute config path for this client on the current OS. Exact-file ## overrides win first, followed by directory-valued `config_home_env`, then ## ordered candidates / `path_template`. Ignoring either override can write a ## file the client never reads and false-succeed. func resolved_config_path() -> String: return str(resolved_config_path_details().get("path", "")) ## Detailed sibling used by status/configure/remove so safe resolution ## failures reach the dock instead of collapsing into NOT_CONFIGURED. `error` ## is empty for ordinary unsupported/missing path mappings to preserve the ## long-standing status behavior for clients not installed on this platform. func resolved_config_path_details() -> Dictionary: var details := _resolve_config_path_details() var path := str(details.get("path", "")) if path.is_empty() or path.is_absolute_path(): return details ## Last gate before every strategy's read/write. `McpPathTemplate.expand` ## leaves a token it cannot resolve in place, so an unset `$USERPROFILE` (or ## a `~` with no HOME) reaches here as a RELATIVE path rather than as the ## root-relative `/godot` that sails through `is_absolute_path()`. Acting on ## it would resolve against the editor's own working directory and create a ## literal `$USERPROFILE` folder in the Godot project. No descriptor ## template is intentionally relative — every one is `~`- or `$VAR`-rooted — ## so a non-absolute survivor is always a resolution failure. Report it with ## the unexpanded path, which still shows what could not be resolved. return {"path": "", "error": unresolved_config_path_error(display_name, path)} ## Why Configure and Remove must leave this client's resolved file alone; "" ## when they may write it. See `unshared_flatpak_config_error`. func config_write_error() -> String: var path := resolved_config_path() return "" if path.is_empty() else unshared_flatpak_config_error(display_name, path) ## Why Configure and Remove must leave the file at `path` alone when this ## editor runs in a Flatpak sandbox that does not share it read-write with the ## host; "" when it does, and outside Flatpak. A file written there is read ## back and verified by this editor and never seen by the client. ## ## Every strategy asks this before it writes, ahead of `create_error`: where a ## Flatpak build of the client keeps its settings is beside the point until ## the sandbox can write the ordinary location at all. Status does not ask. ## It keeps reading the path, and under a read-only grant that is the ## client's real file. static func unshared_flatpak_config_error(client_name: String, path: String) -> String: var block := McpPathTemplate.flatpak_write_block(path) if block.is_empty(): return "" var refused := ( "Godot runs in a Flatpak sandbox that cannot write %s where %s reads " + "it, so nothing was changed." ) % [path, client_name] var by_hand := "or edit that file by hand (docs/steam-capability-directory.md)." if block.has("unmounted"): return refused + ( " Flatpak was asked to share %s but mounted nothing there, which " + "is what it does when the directory does not exist as Godot " + "starts. Create it, restart Godot, and try again, %s" ) % [block["unmounted"], by_hand] if block.has("hidden"): return refused + ( " Flatpak hides %s from this sandbox, as a --nofilesystem rule " + "does. Remove that rule, restart Godot, and try again, %s" ) % [block["hidden"], by_hand] if block.has("read_only"): return refused + ( " %s is mounted read-only. Make it writable, restart Godot, and " + "try again, %s" ) % [block["read_only"], by_hand] var app_id := McpPathTemplate.flatpak_app_id() return refused + ( " Run `flatpak override --user --filesystem=%s %s`, restart Godot, " + "and try again, or edit that file by hand: " + "docs/steam-capability-directory.md covers a sandbox without that " + "grant." ) % [block["needs"], app_id if not app_id.is_empty() else ""] ## Shared wording for a path template `McpPathTemplate.expand` could not fully ## resolve. Used by the guard above and by the merge-tier loader in ## `_json_strategy.gd`, which resolves its own templates and never passes ## through `resolved_config_path_details`. static func unresolved_config_path_error(client_name: String, path: String) -> String: return ( "Could not resolve %s's config path: %s did not expand to an absolute " + "path. Set HOME/USERPROFILE (or the variable the path names) in the " + "environment the editor was launched from." ) % [client_name, path] func _resolve_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 home_override := config_home_override_details() if not str(home_override.get("path", "")).is_empty() or not str(home_override.get("error", "")).is_empty(): _clear_config_path_warning() return home_override 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)) ## Candidates whose file does not exist yet and that Configure could create. var create_templates := PackedStringArray() var create_paths := PackedStringArray() var hidden_app_dirs := PackedStringArray() var hidden_paths := PackedStringArray() for index in range(ordered_templates.size()): var template := str(ordered_templates[index]) var expanded_template := McpPathTemplate.expand(template) # An empty wildcard group ordinarily means "this package is not # installed", but an unresolved root token produces the same empty # group. Do not silently treat "could not inspect" as "not present" and # fall through to a later writable candidate. if not expanded_template.is_absolute_path(): _clear_config_path_warning() return { "path": "", "error": unresolved_config_path_error(display_name, expanded_template), } 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 := _first_existing_later_candidate(ordered_templates, index + 1) if not str(seed.get("error", "")).is_empty(): _clear_config_path_warning() return {"path": "", "error": seed["error"]} _clear_config_path_warning() return {"path": path, "error": "", "seed_path": seed.get("path", "")} # Creating below a directory the sandbox hides would land on its # private tmpfs and vanish with the editor, so a hidden candidate can # be neither evidence nor a create target. var hidden_app_dir := McpPathTemplate.hidden_flatpak_app_dir(path) if not hidden_app_dir.is_empty(): hidden_paths.append(path) if not hidden_app_dirs.has(hidden_app_dir): hidden_app_dirs.append(hidden_app_dir) continue create_templates.append(template) create_paths.append(path) _clear_config_path_warning() var evident_create_path := _evident_create_path(create_templates, create_paths) if not evident_create_path.is_empty(): return {"path": evident_create_path, "error": ""} var fallback_create_path := "" if create_paths.is_empty() else create_paths[0] if hidden_app_dirs.is_empty(): return {"path": fallback_create_path, "error": ""} var hidden_error := hidden_flatpak_config_error( display_name, fallback_create_path.get_base_dir(), hidden_app_dirs ) if fallback_create_path.is_empty(): return {"path": "", "error": hidden_error} return { "path": fallback_create_path, "error": "", "create_error": hidden_error, "hidden_paths": hidden_paths, } ## The location the client has demonstrably run from, among candidates with no ## config file yet; "" when nothing says. Candidates are compared level by ## level, from the directory that would hold the file upward, and the first ## level at which one of those directories exists decides, in descriptor order. ## A client nested in its host app's settings (a VS Code extension) therefore ## follows the host app when the extension's own directory exists nowhere, ## instead of creating a second, unread copy of the host's settings tree. ## ## Only directories the templates name below their shared root are compared, ## and only as many levels as the shortest template has. Those belong to the ## client; a container such as ~/.config exists whether or not the client does. static func _evident_create_path(templates: PackedStringArray, paths: PackedStringArray) -> String: if paths.is_empty(): return "" var shared := _shared_root_length(templates) var levels := -1 for template in templates: var own_directories := template.split("/").size() - 1 - shared levels = own_directories if levels < 0 else mini(levels, own_directories) var directories := PackedStringArray() for path in paths: directories.append(path.get_base_dir()) for _level in range(maxi(levels, 0)): for index in range(directories.size()): if DirAccess.dir_exists_absolute(directories[index]): return paths[index] for index in range(directories.size()): directories[index] = directories[index].get_base_dir() return "" ## How many leading path elements every template shares, counting the root ## element (`$XDG_CONFIG_HOME`, `~`, a drive) and never the file name. At least ## one: the root is never the client's own directory. static func _shared_root_length(templates: PackedStringArray) -> int: if templates.size() < 2: return 1 var first := templates[0].split("/") var shared := first.size() - 1 for index in range(1, templates.size()): var other := templates[index].split("/") var limit := mini(shared, other.size() - 1) var same := 0 while same < limit and first[same] == other[same]: same += 1 shared = same return maxi(shared, 1) ## Why Configure will not create a client's settings file from inside a ## Flatpak editor: nothing shows the client runs from the ordinary location, ## and the location a Flatpak build of it uses cannot be inspected. Names the ## one grant that lets this editor see it. static func hidden_flatpak_config_error( client_name: String, settings_dir: String, hidden_app_dirs: PackedStringArray ) -> String: var app_id := McpPathTemplate.flatpak_app_id() var grants := PackedStringArray() for app_dir in hidden_app_dirs: grants.append("--filesystem=%s" % app_dir) var missing := ( "%s does not exist" % settings_dir if not settings_dir.is_empty() else "it has no settings location outside Flatpak" ) return ( "Could not find where %s keeps its settings: %s, and a Flatpak build " + "keeps them under %s, which Godot's own Flatpak sandbox cannot see. " + "If you use the Flatpak build, run `flatpak override --user %s %s`, " + "restart Godot, and configure again. If it is installed outside " + "Flatpak, start it once so it creates its settings, then configure " + "again." ) % [ client_name, missing, ", ".join(hidden_app_dirs), " ".join(grants), app_id if not app_id.is_empty() else "", ] ## Find a readable seed for a newly-created authoritative wildcard target. ## An unresolved later root is an error, not an absent seed: writing without ## inspecting it could silently discard configuration visible through the ## package's read-through fallback. func _first_existing_later_candidate(templates: Array, start_index: int) -> Dictionary: for index in range(start_index, templates.size()): var template := str(templates[index]) var expanded_template := McpPathTemplate.expand(template) if not expanded_template.is_absolute_path(): return { "path": "", "error": unresolved_config_path_error(display_name, expanded_template), } var group := McpPathTemplate.expand_path_candidates(template) # 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": path, "error": ""} return {"path": "", "error": ""} 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 config-home env override plus any fail-closed diagnostic. Empty path and ## error means no override applies (no mapping declared, env var unset, or env ## var empty/whitespace). func config_home_override_details() -> Dictionary: if config_home_env.is_empty() or config_home_env_subpath.is_empty(): return {"path": "", "error": ""} ## 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 {"path": "", "error": ""} # Expand a leading ~ so `CODEX_HOME=~/codex-alt` behaves like the shell. var expanded := McpPathTemplate.expand(home) ## Same fail-closed rule as `config_file_override_details`: a relative value ## resolves against the EDITOR's working directory, not the client's, so ## honouring it silently aims the write at the Godot project directory ## instead of the file the client reads. The write layer can only name the ## mangled path it was handed; only here do we still know which env var ## produced it, so this is where the user-facing explanation belongs. if not expanded.is_absolute_path(): return { "path": "", "error": "%s's $%s override must be an absolute config-home directory path; got %s" % [ display_name, config_home_env, home, ], } return {"path": expanded.path_join(config_home_env_subpath), "error": ""} ## The env-var-relocated config path, or "" when no override applies (no ## mapping declared, env var unset, env var empty/whitespace, or a value that ## failed closed — `config_home_override_details` carries that diagnostic). func config_home_override() -> String: return str(config_home_override_details().get("path", "")) ## 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 ## Whether an existing entry's launch text launches Godot AI: an executable ## named `godot-ai`, a `godot-ai==` package pin, the bare `godot-ai` ## console-script argument, or the `godot_ai` module. Tokens are matched ## exactly, so a URL or path that merely contains the name (a docs link, a ## project directory) does not count. The post-update major migration ## rewrites only such entries; anything else is the user's own server. static func launch_mentions_godot_ai(text: String) -> bool: ## Free text (a CLI probe's output) is split on spaces; structured launch ## values should go through `launch_values_mention_godot_ai` unsplit. return launch_values_mention_godot_ai(PackedStringArray(text.split(" ", false))) ## Each value is one command, argument or URL. A URI never names an ## executable, and Windows separators are normalized before the basename ## check so `C:\Program Files\Godot AI\godot-ai.exe` is recognized whole. static func launch_values_mention_godot_ai(values: PackedStringArray) -> bool: for raw_value in values: var value := raw_value.strip_edges().lstrip("\"'[{(").rstrip("\"'])},") if value.is_empty() or value.contains("://"): continue if value == "godot-ai" or value == "godot_ai" or value.begins_with("godot-ai=="): return true var base := value.replace("\\", "/").get_file() if base == "godot-ai" or base == "godot-ai.exe": return true return false ## The launch-bearing fields of an entry, one value each, for the check ## above. The entry sits under our server name, so the name itself must not ## count. static func entry_launch_values(entry: Dictionary) -> PackedStringArray: var values := PackedStringArray() for key in ["command", "args", "url"]: if not entry.has(key): continue var value: Variant = entry[key] if value is Array: for item in value: values.append(str(item)) else: values.append(str(value)) return values