579 lines
28 KiB
GDScript
579 lines
28 KiB
GDScript
@tool
|
|
class_name McpClient
|
|
extends RefCounted
|
|
|
|
## Descriptor for one MCP client (Cursor, Claude Desktop, Codex, ...).
|
|
##
|
|
## Subclasses set fields in `_init()` and MUST NOT carry Callables — strategies
|
|
## (json/toml/cli) interpret the data. Enforced by
|
|
## `test_clients.gd::test_descriptors_are_data_only`.
|
|
##
|
|
## Why no Callables: per-client `.gd` files get hot-reloaded on disk-mtime
|
|
## change. A worker thread mid-call into a descriptor lambda races the
|
|
## bytecode swap and SEGVs (issue #229). Bonus: also obsoletes the stale-
|
|
## Callable workaround from #192.
|
|
|
|
## CONFIGURED_MISMATCH = an entry with our `SERVER_NAME` exists in the user's
|
|
## client config, but its URL or launch command doesn't match the current
|
|
## ports/version/exclusions — typical after a setting change or update.
|
|
## Distinguishing this from `NOT_CONFIGURED` lets the dock surface a "your
|
|
## saved client configuration is stale" banner instead of conflating it with
|
|
## "you never configured this client".
|
|
enum Status { NOT_CONFIGURED, CONFIGURED, CONFIGURED_MISMATCH, ERROR }
|
|
|
|
|
|
## Lowercase string label for a `Status` value. Single source of truth so the
|
|
## MCP `client_status` tool, the dock, and the verify-after-write diagnostic
|
|
## in `McpClientConfigurator` all emit the same names — agents pattern-match
|
|
## against this set, so a fifth value being silently introduced would break
|
|
## them.
|
|
static func status_label(status: McpClient.Status) -> String:
|
|
match status:
|
|
Status.CONFIGURED:
|
|
return "configured"
|
|
Status.NOT_CONFIGURED:
|
|
return "not_configured"
|
|
Status.CONFIGURED_MISMATCH:
|
|
return "configured_mismatch"
|
|
return "error"
|
|
|
|
|
|
## One-line configure success message, shared by every strategy so the dock
|
|
## and the `client_manage` tool describe the transport that was actually
|
|
## written. Command-shape clients register the stdio `godot-ai attach`
|
|
## bridge — the URL-era "(HTTP: <url>)" suffix would name a transport the
|
|
## write never touched (found live in the #838 Windows smoke).
|
|
static func configured_message(client: McpClient, server_url: String) -> String:
|
|
if client.command_shape != CommandShape.NONE:
|
|
return "%s configured (stdio attach)" % client.display_name
|
|
return "%s configured (HTTP: %s)" % [client.display_name, server_url]
|
|
|
|
var id: String = "" ## stable key, e.g. "cursor"
|
|
var display_name: String = "" ## "Cursor"
|
|
var config_type: String = "" ## "json" | "toml" | "yaml" | "cli" | "dsh"
|
|
## 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 first non-wildcard
|
|
## template is the deterministic create target.
|
|
## 3. Multiple matches within any wildcard group are ambiguous and fail
|
|
## closed instead of choosing an arbitrary package.
|
|
##
|
|
## Exact-file and config-home environment overrides still have higher
|
|
## priority. When this map has no entry for the current platform,
|
|
## `path_template` remains the fallback.
|
|
var config_path_candidates: Dictionary = {}
|
|
|
|
## Optional global JSON config files merged by the client from lowest to highest
|
|
## precedence. Unlike `config_path_candidates`, every existing file participates;
|
|
## Configure updates the effective last definition, status verifies it, and
|
|
## Remove clears every global definition transactionally.
|
|
var config_merge_path_templates: Dictionary = {}
|
|
## Project-relative tiers that may override the global files. Their root is the
|
|
## external client's working directory, which the Godot process cannot know.
|
|
## The strategy checks plausible roots and fails closed instead of mutating them.
|
|
var config_merge_project_paths: PackedStringArray = PackedStringArray()
|
|
|
|
## De-duplicate persistent path-ambiguity warnings across recurring status
|
|
## refreshes. The actionable message still returns on every resolution; only
|
|
## the editor-console echo is single-shot until the ambiguity clears/changes.
|
|
var _last_config_path_warning := ""
|
|
var _config_path_warning_mutex := Mutex.new()
|
|
|
|
## Path inside the config object where the per-server map lives.
|
|
## Cursor / Claude Desktop / most others: ["mcpServers"]
|
|
## VS Code: ["servers"]
|
|
## OpenCode: ["mcp"]
|
|
var server_key_path: PackedStringArray = PackedStringArray()
|
|
## Alternative server-map paths accepted by the client, in precedence order
|
|
## after `server_key_path`. JSON strategy operations select the first non-null
|
|
## existing path so Configure, status, and Remove agree with the client parser.
|
|
var server_key_path_aliases: Array[PackedStringArray] = []
|
|
|
|
## Field inside the entry dict that holds our server URL.
|
|
## "url" by default; some clients use "serverUrl" or "httpUrl".
|
|
var entry_url_field: String = "url"
|
|
|
|
## Required entry fields — written on every Configure AND verified by the
|
|
## default verifier. Use this for transport pins (e.g. `type:
|
|
## "streamable-http"`) where a missing/wrong value breaks negotiation: a
|
|
## legacy entry without the pin fails verification and surfaces as drift.
|
|
##
|
|
## DO NOT put user-mutable state here (auto-approval lists, `disabled`
|
|
## flags, opt-in toggles). Verifying those treats every user customisation
|
|
## as drift, and Configure-All-Mismatched then silently overwrites them
|
|
## back to defaults — see the `entry_initial_fields` doc below.
|
|
var entry_extra_fields: Dictionary = {}
|
|
|
|
## Default fields written ONLY when the entry doesn't yet exist. Reconfigure
|
|
## preserves whatever the user (or the client itself) has set; the verifier
|
|
## ignores these keys entirely. Use for opt-in flags and user-state arrays —
|
|
## e.g. Roo / Cline / Kilo `alwaysAllow` / `autoApprove` lists, `disabled:
|
|
## false`, `isActive: true`. The pre-#229 behaviour was equivalent: per-
|
|
## client `entry_builder` lambdas seeded these as defaults but the
|
|
## per-client `verify_entry` lambdas only checked transport pins, so a
|
|
## user-customised array was `CONFIGURED`, not drift. Splitting the field
|
|
## restores that contract under the data-only descriptor model.
|
|
var entry_initial_fields: Dictionary = {}
|
|
|
|
## Client-owned stdio launch shape. Each strategy renders the shape in its
|
|
## config language:
|
|
##
|
|
## - FLAT — `command` string + `args` array as sibling keys. JSON and YAML
|
|
## strategies. A client whose docs require a type discriminator next to the
|
|
## flat keys (VS Code's `type: "stdio"`, Claude Code's fallback file) stays
|
|
## FLAT and declares it via `command_transport_key` / `command_transport_value`,
|
|
## so TYPED_FLAT remains reserved vocabulary.
|
|
## - COMMAND_ARRAY — the launch argv carried as one array. In the JSON
|
|
## strategy the entry's `command` field IS that array (OpenCode's
|
|
## `"command": ["uvx", …]`). In the TOML strategy the launcher renders as a
|
|
## `command = "…"` line plus an `args = […]` array (Codex, Grok) — the name
|
|
## refers to the argv-as-TOML-array body it emits.
|
|
## - NESTED_COMMAND — command/args nested inside a sub-object. Reserved; no
|
|
## current client needs it and strategies reject it with an actionable error.
|
|
##
|
|
## CLI-registered clients (`config_type == "cli"`) express the launch through
|
|
## `cli_register_template` tokens instead; their `command_shape` governs the
|
|
## JSON-fallback file rendering (Claude Code, #463).
|
|
##
|
|
## Values are data-only shared vocabulary; keeping them data-only avoids
|
|
## reintroducing the descriptor Callable race from #229.
|
|
enum CommandShape { NONE, FLAT, TYPED_FLAT, COMMAND_ARRAY, NESTED_COMMAND }
|
|
var command_shape: CommandShape = CommandShape.NONE
|
|
|
|
## 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.
|
|
## 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: <user|project|local> …` line
|
|
## (`claude mcp get <name>` does) and exit non-zero when the server is absent.
|
|
## Only consulted when the selected scope isn't the one `path_template` reads.
|
|
var cli_scope_status_template: PackedStringArray = PackedStringArray()
|
|
|
|
# Codex / TOML clients -----------------------------------------------------
|
|
## Dotted TOML path under which our entry lives, e.g. ["mcp_servers", "godot-ai"].
|
|
## Strategies build the [section."name"] header from this.
|
|
var toml_section_path: PackedStringArray = PackedStringArray()
|
|
var toml_legacy_section_aliases: PackedStringArray = PackedStringArray()
|
|
## Lines (without the [header]) emitted under the section, with `{url}`
|
|
## tokens. Substituted at call time.
|
|
var toml_body_template: PackedStringArray = PackedStringArray()
|
|
|
|
|
|
## Resolved absolute config path for this client on the current OS. Exact-file
|
|
## overrides win first, followed by directory-valued `config_home_env`, then
|
|
## ordered candidates / `path_template`. Ignoring either override can write a
|
|
## file the client never reads and false-succeed.
|
|
func resolved_config_path() -> String:
|
|
return str(resolved_config_path_details().get("path", ""))
|
|
|
|
|
|
## Detailed sibling used by status/configure/remove so safe resolution
|
|
## failures reach the dock instead of collapsing into NOT_CONFIGURED. `error`
|
|
## is empty for ordinary unsupported/missing path mappings to preserve the
|
|
## long-standing status behavior for clients not installed on this platform.
|
|
func resolved_config_path_details() -> Dictionary:
|
|
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)}
|
|
|
|
|
|
## 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))
|
|
var fallback_create_path := ""
|
|
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", "")}
|
|
if fallback_create_path.is_empty():
|
|
fallback_create_path = path
|
|
_clear_config_path_warning()
|
|
return {"path": fallback_create_path, "error": ""}
|
|
|
|
|
|
## 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==<version>` 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
|