Replace dasher-pack with unified animation-pack using original Blender bone names
This commit is contained in:
@@ -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
|
||||
@@ -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"}
|
||||
@@ -0,0 +1 @@
|
||||
uid://cyowqr1x12ilg
|
||||
@@ -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()
|
||||
@@ -0,0 +1 @@
|
||||
uid://dhoe3ypkhm12v
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,263 @@
|
||||
@tool
|
||||
class_name McpJsonStrategy
|
||||
extends RefCounted
|
||||
|
||||
## Read–merge–write 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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
uid://bxougoq8xwg1
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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())
|
||||
@@ -0,0 +1 @@
|
||||
uid://d36nywn2nkgts
|
||||
@@ -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())
|
||||
@@ -0,0 +1 @@
|
||||
uid://hdlwcfdr8mdk
|
||||
@@ -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())
|
||||
@@ -0,0 +1 @@
|
||||
uid://bvpbssfanukef
|
||||
@@ -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
|
||||
@@ -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())
|
||||
@@ -0,0 +1 @@
|
||||
uid://dc1x77i1cmb6w
|
||||
@@ -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"])
|
||||
@@ -0,0 +1 @@
|
||||
uid://d2whd6a5fofhg
|
||||
@@ -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())
|
||||
@@ -0,0 +1 @@
|
||||
uid://dqdmd2jw5qen7
|
||||
@@ -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())
|
||||
@@ -0,0 +1 @@
|
||||
uid://s8n0vfirf2pj
|
||||
@@ -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())
|
||||
@@ -0,0 +1 @@
|
||||
uid://qwb5udkf423q
|
||||
@@ -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())
|
||||
@@ -0,0 +1 @@
|
||||
uid://denjdf50qrf66
|
||||
@@ -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())
|
||||
@@ -0,0 +1 @@
|
||||
uid://cwpu48772vfj1
|
||||
@@ -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())
|
||||
@@ -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
|
||||
@@ -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())
|
||||
@@ -0,0 +1 @@
|
||||
uid://b6pqiok2mlsmg
|
||||
@@ -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())
|
||||
@@ -0,0 +1 @@
|
||||
uid://d152l0u0r6fsc
|
||||
Reference in New Issue
Block a user