Files
tekton/addons/godot_ai/clients/_base.gd
T

212 lines
10 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 doesn't match `http_url()` — typical after the
## user changes `godot_ai/http_port` and reloads. Distinguishing this from
## `NOT_CONFIGURED` lets the dock surface a "your saved client URLs are 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"
var id: String = "" ## stable key, e.g. "cursor"
var display_name: String = "" ## "Cursor"
var config_type: String = "" ## "json" | "toml" | "cli"
var doc_url: String = ""
# 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 = {}
## Path inside the config object where the per-server map lives.
## Cursor / Claude Desktop / most others: ["mcpServers"]
## VS Code: ["servers"]
## OpenCode: ["mcp"]
var server_key_path: PackedStringArray = PackedStringArray()
## Field inside the entry dict that holds our server URL.
## "url" by default; some clients use "serverUrl" or "httpUrl".
var entry_url_field: String = "url"
## Required entry fields — written on every Configure AND verified by the
## default verifier. Use this for transport pins (e.g. `type:
## "streamable-http"`) where a missing/wrong value breaks negotiation: a
## legacy entry without the pin fails verification and surfaces as drift.
##
## DO NOT put user-mutable state here (auto-approval lists, `disabled`
## flags, opt-in toggles). Verifying those treats every user customisation
## as drift, and Configure-All-Mismatched then silently overwrites them
## back to defaults — see the `entry_initial_fields` doc below.
var entry_extra_fields: Dictionary = {}
## Default fields written ONLY when the entry doesn't yet exist. Reconfigure
## preserves whatever the user (or the client itself) has set; the verifier
## ignores these keys entirely. Use for opt-in flags and user-state arrays —
## e.g. Roo / Cline / Kilo `alwaysAllow` / `autoApprove` lists, `disabled:
## false`, `isActive: true`. The pre-#229 behaviour was equivalent: per-
## client `entry_builder` lambdas seeded these as defaults but the
## per-client `verify_entry` lambdas only checked transport pins, so a
## user-customised array was `CONFIGURED`, not drift. Splitting the field
## restores that contract under the data-only descriptor model.
var entry_initial_fields: Dictionary = {}
## stdio→HTTP bridge mode for clients that don't speak HTTP natively.
## NONE — entry is `{[entry_url_field]: url, **entry_extra_fields,
## ...entry_initial_fields (only for new entries)}`
## FLAT — Claude Desktop shape: `{"command": <uvx>, "args": [...bridge...]}`
## Verifier ALSO accepts a future url-style entry.
##
## Enum (vs. String) so a typo in a descriptor fails at parse time instead of
## silently falling through `match` to the non-bridge path.
enum UvxBridge { NONE, FLAT }
var entry_uvx_bridge: UvxBridge = UvxBridge.NONE
## Paths whose existence implies the user has this client installed.
## Used purely for the dock's "installed" badge.
var detect_paths: PackedStringArray = PackedStringArray()
# 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. Today only `claude_code` populates these.
var cli_register_template: PackedStringArray = PackedStringArray()
var cli_unregister_template: PackedStringArray = PackedStringArray()
## Args run to read current state; stdout is scanned for the server name and
## URL. Presence of `name` AND `url` → CONFIGURED, name only → MISMATCH,
## neither → NOT_CONFIGURED.
var cli_status_args: PackedStringArray = PackedStringArray()
# Codex / TOML clients -----------------------------------------------------
## Dotted TOML path under which our entry lives, e.g. ["mcp_servers", "godot-ai"].
## Strategies build the [section."name"] header from this.
var toml_section_path: PackedStringArray = PackedStringArray()
var toml_legacy_section_aliases: PackedStringArray = PackedStringArray()
## Lines (without the [header]) emitted under the section, with `{url}`
## tokens. Substituted at call time.
var toml_body_template: PackedStringArray = PackedStringArray()
## Resolved absolute config path for this client on the current OS.
func resolved_config_path() -> String:
return McpPathTemplate.resolve(path_template)
## 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:
var resolved := McpPathTemplate.expand(p)
if not resolved.is_empty() and (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
# ---------- stdio→http bridge helpers (Claude Desktop) --------------------
## Pinned mcp-proxy release used by every stdio-only client's bridge. uvx's
## cache key is version-specific, so pinning guarantees all users run the
## same vetted bridge — a malicious or broken future release on PyPI can't
## silently break everyone's Configure flow. Bump deliberately when the
## upstream publishes something we want.
const MCP_PROXY_VERSION := "0.11.0"
## Resolve `uvx` to an absolute path. GUI-launched apps (Claude Desktop)
## often run with a minimal PATH that excludes ~/.local/bin on macOS /
## Linux, so a bare "uvx" string in the config would fail at spawn time
## with the same "Server disconnected" symptom we're trying to cure. The
## shared three-tier McpCliFinder covers the well-known install dirs;
## returns bare "uvx" as a last-resort fallback so the entry is still
## well-formed even if the lookup failed.
static func resolve_uvx_path() -> String:
var names: Array[String] = []
names.append("uvx.exe" if OS.get_name() == "Windows" else "uvx")
var resolved := McpCliFinder.find(names)
return resolved if not resolved.is_empty() else "uvx"
## Build the `mcp-proxy` bridge argv (without the leading uvx command).
## Callers splice this into the client-specific command shape.
static func mcp_proxy_bridge_args(url: String) -> Array:
return ["mcp-proxy==" + MCP_PROXY_VERSION, "--transport", "streamablehttp", url]
## Environment overrides written alongside every auto-configured uvx-bridge
## entry. `UV_LINK_MODE=copy` tells uv to copy shared C extensions into each
## `builds-v0\.tmpXXXXXX\` build venv instead of hard-linking them from
## `archive-v0\`. On Windows that breaks the lock race documented in
## `utils/uv_cache_cleanup.gd` and the README — the running godot-ai server
## holds `_pydantic_core.pyd` mapped, the build venv's hard-linked copy
## inherits the lock, uv's post-install cleanup fails, and the MCP launcher
## reports "pywin32 wheel invalid / file in use" with no working transport.
## Cost on macOS/Linux is a few extra MB in the uvx cache — well worth it
## to keep one config shape across platforms.
static func bridge_env_for_uvx() -> Dictionary:
return {"UV_LINK_MODE": "copy"}