Replace dasher-pack with unified animation-pack using original Blender bone names

This commit is contained in:
2026-06-15 14:28:26 +08:00
parent 9dd3c59edf
commit 844ec194cb
297 changed files with 28680 additions and 1884 deletions
+161
View File
@@ -0,0 +1,161 @@
@tool
class_name McpAtomicWrite
extends RefCounted
## Write text to a file via temp + rename so a crash mid-write never leaves
## the user's MCP config truncated. Creates the parent dir if needed and
## keeps a one-shot `.backup` of the prior file.
##
## On filesystems where rename-over-existing fails (Windows under AV / lock
## pressure, some SMB shares), falls back to overwrite-copy plus a
## backup-restore on failure. The original file is never removed before the
## new bytes are verified on disk — if both the rename and the copy fail,
## the user's prior config is restored from the `.backup` snapshot. See
## issue #297 finding #10 for the data-loss scenario this guards against.
static func write(path: String, content: String) -> bool:
var dir_path := path.get_base_dir()
if not DirAccess.dir_exists_absolute(dir_path):
if DirAccess.make_dir_recursive_absolute(dir_path) != OK:
return false
# Decide the permission mode the final file (and its backup) must carry
# BEFORE we replace anything. A rewrite must preserve the prior file's
# mode: the Claude CLI creates ~/.claude.json as 0600 (it holds OAuth
# creds + history), and a naive FileAccess write + DirAccess copy would
# silently relax that to the umask default (0644) and leak it on shared
# machines. A brand-new config defaults to owner-only 0600 since these
# files routinely carry tokens. On platforms without POSIX permissions
# (Windows) the get/set calls no-op and this logic is inert. See #297
# finding TC-1.
var had_original := FileAccess.file_exists(path)
var target_mode := _resolve_target_mode(path, had_original)
var tmp_path := path + ".tmp"
var file := FileAccess.open(tmp_path, FileAccess.WRITE)
if file == null:
return false
# Lock the temp inode down BEFORE writing any bytes. FileAccess.open creates
# it at the umask default (often 0644); chmod'ing the still-empty file first
# means the config contents are never on disk under a world-readable mode in
# the create->chmod gap. rename preserves the inode mode, so the swapped-in
# file lands correct and is never briefly world-readable under the target name.
_apply_mode(tmp_path, target_mode)
file.store_string(content)
# Push Godot's internal buffer out to the OS before the rename. Godot
# exposes no fsync, so the bytes aren't guaranteed durable on the physical
# disk until the OS flushes its own cache — a power loss in that window can
# still lose the data. But flush() ensures the rename can't be ordered ahead
# of the write at the application layer, which is the failure this guards.
file.flush()
file.close()
# Re-assert the mode on the closed inode. The pre-write chmod above closes
# the world-readable window; this second apply is the authoritative one
# (a chmod issued while the FileAccess handle is still open doesn't reliably
# stick inside the editor) and guarantees the final mode before the rename,
# which preserves it.
_apply_mode(tmp_path, target_mode)
# Best-effort: snapshot the prior file before we touch the target so we
# can restore on a failed swap. The backup is also kept on success as a
# one-shot rollback aid for the user — give it the same (preserved) mode
# so a 0600 config's backup isn't itself a world-readable copy.
#
# copy_absolute creates the backup at the umask default and we can only
# chmod it afterward, so there's a sub-millisecond window where the backup
# carries default perms. Accepted: it duplicates bytes already sitting at
# `path` (which the caller created 0600) inside the user's own config dir,
# and Godot exposes no API to create the copy pre-chmod'd. Not worth
# reimplementing copy by hand to shave that window.
var backup_path := path + ".backup"
var backup_made := false
if had_original:
DirAccess.remove_absolute(backup_path)
if DirAccess.copy_absolute(path, backup_path) == OK:
backup_made = true
_apply_mode(backup_path, target_mode)
if DirAccess.rename_absolute(tmp_path, path) == OK:
return true
# Rename-over-existing rejected (Windows + AV / lock timing, some SMB
# shares). Use overwrite-copy as the recovery path: copy_absolute never
# removes the original before writing the new bytes, so a failure here
# leaves the user's prior config in place rather than nuking it.
if DirAccess.copy_absolute(tmp_path, path) == OK and _written_size_matches(path, content):
# copy_absolute creates the destination with the default mode, so
# re-apply the preserved/owner-only mode after the copy lands.
_apply_mode(path, target_mode)
DirAccess.remove_absolute(tmp_path)
return true
# Copy didn't land cleanly. Restore the destination to its pre-call state.
if backup_made:
# Restore the snapshot we took before the swap. `copy_absolute`
# overwrites the destination, so we don't pre-remove `path` — the
# pre-remove created a window where `path` was gone if the
# subsequent copy itself failed. If the restore copy fails now the
# user's prior bytes are still in `.backup` for manual recovery
# and the false return value tells the caller the swap didn't
# complete.
DirAccess.copy_absolute(backup_path, path)
_apply_mode(path, target_mode)
elif not had_original and FileAccess.file_exists(path):
# No prior file existed but copy_absolute landed partial bytes at
# `path`. Remove them so the failure leaves nothing on disk rather
# than a truncated/invalid new file. The `file_exists` guard keeps
# us off non-file destinations (a path that points at a directory
# yields `had_original=false` too, but we must not try to delete
# the directory). Issue #297 PR review.
DirAccess.remove_absolute(path)
# (If `had_original` is true but the snapshot couldn't be taken, the
# original on disk is whatever copy_absolute managed to write before
# failing. This is a best-effort path — the false return value tells the
# caller the swap didn't complete; recovery beyond that requires a
# backup we couldn't take.)
DirAccess.remove_absolute(tmp_path)
return false
static func _resolve_target_mode(path: String, had_original: bool) -> int:
# Preserve the prior file's POSIX mode on a rewrite; default a brand-new
# config (or any case we can't read a mode for) to owner read+write (0600).
#
# get_unix_permissions returns 0 both on Windows (no POSIX perms) and for a
# genuine 0000 file. Treating 0 as "use the 0600 floor" is deliberate, not a
# missed case: these are config files the plugin must read and write, 0000 is
# unusable, and re-applying 0000 would lock the owner out next run. 0600 is
# still owner-only so this never widens access. (A genuinely-0000 file can't
# reach a rewrite through the config strategies anyway — their read-first
# guard fails to open it and refuses the write before we get here.)
if had_original:
var existing := FileAccess.get_unix_permissions(path)
if existing > 0:
return existing
return FileAccess.UNIX_READ_OWNER | FileAccess.UNIX_WRITE_OWNER
static func _apply_mode(path: String, mode: int) -> void:
# Best-effort. set_unix_permissions returns ERR_UNAVAILABLE on platforms
# without POSIX permissions (Windows); that's expected and ignored so the
# write still works there. mode <= 0 should never happen (resolve always
# returns >0) but is guarded so a future caller can't chmod a file to nothing.
if mode <= 0:
return
var err := FileAccess.set_unix_permissions(path, mode)
# Surface a real chmod failure (not the Windows no-op) so permission
# hardening on a sensitive config doesn't fail completely silently.
if err != OK and err != ERR_UNAVAILABLE:
push_warning("MCP | could not set permissions on %s (error %d)" % [path, err])
static func _written_size_matches(path: String, content: String) -> bool:
# `store_string` writes UTF-8 bytes with no BOM and no newline translation,
# so the byte length on disk must match `to_utf8_buffer().size()` exactly.
var f := FileAccess.open(path, FileAccess.READ)
if f == null:
return false
var on_disk := f.get_length()
f.close()
return on_disk == content.to_utf8_buffer().size()
@@ -0,0 +1 @@
uid://6fkb5uau0r4h
+211
View File
@@ -0,0 +1,211 @@
@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"}
+1
View File
@@ -0,0 +1 @@
uid://cyowqr1x12ilg
+143
View File
@@ -0,0 +1,143 @@
@tool
class_name McpCliExec
extends RefCounted
## Wall-clock-bounded CLI invocation. Every dock shell-out to a per-client
## CLI (`claude mcp list`, `claude mcp add ...`, etc.) goes through here so
## a hung subprocess can't trap the calling thread forever.
##
## Without the timeout, a contended `claude mcp list` has been observed to
## hang for 6+ minutes (issues #238, #239) — wedging the dock's status
## refresh worker, and on the Configure / Remove paths the editor main
## thread itself.
##
## Why poll/kill instead of `OS.execute(..., true)`: GDScript can't
## interrupt a blocking `OS.execute`, so a hung CLI takes its caller's
## thread with it. `OS.execute_with_pipe` returns immediately with a PID;
## we drive the wait ourselves and `OS.kill` the orphan if budget
## expires. CLI registry commands have bounded output (a few hundred
## bytes), so we don't bother draining the pipe during the poll loop —
## the kernel buffer absorbs it.
##
## Returns a Dictionary with:
## exit_code: process exit code (0 = success). -1 on timeout / spawn failure.
## stdout: captured stdout text. May be partial on timeout.
## stderr: captured stderr text. May be partial on timeout. Empty when
## `capture_stderr` is false.
## output: stdout + (newline + stderr if non-empty). Convenience for
## the common case of "show whatever the CLI said when it
## failed" — `claude mcp add` writes its real diagnostics to
## stderr, so callers that only read `stdout` would surface
## a generic "exit code 1" instead.
## timed_out: true if we killed the process at the wall-clock budget.
## spawn_failed: true if `OS.execute_with_pipe` didn't return a usable PID.
const DEFAULT_TIMEOUT_MS := 8000
const _POLL_INTERVAL_MS := 50
static func run(
exe: String,
args: Array,
timeout_ms: int = DEFAULT_TIMEOUT_MS,
capture_stderr: bool = true
) -> Dictionary:
if exe.is_empty():
return _spawn_failed_result()
var spawn_exe := exe
var spawn_args := args
if OS.get_name() == "Windows":
var lower := exe.to_lower()
if lower.ends_with(".cmd") or lower.ends_with(".bat"):
## CreateProcessW can't launch `.cmd` / `.bat` scripts on its
## own — they're cmd.exe input, not PE binaries. Without this
## wrap, the moment `McpCliFinder` resolves a Node-style shim
## (npm's `claude.cmd`, pnpm's wrappers, …) the next
## `OS.execute_with_pipe` surfaces "Could not create child
## process: <path> ..." in Godot's output log (#251). Passing
## `exe` as a separate argv element keeps spaces in the path
## quoted by Godot's standard quoter — no manual escaping.
spawn_exe = "cmd.exe"
spawn_args = ["/c", exe]
spawn_args.append_array(args)
var info := OS.execute_with_pipe(spawn_exe, spawn_args)
if info.is_empty():
return _spawn_failed_result()
var pid: int = int(info.get("pid", -1))
var stdio: Variant = info.get("stdio", null)
var stderr_pipe: Variant = info.get("stderr", null)
if pid <= 0:
_close_pipes(stdio, stderr_pipe)
return _spawn_failed_result()
var deadline := Time.get_ticks_msec() + maxi(timeout_ms, _POLL_INTERVAL_MS)
while OS.is_process_running(pid):
if Time.get_ticks_msec() >= deadline:
## Read whatever made it to the pipes before we kill the
## process — partial output beats blank "timed out" when the
## CLI was emitting useful diagnostics on its way to hanging.
var partial_stdout := _drain_pipe(stdio)
var partial_stderr := _drain_pipe(stderr_pipe) if capture_stderr else ""
OS.kill(pid)
_close_pipes(stdio, stderr_pipe)
return {
"exit_code": -1,
"stdout": partial_stdout,
"stderr": partial_stderr,
"output": _join_streams(partial_stdout, partial_stderr),
"timed_out": true,
"spawn_failed": false,
}
OS.delay_msec(_POLL_INTERVAL_MS)
var stdout := _drain_pipe(stdio)
var stderr_text := _drain_pipe(stderr_pipe) if capture_stderr else ""
_close_pipes(stdio, stderr_pipe)
return {
"exit_code": OS.get_process_exit_code(pid),
"stdout": stdout,
"stderr": stderr_text,
"output": _join_streams(stdout, stderr_text),
"timed_out": false,
"spawn_failed": false,
}
static func _spawn_failed_result() -> Dictionary:
return {
"exit_code": -1,
"stdout": "",
"stderr": "",
"output": "",
"timed_out": false,
"spawn_failed": true,
}
static func _drain_pipe(pipe: Variant) -> String:
if pipe is FileAccess:
return (pipe as FileAccess).get_as_text()
return ""
static func _join_streams(stdout: String, stderr_text: String) -> String:
## Most CLIs write their actionable diagnostics to one stream or the
## other, never both — so concatenation gives "the message" without
## the caller having to guess which key to read. Newline-separate so
## callers that grep don't see two lines run together.
if stderr_text.is_empty():
return stdout
if stdout.is_empty():
return stderr_text
return "%s\n%s" % [stdout, stderr_text]
static func _close_pipes(stdio: Variant, stderr_pipe: Variant) -> void:
if stdio is FileAccess:
(stdio as FileAccess).close()
if stderr_pipe is FileAccess:
(stderr_pipe as FileAccess).close()
+1
View File
@@ -0,0 +1 @@
uid://dhoe3ypkhm12v
+175
View File
@@ -0,0 +1,175 @@
@tool
class_name McpCliFinder
extends RefCounted
## Generic three-tier CLI resolution for clients whose binary lives somewhere
## a GUI-launched Godot's minimal PATH won't see:
## 1. Well-known install locations (~/.local/bin, /opt/homebrew/bin, ...)
## 2. Login shell lookup (`bash -lc 'command -v <exe>'`) — picks up .zshrc / .bashrc
## 3. Plain `which` / `where` against the inherited PATH
## Caches per-exe so repeated dock refreshes don't fork a shell every frame.
##
## Thread safety: `find()` runs on action-worker threads
## (`_run_client_action_worker` in `mcp_dock.gd`), and `invalidate()` runs on
## the main thread (manual Refresh path). Godot `Dictionary` is not safe for
## concurrent mutation, so `_cache` / `_searched` access is guarded by
## `_mutex`. The mutex is held only across dictionary read/write — the slow
## `_resolve()` path (FileAccess + `OS.execute`) runs unlocked, so a
## main-thread `invalidate()` can never block on a worker's subprocess.
## Two workers racing the same exe both call `_resolve()` and both write
## back the same answer; that's wasted work, not corruption.
static var _mutex: Mutex = Mutex.new()
static var _cache: Dictionary = {} # exe_name -> resolved path (or "")
static var _searched: Dictionary = {}
## Find any of the supplied exe names; returns the first hit.
## On Windows pass the .exe variant in `exe_names` if relevant.
static func find(exe_names: Array[String]) -> String:
for name in exe_names:
var hit := _find_one(name)
if not hit.is_empty():
return hit
return ""
## Drop cache for one exe (call after the user installs / reinstalls).
static func invalidate(exe_name: String = "") -> void:
_mutex.lock()
if exe_name.is_empty():
_cache.clear()
_searched.clear()
else:
_cache.erase(exe_name)
_searched.erase(exe_name)
_mutex.unlock()
static func _find_one(exe_name: String) -> String:
_mutex.lock()
var already_searched: bool = _searched.get(exe_name, false)
var cached: String = _cache.get(exe_name, "")
_mutex.unlock()
if already_searched:
return cached
# `_resolve()` does FileAccess + `OS.execute` (forks `bash -lc` /
# `which`), which can take 100ms-1s. Holding the mutex across that
# would let a concurrent `invalidate()` on the main thread freeze the
# editor for the duration of the subprocess — which defeats the whole
# point of running CLI lookup off the main thread.
var hit := _resolve(exe_name)
_mutex.lock()
_cache[exe_name] = hit
_searched[exe_name] = true
_mutex.unlock()
return hit
static func _resolve(exe_name: String) -> String:
var is_windows := OS.get_name() == "Windows"
# 1. Well-known locations
for dir in _well_known_dirs():
var full := dir.path_join(exe_name)
if FileAccess.file_exists(full):
return full
# 2. Login shell lookup (Unix only)
if not is_windows:
var shell := OS.get_environment("SHELL")
if shell.is_empty():
shell = "/bin/bash"
var login_output: Array = []
var stripped := exe_name.trim_suffix(".exe")
var login_exit := OS.execute(shell, ["-lc", "command -v %s" % stripped], login_output, true)
if login_exit == 0 and login_output.size() > 0:
var login_found: String = login_output[0].strip_edges()
if not login_found.is_empty() and FileAccess.file_exists(login_found):
return login_found
# 3. which / where with inherited PATH
var lookup := "where" if is_windows else "which"
var output: Array = []
var exit_code := OS.execute(lookup, [exe_name], output, true)
if exit_code == 0 and output.size() > 0:
var lines := PackedStringArray(output[0].split("\n"))
var found := _pick_best_path(lines) if is_windows else lines[0].strip_edges()
if not found.is_empty():
return found
return ""
## Executable extensions Windows' CreateProcessW can launch from a path
## (after the cmd.exe wrap in `_cli_exec.gd`). Order is preference: `.exe`
## is a native PE binary; `.cmd` / `.bat` go through the shell; `.com` is
## the legacy COM-format executable that some shims still ship.
const _WINDOWS_EXEC_EXTS := [".exe", ".cmd", ".bat", ".com"]
## Pick the best path from `where` output on Windows.
##
## npm-installed Node CLIs ship as BOTH `<dir>/<name>` (a POSIX bash shim
## for WSL / Git Bash users) AND `<dir>/<name>.cmd` (the actual Windows
## wrapper). `where <name>` lists both. CreateProcessW — the underlying
## syscall behind `OS.execute_with_pipe` — refuses to launch the
## extensionless POSIX shim, surfacing as
## `ERROR: Could not create child process: "...\claude" mcp list`
## in Godot's output log (#251). Picking a path with a real executable
## extension dodges that entirely.
##
## Extension scan is the OUTER loop so the order in `_WINDOWS_EXEC_EXTS`
## drives preference — `.exe` wins over `.cmd` even when the `.cmd` shows
## up first in `where` output (one fewer process per shell-out). Falls
## back to the first non-empty line when no entry has a recognised
## extension, so we never come up empty when `where` returned *something*.
static func _pick_best_path(lines: PackedStringArray) -> String:
var stripped := PackedStringArray()
for raw in lines:
var line := raw.strip_edges()
if not line.is_empty():
stripped.append(line)
if stripped.is_empty():
return ""
for ext in _WINDOWS_EXEC_EXTS:
for candidate in stripped:
if candidate.to_lower().ends_with(ext):
return candidate
return stripped[0]
static func _well_known_dirs() -> Array[String]:
var home := OS.get_environment("HOME")
if home.is_empty():
home = OS.get_environment("USERPROFILE")
match OS.get_name():
"macOS":
return [
home.path_join(".local/bin"),
home.path_join(".claude/local"),
home.path_join(".cargo/bin"),
"/opt/homebrew/bin",
"/usr/local/bin",
]
"Windows":
var local := OS.get_environment("LOCALAPPDATA")
var prog := OS.get_environment("ProgramFiles")
var paths: Array[String] = []
if not home.is_empty():
paths.append(home.path_join(".claude/local"))
paths.append(home.path_join(".local/bin"))
paths.append(home.path_join(".cargo/bin"))
paths.append(home.path_join("AppData/Local/Programs/uv"))
if not local.is_empty():
paths.append(local.path_join("Programs/uv"))
if not prog.is_empty():
paths.append(prog.path_join("uv"))
return paths
_:
return [
home.path_join(".local/bin"),
home.path_join(".claude/local"),
home.path_join(".cargo/bin"),
"/usr/local/bin",
]
@@ -0,0 +1 @@
uid://cnp5b6fcwou2y
+152
View File
@@ -0,0 +1,152 @@
@tool
class_name McpCliStrategy
extends RefCounted
## Strategy for MCP clients that own their own state via a CLI (e.g.
## `claude mcp add`). Reads `cli_register_template` / `cli_unregister_template`
## / `cli_status_args` from the descriptor and substitutes `{name}` / `{url}`
## tokens. No descriptor-supplied Callables — see `_base.gd` for why.
##
## Every shell-out goes through `McpCliExec.run`, which wraps the call in a
## wall-clock timeout. A hung CLI (e.g. `claude mcp list` under
## inter-Claude-Code contention) gets killed at the budget instead of
## locking up the caller forever — see issues #238 / #239.
const _CONFIGURE_TIMEOUT_MS := 10000
const _REMOVE_TIMEOUT_MS := 10000
const _STATUS_TIMEOUT_MS := 6000
static func configure(client: McpClient, server_name: String, server_url: String) -> Dictionary:
var cli := _resolve_cli(client)
if cli.is_empty():
return {"status": "error", "message": "%s not found" % client.display_name}
# Best-effort prior cleanup so re-configure is idempotent. Bounded to
# the same budget — a hung unregister shouldn't block the configure
# that follows.
if not client.cli_unregister_template.is_empty():
var pre_args := _format_args(client.cli_unregister_template, server_name, server_url)
McpCliExec.run(cli, pre_args, _REMOVE_TIMEOUT_MS)
if client.cli_register_template.is_empty():
return {"status": "error", "message": "%s descriptor missing cli_register_template" % client.display_name}
var args := _format_args(client.cli_register_template, server_name, server_url)
var result := McpCliExec.run(cli, args, _CONFIGURE_TIMEOUT_MS)
if result.get("timed_out", false):
return {
"status": "error",
"message": "Configure %s timed out after %ds — see 'Run this manually' below to retry by hand" % [
client.display_name, _CONFIGURE_TIMEOUT_MS / 1000,
],
}
if result.get("spawn_failed", false):
return {"status": "error", "message": "Failed to spawn %s" % client.display_name}
if int(result.get("exit_code", -1)) == 0:
return {"status": "ok", "message": "%s configured (HTTP: %s)" % [client.display_name, server_url]}
## `claude mcp add` writes its real failure diagnostics to stderr, so
## prefer `output` (stdout + stderr) over `stdout` alone — otherwise
## the user sees "exit code 1" instead of the actual error.
var combined := str(result.get("output", "")).strip_edges()
var err := combined if not combined.is_empty() else "exit code %d" % int(result.get("exit_code", -1))
return {"status": "error", "message": "Failed to configure %s: %s" % [client.display_name, err]}
## Run the descriptor's `cli_status_args`, scan stdout for `server_name` and
## `server_url`. The matching rule is the only sensible one for "list MCP
## entries" output across CLI clients we currently support: name AND url
## present → CONFIGURED; name only → MISMATCH; neither → NOT_CONFIGURED.
static func check_status(client: McpClient, server_name: String, server_url: String) -> McpClient.Status:
return check_status_with_cli_path(client, server_name, server_url, _resolve_cli(client))
static func check_status_with_cli_path(client: McpClient, server_name: String, server_url: String, cli: String) -> McpClient.Status:
return check_status_details(client, server_name, server_url, cli).get("status", McpClient.Status.NOT_CONFIGURED)
## Detailed variant used by the dock's refresh worker so it can surface a
## "probe timed out" badge on the affected row instead of silently
## conflating the timeout with NOT_CONFIGURED. Returns
## `{"status": Status, "error_msg": String}`. The caller plumbs
## `error_msg` straight into `_apply_row_status`.
static func check_status_details(client: McpClient, server_name: String, server_url: String, cli: String) -> Dictionary:
if cli.is_empty():
return _status_details(McpClient.Status.NOT_CONFIGURED)
if client.cli_status_args.is_empty():
return _status_details(McpClient.Status.NOT_CONFIGURED)
var result := McpCliExec.run(
cli,
McpClient._array_from_packed(client.cli_status_args),
_STATUS_TIMEOUT_MS,
false
)
if result.get("timed_out", false):
return _status_details(McpClient.Status.ERROR, "probe timed out")
if result.get("spawn_failed", false):
return _status_details(McpClient.Status.NOT_CONFIGURED)
if int(result.get("exit_code", -1)) != 0:
return _status_details(McpClient.Status.NOT_CONFIGURED)
var text := str(result.get("stdout", ""))
if text.find(server_name) < 0:
return _status_details(McpClient.Status.NOT_CONFIGURED)
## Server registered, but pointing somewhere else — drift after a
## port change. Surface as mismatch so the dock offers Reconfigure.
if text.find(server_url) < 0:
return _status_details(McpClient.Status.CONFIGURED_MISMATCH)
return _status_details(McpClient.Status.CONFIGURED)
static func _status_details(status: McpClient.Status, error_msg: String = "") -> Dictionary:
return {"status": status, "error_msg": error_msg}
static func remove(client: McpClient, server_name: String) -> Dictionary:
var cli := _resolve_cli(client)
if cli.is_empty():
return {"status": "error", "message": "%s not found" % client.display_name}
if client.cli_unregister_template.is_empty():
return {"status": "error", "message": "%s descriptor missing cli_unregister_template" % client.display_name}
var args := _format_args(client.cli_unregister_template, server_name, "")
var result := McpCliExec.run(cli, args, _REMOVE_TIMEOUT_MS)
if result.get("timed_out", false):
return {
"status": "error",
"message": "Remove %s timed out after %ds — see 'Run this manually' below to retry by hand" % [
client.display_name, _REMOVE_TIMEOUT_MS / 1000,
],
}
if result.get("spawn_failed", false):
return {"status": "error", "message": "Failed to spawn %s" % client.display_name}
if int(result.get("exit_code", -1)) == 0:
return {"status": "ok", "message": "%s configuration removed" % client.display_name}
## `claude mcp add` writes its real failure diagnostics to stderr, so
## prefer `output` (stdout + stderr) over `stdout` alone — otherwise
## the user sees "exit code 1" instead of the actual error.
var combined := str(result.get("output", "")).strip_edges()
var err := combined if not combined.is_empty() else "exit code %d" % int(result.get("exit_code", -1))
return {"status": "error", "message": "Failed to remove %s: %s" % [client.display_name, err]}
## Substitute `{name}` and `{url}` tokens in every template entry.
## Tokens match verbatim — `{name_suffix}` is NOT touched, so callers don't
## have to worry about partial-token collisions in their argv.
static func format_args(template: PackedStringArray, server_name: String, server_url: String) -> Array[String]:
return _format_args(template, server_name, server_url)
static func _format_args(template: PackedStringArray, server_name: String, server_url: String) -> Array[String]:
var out: Array[String] = []
for arg in template:
var s := String(arg)
s = s.replace("{name}", server_name)
s = s.replace("{url}", server_url)
out.append(s)
return out
static func _resolve_cli(client: McpClient) -> String:
return McpCliFinder.find(McpClient._array_from_packed(client.cli_names))
static func resolve_cli_path(client: McpClient) -> String:
return _resolve_cli(client)
@@ -0,0 +1 @@
uid://bvib7d8eabbcm
+263
View File
@@ -0,0 +1,263 @@
@tool
class_name McpJsonStrategy
extends RefCounted
## Readmergewrite strategy for JSON-backed MCP clients.
## All knobs come from the McpClient descriptor as plain data — no Callables.
## See `_base.gd` for why descriptors are data-only.
static func configure(client: McpClient, server_name: String, server_url: String) -> Dictionary:
var path := client.resolved_config_path()
if path.is_empty():
return {"status": "error", "message": "Could not resolve config path for %s on this OS" % client.display_name}
var read := _read_or_init(path)
if not read["ok"]:
return {"status": "error", "message": "Refusing to overwrite %s: %s. Fix or move the file, then re-run Configure." % [path, read["error"]]}
var config: Dictionary = read["data"]
var holder := _ensure_path(config, client.server_key_path)
## Pass the existing entry through so `build_entry` can preserve user-mutable
## state (auto-approval lists, `disabled` toggles) instead of resetting it
## to descriptor defaults on every Configure click. See `entry_initial_fields`
## docs in `_base.gd`.
var existing: Variant = holder.get(server_name, null)
holder[server_name] = build_entry(client, server_url, existing)
if not McpAtomicWrite.write(path, JSON.stringify(_narrow_integral_numbers(config), "\t", false)):
return {"status": "error", "message": "Cannot write to %s" % path}
return {"status": "ok", "message": "%s configured (HTTP: %s)" % [client.display_name, server_url]}
static func check_status(client: McpClient, server_name: String, server_url: String) -> McpClient.Status:
var path := client.resolved_config_path()
if path.is_empty() or not FileAccess.file_exists(path):
return McpClient.Status.NOT_CONFIGURED
var read := _read_or_init(path)
if not read["ok"]:
return McpClient.Status.NOT_CONFIGURED
var config: Dictionary = read["data"]
var holder := _walk_path(config, client.server_key_path)
if not (holder is Dictionary) or not holder.has(server_name):
return McpClient.Status.NOT_CONFIGURED
var entry = holder[server_name]
if not (entry is Dictionary):
return McpClient.Status.NOT_CONFIGURED
## An entry under `server_name` exists — if the URL doesn't match,
## that's drift (the user changed the port and the client config is stale),
## not "never configured". The dock surfaces that as an amber banner.
return McpClient.Status.CONFIGURED if verify_entry(client, entry, server_url) else McpClient.Status.CONFIGURED_MISMATCH
static func remove(client: McpClient, server_name: String) -> Dictionary:
var path := client.resolved_config_path()
if path.is_empty() or not FileAccess.file_exists(path):
return {"status": "ok", "message": "Not configured"}
var read := _read_or_init(path)
if not read["ok"]:
return {"status": "error", "message": "Refusing to rewrite %s: %s." % [path, read["error"]]}
var config: Dictionary = read["data"]
var holder := _walk_path(config, client.server_key_path)
if holder is Dictionary and holder.has(server_name):
holder.erase(server_name)
if not McpAtomicWrite.write(path, JSON.stringify(_narrow_integral_numbers(config), "\t", false)):
return {"status": "error", "message": "Cannot write to %s" % path}
return {"status": "ok", "message": "%s configuration removed" % client.display_name}
## Synthesize the entry dict the strategy will write under
## `server_key_path[server_name]`. For non-bridge clients this is the
## existing entry (if any) with `entry_url_field` + every
## `entry_extra_fields` key force-set (the verified type pins) and every
## `entry_initial_fields` key set ONLY when absent (preserves user state
## like `alwaysAllow`/`autoApprove` arrays). For bridge clients (Claude
## Desktop) it composes the uvx + mcp-proxy command shape unconditionally
## — the bridge form has no user-mutable surface.
static func build_entry(client: McpClient, server_url: String, existing: Variant = null) -> Dictionary:
match client.entry_uvx_bridge:
McpClient.UvxBridge.FLAT:
return {
"command": McpClient.resolve_uvx_path(),
"args": McpClient.mcp_proxy_bridge_args(server_url),
"env": _merge_bridge_env(existing),
}
var entry: Dictionary = (existing as Dictionary).duplicate() if existing is Dictionary else {}
entry[client.entry_url_field] = server_url
for k in client.entry_extra_fields:
entry[k] = client.entry_extra_fields[k]
for k in client.entry_initial_fields:
if not entry.has(k):
entry[k] = client.entry_initial_fields[k]
return entry
## Default verifier for a stored entry. For bridge clients, recognise the
## bridge form (and, for `flat`, the future url-style form too — keeps the
## tolerance Claude Desktop has had since the npx-bridge migration).
##
## For non-bridge clients: assert `entry[entry_url_field] == url` AND every
## key in `entry_extra_fields` matches verbatim. Type-pinning for Cline /
## Roo / Kilo (`type: "streamable-http"` etc.) falls out of this — pre-fix
## entries that lack the type field fail verification and surface as drift.
static func verify_entry(client: McpClient, entry: Dictionary, server_url: String) -> bool:
match client.entry_uvx_bridge:
McpClient.UvxBridge.FLAT:
# Future url-style entry: accept if Claude Desktop ever speaks HTTP natively.
if entry.get(client.entry_url_field, "") == server_url:
return true
var cmd = entry.get("command", "")
if not (cmd is String and _command_is_uvx_like(cmd as String)):
return false
if not _bridge_args_are_valid(entry.get("args", []), server_url):
return false
return _bridge_env_matches(entry)
if entry.get(client.entry_url_field, "") != server_url:
return false
for k in client.entry_extra_fields:
if entry.get(k) != client.entry_extra_fields[k]:
return false
return true
## Pre-fix entries lack `env.UV_LINK_MODE=copy` and hit the Windows uvx
## hard-link race documented in `utils/uv_cache_cleanup.gd`. Flag them as
## drift so the dock surfaces an amber banner and a Configure-click
## rewrites the entry with the env pin. Every key in `bridge_env_for_uvx()`
## must match verbatim — extra user keys are tolerated so a hand-added
## `PYTHONUNBUFFERED=1` etc. doesn't trigger drift forever.
static func _bridge_env_matches(entry: Dictionary) -> bool:
var env = entry.get("env", null)
if not (env is Dictionary):
return false
var pin := McpClient.bridge_env_for_uvx()
for k in pin:
if env.get(k) != pin[k]:
return false
return true
## Configure rewrites the bridge entry wholesale (the bridge form is
## identity-defined by command+args+env), but the verifier tolerates extra
## user-added env keys like `HTTP_PROXY` / `PYTHONUNBUFFERED`. Without
## merging, a Configure click on a CONFIGURED_MISMATCH entry would silently
## drop those keys — so layer the UV_LINK_MODE pin over whatever env block
## already exists on disk. New entries with no prior env get just the pin.
static func _merge_bridge_env(existing: Variant) -> Dictionary:
var pin := McpClient.bridge_env_for_uvx()
if not (existing is Dictionary):
return pin
var existing_env = (existing as Dictionary).get("env", null)
if not (existing_env is Dictionary):
return pin
var merged: Dictionary = (existing_env as Dictionary).duplicate()
for k in pin:
merged[k] = pin[k]
return merged
## Basename match for `uvx` / `uvx.exe`, accepting both the bare-name
## fallback and an absolute path resolved by `McpCliFinder`. Used by the
## FLAT bridge verifier — the only place we ever inspect a stored bridge
## command/path.
static func _command_is_uvx_like(cmd: String) -> bool:
var basename := cmd.get_file()
return basename == "uvx" or basename == "uvx.exe"
## Strict bridge-argv check: the args array must include the pinned
## `mcp-proxy` package spec, the `--transport streamablehttp` selector, and
## the expected URL. Pre-fix `args.has(url)` was lenient — entries with the
## wrong transport (`--transport sse`) or a different package would still
## verify CONFIGURED, hiding the broken bridge. Match `mcp-proxy` by prefix
## so a future MCP_PROXY_VERSION bump doesn't churn the verifier.
static func _bridge_args_are_valid(args: Variant, server_url: String) -> bool:
if not (args is Array):
return false
var has_mcp_proxy := false
for a in args:
if a is String and (a as String).begins_with("mcp-proxy"):
has_mcp_proxy = true
break
if not has_mcp_proxy:
return false
if not (args.has("--transport") and args.has("streamablehttp") and args.has(server_url)):
return false
return true
## Returns {"ok": true, "data": Dictionary} when the file is absent or parses
## cleanly, and {"ok": false, "error": String} when the file exists with
## non-empty content we cannot safely round-trip. Callers must NOT fall back
## to an empty dict on the error path — doing so blows away the user's other
## MCP entries on the next write.
static func _read_or_init(path: String) -> Dictionary:
if not FileAccess.file_exists(path):
return {"ok": true, "data": {}}
var file := FileAccess.open(path, FileAccess.READ)
if file == null:
var err := FileAccess.get_open_error()
return {"ok": false, "error": "could not open for reading (error %d)" % err}
var content := file.get_as_text()
file.close()
# Strip a UTF-8 BOM if present — some editors (notably on Windows) save
# JSON with a leading , which Godot's JSON.parse rejects outright.
# Previously this landed on the "unparseable → wipe" path.
if content.begins_with(""):
content = content.substr(1)
if content.strip_edges().is_empty():
return {"ok": true, "data": {}}
var json := JSON.new()
if json.parse(content) != OK:
var msg := "JSON parse error on line %d: %s" % [json.get_error_line(), json.get_error_message()]
push_warning("MCP | %s in %s" % [msg, path])
return {"ok": false, "error": msg}
if not (json.data is Dictionary):
return {"ok": false, "error": "top-level value is %s, expected object" % type_string(typeof(json.data))}
return {"ok": true, "data": json.data}
## Walk a key path, creating intermediate Dicts as needed. Returns the leaf Dict.
static func _ensure_path(root: Dictionary, key_path: PackedStringArray) -> Dictionary:
var cur := root
for key in key_path:
var next = cur.get(key)
if not (next is Dictionary):
next = {}
cur[key] = next
cur = next
return cur
## Walk a key path, returning the leaf Dict if all hops exist; else null.
static func _walk_path(root: Dictionary, key_path: PackedStringArray) -> Variant:
var cur: Variant = root
for key in key_path:
if not (cur is Dictionary) or not cur.has(key):
return null
cur = cur[key]
return cur
## Godot's JSON.parse turns every JSON number into a float, so a later
## JSON.stringify re-emits the user's integer fields as "8080.0" — which strict
## consumers (Go's encoding/json into an int field, etc.) reject, and which
## needlessly rewrites every number across the user's *other* entries. Re-narrow
## exactly-representable integral floats back to int so they serialize without
## the ".0". Walks dicts/arrays in place and returns the (same) value.
##
## Integers above 2^53 already lost precision when Godot parsed them to double,
## so they're left as the float Godot produced rather than faking exactness —
## byte-perfect preservation would require not parsing the file at all, and such
## magnitudes don't occur in MCP client configs.
static func _narrow_integral_numbers(value: Variant) -> Variant:
match typeof(value):
TYPE_FLOAT:
if is_finite(value) and value == floor(value) and absf(value) <= 9007199254740992.0:
return int(value)
TYPE_DICTIONARY:
for k in value:
value[k] = _narrow_integral_numbers(value[k])
TYPE_ARRAY:
for i in value.size():
value[i] = _narrow_integral_numbers(value[i])
return value
@@ -0,0 +1 @@
uid://g8a4iijpk22w
+113
View File
@@ -0,0 +1,113 @@
@tool
class_name McpManualCommand
extends RefCounted
## Synthesize the "Run this manually" string the dock surfaces when
## auto-configure can't find a CLI / write a file. Generated from the
## descriptor's declarative fields — there is no per-client builder
## Callable. See `_base.gd` for why descriptors are data-only.
static func build(client: McpClient, server_name: String, server_url: String, resolved_path: String) -> String:
match client.config_type:
"cli":
return _build_cli(client, server_name, server_url, resolved_path)
"json":
return _build_json(client, server_name, server_url, resolved_path)
"toml":
return _build_toml(client, server_name, server_url, resolved_path)
return ""
## CLI clients: format the register template against the *short* CLI name so
## the user can paste it into a terminal regardless of where their binary
## lives. (The auto-configure path resolves to an absolute uvx-style path;
## that's noise for a paste-into-terminal hint.)
static func _build_cli(client: McpClient, server_name: String, server_url: String, resolved_path: String = "") -> String:
if client.cli_register_template.is_empty() or client.cli_names.is_empty():
return ""
var short_name: String = String(client.cli_names[0])
# Prefer the non-.exe form for a cross-platform-looking command line.
for n in client.cli_names:
if not String(n).ends_with(".exe"):
short_name = String(n)
break
var args := McpCliStrategy.format_args(client.cli_register_template, server_name, server_url)
var parts: Array[String] = [short_name]
parts.append_array(args)
var cmd := " ".join(parts)
# #463: a CLI client with a JSON fallback (Claude Code) may have no `claude`
# binary at all — e.g. installed only as a VS Code/Cursor extension. The CLI
# line above is useless to that user, so also show the config-file edit that
# auto-configure falls back to writing.
if client.has_json_fallback() and not resolved_path.is_empty():
return "%s\n\nNo `%s` CLI (e.g. installed as a VS Code/Cursor extension)? %s" % [
cmd, short_name, _build_json(client, server_name, server_url, resolved_path),
]
return cmd
static func _build_json(client: McpClient, server_name: String, server_url: String, resolved_path: String) -> String:
var entry := McpJsonStrategy.build_entry(client, server_url)
var entry_text := _format_entry_inline(entry)
var key := client.server_key_path[0] if client.server_key_path.size() > 0 else "mcpServers"
return "Edit %s and add under \"%s\":\n \"%s\": %s" % [resolved_path, key, server_name, entry_text]
static func _build_toml(client: McpClient, _server_name: String, server_url: String, resolved_path: String) -> String:
var header := _toml_header(client)
var body := McpTomlStrategy.format_body(client.toml_body_template, server_url)
var lines: Array[String] = ["Edit %s and add:" % resolved_path, " %s" % header]
for b in body:
lines.append(" %s" % String(b))
return "\n".join(lines)
## Mirrors the [section."name"] header `_toml_strategy._primary_header`
## emits, kept here so the manual-command text matches the file we'd write.
static func _toml_header(client: McpClient) -> String:
var parts := client.toml_section_path
if parts.size() < 2:
return "[%s]" % ".".join(parts)
var section := ".".join(McpClient._array_from_packed(McpClient._packed_slice(parts, 0, parts.size() - 1)))
var name := parts[parts.size() - 1]
return "[%s.\"%s\"]" % [section, name]
## Format an entry dict as a single inline JSON-ish string, matching the
## pre-refactor manual-command style: `{ "k": v, "k": v }` with spaces.
## Pre-existing manual-command tests assert the exact substring shape; this
## keeps them stable.
##
## Uses `JSON.stringify` for every leaf String (key OR value) so paths
## containing backslashes / quotes / newlines render as syntactically valid
## JSON. A Windows uvx path like `C:\Users\foo\uvx.exe` would otherwise be
## emitted as `"C:\Users\foo\uvx.exe"` — invalid JSON, unsafe to paste.
static func _format_entry_inline(entry: Dictionary) -> String:
var parts: Array[String] = []
for k in entry:
parts.append("%s: %s" % [JSON.stringify(String(k)), _format_value(entry[k])])
if parts.is_empty():
return "{}"
return "{ %s }" % ", ".join(parts)
static func _format_value(value: Variant) -> String:
# Strings, bools, numbers, null all round-trip correctly through JSON.stringify
# without spurious quoting of non-string scalars (true → `true`, 5 → `5`).
# Arrays and Dictionaries are formatted manually so the inline ` { k: v } `
# spacing matches the pre-refactor manual-command output shape that tests
# pin with assert_contains.
if value is Array:
var arr_parts: Array[String] = []
for v in value:
arr_parts.append(_format_value(v))
return "[%s]" % ", ".join(arr_parts)
if value is Dictionary:
var d_parts: Array[String] = []
for k in value:
d_parts.append("%s: %s" % [JSON.stringify(String(k)), _format_value(value[k])])
if d_parts.is_empty():
return "{}"
return "{ %s }" % ", ".join(d_parts)
return JSON.stringify(value)
@@ -0,0 +1 @@
uid://ct1wmgfk408x0
+62
View File
@@ -0,0 +1,62 @@
@tool
class_name McpPathTemplate
extends RefCounted
## Expands ~ / $HOME / $APPDATA / $XDG_CONFIG_HOME / $LOCALAPPDATA / $USERPROFILE
## inside path templates so per-client descriptors can declare paths declaratively
## without hand-rolling per-OS lookups.
## Pick the right entry from a {"darwin": ..., "windows": ..., "linux": ...} map.
static func resolve(template_map: Dictionary) -> String:
var key := _os_key()
if not template_map.has(key):
# Allow "unix" as a shorthand for both macOS and Linux.
if (key == "darwin" or key == "linux") and template_map.has("unix"):
key = "unix"
else:
return ""
var template: String = template_map[key]
return expand(template)
## Substitute env vars and ~ in a single template string.
static func expand(template: String) -> String:
if template.is_empty():
return ""
var out := template
if out.begins_with("~/") or out == "~":
var home := _home()
out = home if out == "~" else home.path_join(out.substr(2))
# $HOME, $APPDATA, $LOCALAPPDATA, $USERPROFILE, $XDG_CONFIG_HOME
for var_name in ["XDG_CONFIG_HOME", "LOCALAPPDATA", "USERPROFILE", "APPDATA", "HOME"]:
var token := "$%s" % var_name
if out.find(token) >= 0:
var value := OS.get_environment(var_name)
if value.is_empty() and var_name == "XDG_CONFIG_HOME":
value = _home().path_join(".config")
if value.is_empty() and var_name == "APPDATA":
value = _home().path_join("AppData/Roaming")
if value.is_empty() and var_name == "LOCALAPPDATA":
value = _home().path_join("AppData/Local")
if value.is_empty() and var_name == "HOME":
value = _home()
out = out.replace(token, value)
return out
static func _os_key() -> String:
match OS.get_name():
"macOS":
return "darwin"
"Windows":
return "windows"
_:
return "linux"
static func _home() -> String:
var h := OS.get_environment("HOME")
if h.is_empty():
h = OS.get_environment("USERPROFILE")
return h
@@ -0,0 +1 @@
uid://5pd418va35ms
+71
View File
@@ -0,0 +1,71 @@
@tool
class_name McpClientRegistry
extends RefCounted
## Central enumeration of every supported MCP client. Adding a new client
## means: drop a file in clients/, then append one preload below.
const _CLIENT_SCRIPTS := [
preload("res://addons/godot_ai/clients/claude_code.gd"),
preload("res://addons/godot_ai/clients/claude_desktop.gd"),
preload("res://addons/godot_ai/clients/codex.gd"),
preload("res://addons/godot_ai/clients/antigravity.gd"),
preload("res://addons/godot_ai/clients/cursor.gd"),
preload("res://addons/godot_ai/clients/windsurf.gd"),
preload("res://addons/godot_ai/clients/vscode.gd"),
preload("res://addons/godot_ai/clients/vscode_insiders.gd"),
preload("res://addons/godot_ai/clients/zed.gd"),
preload("res://addons/godot_ai/clients/gemini_cli.gd"),
preload("res://addons/godot_ai/clients/cline.gd"),
preload("res://addons/godot_ai/clients/kilo_code.gd"),
preload("res://addons/godot_ai/clients/roo_code.gd"),
preload("res://addons/godot_ai/clients/kiro.gd"),
preload("res://addons/godot_ai/clients/trae.gd"),
preload("res://addons/godot_ai/clients/cherry_studio.gd"),
preload("res://addons/godot_ai/clients/opencode.gd"),
preload("res://addons/godot_ai/clients/qwen_code.gd"),
preload("res://addons/godot_ai/clients/kimi_code.gd"),
]
static var _instances: Array[McpClient] = []
static var _by_id: Dictionary = {}
static func all() -> Array[McpClient]:
if _instances.is_empty():
_load()
return _instances
static func get_by_id(id: String) -> McpClient:
if _instances.is_empty():
_load()
return _by_id.get(id, null)
static func ids() -> PackedStringArray:
var out := PackedStringArray()
for c in all():
out.append(c.id)
return out
static func has_id(id: String) -> bool:
if _instances.is_empty():
_load()
return _by_id.has(id)
static func _load() -> void:
_instances.clear()
_by_id.clear()
for script in _CLIENT_SCRIPTS:
var inst: McpClient = script.new()
if inst.id.is_empty():
push_warning("MCP | client descriptor %s has empty id" % script.resource_path)
continue
if _by_id.has(inst.id):
push_warning("MCP | duplicate client id: %s" % inst.id)
continue
_instances.append(inst)
_by_id[inst.id] = inst
+1
View File
@@ -0,0 +1 @@
uid://bxougoq8xwg1
+269
View File
@@ -0,0 +1,269 @@
@tool
class_name McpTomlStrategy
extends RefCounted
## Minimal TOML upsert: replace or insert one [section."name"] block whose body
## comes from substituting `{url}` in `client.toml_body_template`. No
## descriptor-supplied Callables — see `_base.gd`.
static func configure(client: McpClient, _server_name: String, server_url: String) -> Dictionary:
var path := client.resolved_config_path()
if path.is_empty():
return {"status": "error", "message": "Could not resolve config path for %s" % client.display_name}
var read := _read_or_init(path)
if not read["ok"]:
return {"status": "error", "message": "Refusing to overwrite %s: %s. Fix or move the file, then re-run Configure." % [path, read["error"]]}
if client.toml_body_template.is_empty():
return {"status": "error", "message": "%s descriptor missing toml_body_template" % client.display_name}
var lines: Array[String] = _split_lines(String(read["data"]))
var body: PackedStringArray = format_body(client.toml_body_template, server_url)
var section := _find_section(lines, _all_headers(client))
var header := _primary_header(client)
var new_lines: Array[String] = [header]
for b in body:
new_lines.append(b)
var output: Array[String] = []
if section.is_empty():
output.append_array(lines)
if not output.is_empty() and not output[-1].strip_edges().is_empty():
output.append("")
output.append_array(new_lines)
else:
output.append_array(_slice(lines, 0, section["start"]))
output.append_array(new_lines)
output.append_array(_slice(lines, section["end"], lines.size()))
if not McpAtomicWrite.write(path, "\n".join(output)):
return {"status": "error", "message": "Cannot write to %s" % path}
return {"status": "ok", "message": "%s configured (HTTP: %s)" % [client.display_name, server_url]}
static func check_status(client: McpClient, _server_name: String, server_url: String) -> McpClient.Status:
var path := client.resolved_config_path()
if path.is_empty() or not FileAccess.file_exists(path):
return McpClient.Status.NOT_CONFIGURED
var read := _read_or_init(path)
if not read["ok"]:
return McpClient.Status.NOT_CONFIGURED
var lines: Array[String] = _split_lines(String(read["data"]))
var section := _find_section(lines, _all_headers(client))
if section.is_empty():
return McpClient.Status.NOT_CONFIGURED
var configured_url := ""
var enabled := true
for i in range(section["start"] + 1, section["end"]):
var trimmed := lines[i].strip_edges()
if trimmed.begins_with("url ="):
var first := trimmed.find("\"")
var last := trimmed.rfind("\"")
if first >= 0 and last > first:
configured_url = trimmed.substr(first + 1, last - first - 1)
elif trimmed.begins_with("enabled ="):
enabled = trimmed.to_lower().find("false") < 0
## Section exists with our `SERVER_NAME` header — a URL mismatch (or a
## disabled entry) is drift, not "never configured". See `_base.gd`.
if configured_url != server_url or not enabled:
return McpClient.Status.CONFIGURED_MISMATCH
return McpClient.Status.CONFIGURED
static func remove(client: McpClient, _server_name: String) -> Dictionary:
var path := client.resolved_config_path()
if path.is_empty() or not FileAccess.file_exists(path):
return {"status": "ok", "message": "Not configured"}
var read := _read_or_init(path)
if not read["ok"]:
return {"status": "error", "message": "Refusing to rewrite %s: %s." % [path, read["error"]]}
var lines: Array[String] = _split_lines(String(read["data"]))
var headers := _all_headers(client)
## Subtables in the namespace (e.g. [mcp_servers.godot-ai.tools.session_list]
## that codex users add to set per-tool approval_mode) must be removed
## too. Leaving them behind keeps `mcp_servers.godot-ai` implicitly
## defined, so a later configure that writes [mcp_servers."godot-ai"]
## produces a duplicate-key TOML error.
var subtable_prefixes := _subtable_prefixes(headers)
var output: Array[String] = []
var i := 0
while i < lines.size():
if _matches_any_header(lines[i], headers) or _matches_subtable_prefix(lines[i], subtable_prefixes):
i += 1
while i < lines.size():
if _is_any_section_header(lines[i]):
break
i += 1
continue
output.append(lines[i])
i += 1
if not McpAtomicWrite.write(path, "\n".join(output)):
return {"status": "error", "message": "Cannot write to %s" % path}
return {"status": "ok", "message": "%s configuration removed" % client.display_name}
## Substitute `{url}` in every body-template line.
static func format_body(template: PackedStringArray, server_url: String) -> PackedStringArray:
var out := PackedStringArray()
for line in template:
out.append(String(line).replace("{url}", server_url))
return out
# --- helpers --------------------------------------------------------------
## Returns {"ok": true, "data": String} when the file is absent or readable,
## and {"ok": false, "error": String} when the file exists but cannot be
## opened. Callers must NOT fall back to an empty string on the error path —
## doing so blows away the user's other MCP entries on the next write.
static func _read_or_init(path: String) -> Dictionary:
if not FileAccess.file_exists(path):
return {"ok": true, "data": ""}
var f := FileAccess.open(path, FileAccess.READ)
if f == null:
var err := FileAccess.get_open_error()
return {"ok": false, "error": "could not open for reading (error %d)" % err}
var t := f.get_as_text()
f.close()
return {"ok": true, "data": t}
static func _split_lines(content: String) -> Array[String]:
var out: Array[String] = []
for line in content.split("\n"):
out.append(line)
return out
static func _slice(lines: Array[String], from: int, to: int) -> Array[String]:
var out: Array[String] = []
for i in range(from, to):
out.append(lines[i])
return out
static func _primary_header(client: McpClient) -> String:
# Quoted form: [section."name"] for ids that contain hyphens.
var parts := client.toml_section_path
if parts.size() < 2:
return "[%s]" % ".".join(parts)
var section := ".".join(McpClient._packed_slice(parts, 0, parts.size() - 1))
var name := parts[parts.size() - 1]
return "[%s.\"%s\"]" % [section, name]
static func _all_headers(client: McpClient) -> Array[String]:
var primary := _primary_header(client)
var out: Array[String] = [primary]
## TOML accepts bare keys ([A-Za-z0-9_-]+) unquoted in section headers,
## so [mcp_servers.godot-ai] is a valid hand-written form of the same
## logical key we emit as [mcp_servers."godot-ai"]. Match both during
## reconfigure / status / remove or a hand-edited (or older-plugin)
## bare-key file gets a duplicate quoted section appended that breaks
## the user's TOML parser.
var bare := _bare_key_header(client)
if not bare.is_empty() and bare != primary:
out.append(bare)
for legacy in client.toml_legacy_section_aliases:
out.append("[%s]" % legacy)
return out
static func _bare_key_header(client: McpClient) -> String:
var parts := client.toml_section_path
if parts.is_empty():
return ""
for p in parts:
if not _is_bare_key(String(p)):
return ""
return "[%s]" % ".".join(parts)
static func _is_bare_key(s: String) -> bool:
if s.is_empty():
return false
for i in range(s.length()):
var c := s.unicode_at(i)
var alpha := (c >= 65 and c <= 90) or (c >= 97 and c <= 122)
var digit := c >= 48 and c <= 57
var dash_or_under := c == 45 or c == 95 # '-' or '_'
if not (alpha or digit or dash_or_under):
return false
return true
## Subtable prefixes derived from each header in `headers`. Strips the
## closing `]` and appends `.` so a header `[a.b]` becomes the prefix
## `[a.b.` — matching subtables `[a.b.<rest>]` but NOT siblings like
## `[a.b-other]` (next char must be a dot, not anything bare-key-valid).
static func _subtable_prefixes(headers: Array[String]) -> Array[String]:
var out: Array[String] = []
for h in headers:
if h.length() > 2 and h.ends_with("]"):
out.append(h.substr(0, h.length() - 1) + ".")
return out
## Mirror of `_matches_any_header` for subtable prefixes — line must
## start with `[a.b.` and have a closing `]` followed only by whitespace
## or a comment.
static func _matches_subtable_prefix(line: String, prefixes: Array[String]) -> bool:
var trimmed := line.strip_edges()
for p in prefixes:
if not trimmed.begins_with(p):
continue
var rest := trimmed.substr(p.length())
var bracket := rest.find("]")
if bracket < 0:
continue
var remainder := rest.substr(bracket + 1).strip_edges()
if remainder.is_empty() or remainder.begins_with("#"):
return true
return false
## Exact-header match. We cannot use a simple prefix check because
## `[mcp_servers."godot-ai"` is a prefix of `[mcp_servers."godot-ai-dev"]`,
## which would silently delete unrelated sections during remove().
static func _matches_any_header(line: String, headers: Array[String]) -> bool:
var trimmed := line.strip_edges()
for h in headers:
if not trimmed.begins_with(h):
continue
var remainder := trimmed.substr(h.length()).strip_edges()
if remainder.is_empty() or remainder.begins_with("#"):
return true
return false
static func _find_section(lines: Array[String], headers: Array[String]) -> Dictionary:
for i in range(lines.size()):
if _matches_any_header(lines[i], headers):
var end := lines.size()
for j in range(i + 1, lines.size()):
if _is_any_section_header(lines[j]):
end = j
break
return {"start": i, "end": end}
return {}
## Generic "is this line a TOML section header" check that tolerates an
## inline comment after the closing `]`, e.g. `[next_section] # note`.
## The pre-fix `nt.begins_with("[") and nt.ends_with("]")` rejected those
## lines, so a hand-written comment after a header would let the
## section-deletion / section-end loops walk straight through into the
## following section and clobber unrelated content.
static func _is_any_section_header(line: String) -> bool:
var trimmed := line.strip_edges()
if not trimmed.begins_with("["):
return false
var bracket := trimmed.find("]")
if bracket < 0:
return false
var remainder := trimmed.substr(bracket + 1).strip_edges()
return remainder.is_empty() or remainder.begins_with("#")
@@ -0,0 +1 @@
uid://cwdvxgn0aurqv
+19
View File
@@ -0,0 +1,19 @@
@tool
extends McpClient
func _init() -> void:
id = "antigravity"
display_name = "Antigravity"
config_type = "json"
doc_url = "https://www.antigravity.dev/"
path_template = {
"unix": "~/.gemini/antigravity/mcp_config.json",
"windows": "$USERPROFILE/.gemini/antigravity/mcp_config.json",
}
server_key_path = PackedStringArray(["mcpServers"])
entry_url_field = "serverUrl"
## `disabled` is user-state (they may have flipped the entry off in the
## UI); seeded on first Configure but preserved across reconfigure.
entry_initial_fields = {"disabled": false}
detect_paths = PackedStringArray(path_template.values())
@@ -0,0 +1 @@
uid://b4l1g0apa2hch
+20
View File
@@ -0,0 +1,20 @@
@tool
extends McpClient
func _init() -> void:
id = "cherry_studio"
display_name = "Cherry Studio"
config_type = "json"
doc_url = "https://docs.cherry-ai.com/advanced-basic/mcp"
path_template = {
"darwin": "~/Library/Application Support/CherryStudio/mcp_servers.json",
"windows": "$APPDATA/CherryStudio/mcp_servers.json",
"linux": "$XDG_CONFIG_HOME/CherryStudio/mcp_servers.json",
}
server_key_path = PackedStringArray(["mcpServers"])
entry_extra_fields = {"type": "streamableHttp"}
## `isActive` is user-state (they may have toggled the server off in the UI).
## Seed on first Configure but preserve across reconfigure.
entry_initial_fields = {"isActive": true}
detect_paths = PackedStringArray(path_template.values())
@@ -0,0 +1 @@
uid://dwbuykxvbv5f7
+24
View File
@@ -0,0 +1,24 @@
@tool
extends McpClient
func _init() -> void:
id = "claude_code"
display_name = "Claude Code"
config_type = "cli"
doc_url = "https://docs.anthropic.com/en/docs/claude-code"
cli_names = PackedStringArray(["claude", "claude.exe"] if OS.get_name() == "Windows" else ["claude"])
cli_register_template = PackedStringArray(
["mcp", "add", "--scope", "user", "--transport", "http", "{name}", "{url}"]
)
cli_unregister_template = PackedStringArray(["mcp", "remove", "{name}"])
cli_status_args = PackedStringArray(["mcp", "list"])
## #463: JSON fallback for when the `claude` binary isn't on PATH — e.g.
## Claude Code installed only as a VS Code / Cursor extension. The CLI is
## still preferred whenever it resolves; this is what gets written
## otherwise. `claude mcp add --scope user --transport http` produces
## exactly this shape under `mcpServers` in ~/.claude.json:
## "godot-ai": { "type": "http", "url": "<url>" }
path_template = {"unix": "~/.claude.json", "windows": "~/.claude.json"}
server_key_path = PackedStringArray(["mcpServers"])
entry_extra_fields = {"type": "http"}
@@ -0,0 +1 @@
uid://cp1u1hdpa6f8d
+24
View File
@@ -0,0 +1,24 @@
@tool
extends McpClient
## Claude Desktop's mcpServers entries are stdio-only, so we bridge our HTTP
## server through `uvx mcp-proxy --transport streamablehttp <url>`. `uvx` is
## already a plugin prereq, so this works without requiring Node.js.
func _init() -> void:
id = "claude_desktop"
display_name = "Claude Desktop"
config_type = "json"
doc_url = "https://claude.ai/download"
path_template = {
"darwin": "~/Library/Application Support/Claude/claude_desktop_config.json",
"windows": "$APPDATA/Claude/claude_desktop_config.json",
"linux": "$XDG_CONFIG_HOME/Claude/claude_desktop_config.json",
}
server_key_path = PackedStringArray(["mcpServers"])
## FLAT bridge: `{"command": "<uvx>", "args": [...]}`. The default
## verifier ALSO accepts a future url-style entry (Claude Desktop has
## been tolerant of both forms since the npx→uvx bridge migration).
entry_uvx_bridge = McpClient.UvxBridge.FLAT
detect_paths = PackedStringArray(path_template.values())
@@ -0,0 +1 @@
uid://bilntn5n8oqe3
+29
View File
@@ -0,0 +1,29 @@
@tool
extends McpClient
## Cline is a VS Code extension. Its MCP settings live in VS Code's
## globalStorage under the extension id `saoudrizwan.claude-dev`.
func _init() -> void:
id = "cline"
display_name = "Cline"
config_type = "json"
doc_url = "https://github.com/cline/cline"
path_template = {
"darwin": "~/Library/Application Support/Code/User/globalStorage/saoudrizwan.claude-dev/settings/cline_mcp_settings.json",
"windows": "$APPDATA/Code/User/globalStorage/saoudrizwan.claude-dev/settings/cline_mcp_settings.json",
"linux": "$XDG_CONFIG_HOME/Code/User/globalStorage/saoudrizwan.claude-dev/settings/cline_mcp_settings.json",
}
server_key_path = PackedStringArray(["mcpServers"])
## Cline (like Roo) defaults a typeless entry to SSE transport, which
## returns HTTP 400 against our streamable-http endpoint on `/mcp`. Pin
## the type explicitly. Cline's schema uses "streamableHttp" (camelCase,
## see src/services/mcp/schemas.ts in the cline repo) — distinct from
## Roo's "streamable-http" string. Parallel to the Roo fix in #190.
entry_extra_fields = {"type": "streamableHttp"}
## `disabled` and `autoApprove` are user-state (they may have flipped the
## entry off, or auto-approved specific tools). Seed on first Configure
## but preserve across reconfigure — see `entry_initial_fields` in `_base.gd`.
entry_initial_fields = {"disabled": false, "autoApprove": []}
detect_paths = PackedStringArray(path_template.values())
+1
View File
@@ -0,0 +1 @@
uid://d36nywn2nkgts
+18
View File
@@ -0,0 +1,18 @@
@tool
extends McpClient
func _init() -> void:
id = "codex"
display_name = "Codex"
config_type = "toml"
doc_url = "https://openai.com/index/codex/"
path_template = {"unix": "~/.codex/config.toml", "windows": "$USERPROFILE/.codex/config.toml"}
toml_section_path = PackedStringArray(["mcp_servers", "godot-ai"])
# Older Codex builds used the unquoted form with underscore-substituted ids.
toml_legacy_section_aliases = PackedStringArray(["mcp_servers.godot_ai"])
toml_body_template = PackedStringArray([
"url = \"{url}\"",
"enabled = true",
])
detect_paths = PackedStringArray(path_template.values())
+1
View File
@@ -0,0 +1 @@
uid://hdlwcfdr8mdk
+12
View File
@@ -0,0 +1,12 @@
@tool
extends McpClient
func _init() -> void:
id = "cursor"
display_name = "Cursor"
config_type = "json"
doc_url = "https://docs.cursor.com/context/model-context-protocol"
path_template = {"unix": "~/.cursor/mcp.json", "windows": "$USERPROFILE/.cursor/mcp.json"}
server_key_path = PackedStringArray(["mcpServers"])
detect_paths = PackedStringArray(path_template.values())
+1
View File
@@ -0,0 +1 @@
uid://bvpbssfanukef
+16
View File
@@ -0,0 +1,16 @@
@tool
extends McpClient
func _init() -> void:
id = "gemini_cli"
display_name = "Gemini CLI"
config_type = "json"
doc_url = "https://github.com/google-gemini/gemini-cli"
path_template = {
"unix": "~/.gemini/settings.json",
"windows": "$USERPROFILE/.gemini/settings.json",
}
server_key_path = PackedStringArray(["mcpServers"])
entry_url_field = "httpUrl"
detect_paths = PackedStringArray(path_template.values())
@@ -0,0 +1 @@
uid://b8288pxninajy
+24
View File
@@ -0,0 +1,24 @@
@tool
extends McpClient
func _init() -> void:
id = "kilo_code"
display_name = "Kilo Code"
config_type = "json"
doc_url = "https://kilocode.ai/docs/features/mcp/using-mcp-in-kilo-code"
path_template = {
"darwin": "~/Library/Application Support/Code/User/globalStorage/kilocode.kilo-code/settings/mcp_settings.json",
"windows": "$APPDATA/Code/User/globalStorage/kilocode.kilo-code/settings/mcp_settings.json",
"linux": "$XDG_CONFIG_HOME/Code/User/globalStorage/kilocode.kilo-code/settings/mcp_settings.json",
}
server_key_path = PackedStringArray(["mcpServers"])
## Kilo Code (like Roo) defaults a typeless entry to SSE transport, which
## returns HTTP 400 against our streamable-http endpoint on `/mcp`. Pin
## the type explicitly. Parallel to the Roo fix in #190.
entry_extra_fields = {"type": "streamable-http"}
## `disabled` and `alwaysAllow` are user-state (they may have flipped the
## entry off, or auto-approved specific tools). Seed on first Configure
## but preserve across reconfigure — see `entry_initial_fields` in `_base.gd`.
entry_initial_fields = {"disabled": false, "alwaysAllow": []}
detect_paths = PackedStringArray(path_template.values())
+1
View File
@@ -0,0 +1 @@
uid://dc1x77i1cmb6w
+15
View File
@@ -0,0 +1,15 @@
@tool
extends McpClient
func _init() -> void:
id = "kimi_code"
display_name = "Kimi Code"
config_type = "cli"
doc_url = "https://moonshotai.github.io/kimi-cli/"
cli_names = PackedStringArray(["kimi", "kimi.exe"] if OS.get_name() == "Windows" else ["kimi"])
cli_register_template = PackedStringArray(
["mcp", "add", "--transport", "http", "{name}", "{url}"]
)
cli_unregister_template = PackedStringArray(["mcp", "remove", "{name}"])
cli_status_args = PackedStringArray(["mcp", "list"])
+1
View File
@@ -0,0 +1 @@
uid://d2whd6a5fofhg
+17
View File
@@ -0,0 +1,17 @@
@tool
extends McpClient
func _init() -> void:
id = "kiro"
display_name = "Kiro"
config_type = "json"
doc_url = "https://kiro.dev/docs/mcp"
path_template = {
"unix": "~/.kiro/settings/mcp.json",
"windows": "$USERPROFILE/.kiro/settings/mcp.json",
}
server_key_path = PackedStringArray(["mcpServers"])
## `disabled` is user-state — preserved across reconfigure.
entry_initial_fields = {"disabled": false}
detect_paths = PackedStringArray(path_template.values())
+1
View File
@@ -0,0 +1 @@
uid://dqdmd2jw5qen7
+21
View File
@@ -0,0 +1,21 @@
@tool
extends McpClient
## OpenCode stores MCP servers under `mcp.<name>` (not the typical mcpServers
## map) and uses `type: "remote"` for HTTP servers.
func _init() -> void:
id = "opencode"
display_name = "OpenCode"
config_type = "json"
doc_url = "https://opencode.ai/docs/mcp-servers"
path_template = {
"unix": "~/.config/opencode/opencode.json",
"windows": "$HOME/.config/opencode/opencode.json",
}
server_key_path = PackedStringArray(["mcp"])
entry_extra_fields = {"type": "remote"}
## `enabled` is user-state (they may have toggled the server off).
entry_initial_fields = {"enabled": true}
detect_paths = PackedStringArray(path_template.values())
+1
View File
@@ -0,0 +1 @@
uid://s8n0vfirf2pj
+16
View File
@@ -0,0 +1,16 @@
@tool
extends McpClient
func _init() -> void:
id = "qwen_code"
display_name = "Qwen Code"
config_type = "json"
doc_url = "https://github.com/QwenLM/qwen-code"
path_template = {
"unix": "~/.qwen/settings.json",
"windows": "$USERPROFILE/.qwen/settings.json",
}
server_key_path = PackedStringArray(["mcpServers"])
entry_url_field = "httpUrl"
detect_paths = PackedStringArray(path_template.values())
+1
View File
@@ -0,0 +1 @@
uid://qwb5udkf423q
+29
View File
@@ -0,0 +1,29 @@
@tool
extends McpClient
func _init() -> void:
id = "roo_code"
display_name = "Roo Code"
config_type = "json"
doc_url = "https://docs.roocode.com/features/mcp/using-mcp-in-roo"
path_template = {
"darwin": "~/Library/Application Support/Code/User/globalStorage/rooveterinaryinc.roo-cline/settings/mcp_settings.json",
"windows": "$APPDATA/Code/User/globalStorage/rooveterinaryinc.roo-cline/settings/mcp_settings.json",
"linux": "$XDG_CONFIG_HOME/Code/User/globalStorage/rooveterinaryinc.roo-cline/settings/mcp_settings.json",
}
server_key_path = PackedStringArray(["mcpServers"])
## Roo defaults an entry with no "type" to SSE transport — which returns
## HTTP 400 against our streamable-http endpoint on `/mcp`. Pin the type
## explicitly so Roo negotiates streamable-http (the current MCP spec's
## recommended remote transport). See issue #189. The default verifier
## requires every entry_extra_fields key to match, so a pre-#189 typeless
## entry surfaces as drift instead of silently passing as configured.
entry_extra_fields = {"type": "streamable-http"}
## `disabled` and `alwaysAllow` are user-state (they may have flipped the
## entry off, or auto-approved specific tools like `session_manage`).
## Seed on first Configure but preserve across reconfigure — without this
## split, the Configure-All-Mismatched sweep silently wipes the user's
## auto-approval list every time the type pin or URL drifts.
entry_initial_fields = {"disabled": false, "alwaysAllow": []}
detect_paths = PackedStringArray(path_template.values())
+1
View File
@@ -0,0 +1 @@
uid://denjdf50qrf66
+16
View File
@@ -0,0 +1,16 @@
@tool
extends McpClient
func _init() -> void:
id = "trae"
display_name = "Trae"
config_type = "json"
doc_url = "https://docs.trae.ai/ide/model-context-protocol"
path_template = {
"darwin": "~/Library/Application Support/Trae/User/mcp.json",
"windows": "$APPDATA/Trae/User/mcp.json",
"linux": "$XDG_CONFIG_HOME/Trae/User/mcp.json",
}
server_key_path = PackedStringArray(["mcpServers"])
detect_paths = PackedStringArray(path_template.values())
+1
View File
@@ -0,0 +1 @@
uid://cwpu48772vfj1
+20
View File
@@ -0,0 +1,20 @@
@tool
extends McpClient
## VS Code (stable) reads MCP servers from per-user mcp.json under
## `servers.<name>` with `{ "type": "http", "url": ... }`.
func _init() -> void:
id = "vscode"
display_name = "VS Code"
config_type = "json"
doc_url = "https://code.visualstudio.com/docs/copilot/chat/mcp-servers"
path_template = {
"darwin": "~/Library/Application Support/Code/User/mcp.json",
"windows": "$APPDATA/Code/User/mcp.json",
"linux": "$XDG_CONFIG_HOME/Code/User/mcp.json",
}
server_key_path = PackedStringArray(["servers"])
entry_extra_fields = {"type": "http"}
detect_paths = PackedStringArray(path_template.values())
+1
View File
@@ -0,0 +1 @@
uid://dl6cm044pihub
@@ -0,0 +1,17 @@
@tool
extends McpClient
func _init() -> void:
id = "vscode_insiders"
display_name = "VS Code Insiders"
config_type = "json"
doc_url = "https://code.visualstudio.com/docs/copilot/chat/mcp-servers"
path_template = {
"darwin": "~/Library/Application Support/Code - Insiders/User/mcp.json",
"windows": "$APPDATA/Code - Insiders/User/mcp.json",
"linux": "$XDG_CONFIG_HOME/Code - Insiders/User/mcp.json",
}
server_key_path = PackedStringArray(["servers"])
entry_extra_fields = {"type": "http"}
detect_paths = PackedStringArray(path_template.values())
@@ -0,0 +1 @@
uid://cad5w4ofyg8a2
+16
View File
@@ -0,0 +1,16 @@
@tool
extends McpClient
func _init() -> void:
id = "windsurf"
display_name = "Windsurf"
config_type = "json"
doc_url = "https://docs.codeium.com/windsurf/mcp"
path_template = {
"unix": "~/.codeium/windsurf/mcp_config.json",
"windows": "$USERPROFILE/.codeium/windsurf/mcp_config.json",
}
server_key_path = PackedStringArray(["mcpServers"])
entry_url_field = "serverUrl"
detect_paths = PackedStringArray(path_template.values())
+1
View File
@@ -0,0 +1 @@
uid://b6pqiok2mlsmg
+19
View File
@@ -0,0 +1,19 @@
@tool
extends McpClient
## Zed registers MCP servers under `context_servers.<name>` and supports both
## stdio and streamable http transports.
func _init() -> void:
id = "zed"
display_name = "Zed"
config_type = "json"
doc_url = "https://zed.dev/docs/assistant/model-context-protocol"
path_template = {
"darwin": "~/.config/zed/settings.json",
"linux": "$XDG_CONFIG_HOME/zed/settings.json",
"windows": "$APPDATA/Zed/settings.json",
}
server_key_path = PackedStringArray(["context_servers"])
detect_paths = PackedStringArray(path_template.values())
+1
View File
@@ -0,0 +1 @@
uid://d152l0u0r6fsc