Files
agame/addons/godot_ai/clients/_base.gd
T
2026-09-21 16:16:59 +08:00

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