Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3b12d7b47e | ||
|
|
9b43f07fd8 | ||
|
|
83c45c5715 |
@@ -1,4 +1,3 @@
|
|||||||
# Godot 4+ specific ignores
|
# Godot 4+ specific ignores
|
||||||
.godot/
|
.godot/
|
||||||
/android/
|
/android/
|
||||||
/output
|
|
||||||
|
|||||||
@@ -1,54 +0,0 @@
|
|||||||
# Godot AI
|
|
||||||
|
|
||||||
Connect AI assistants to a live Godot editor via the [Model Context Protocol](https://modelcontextprotocol.io/introduction) (MCP).
|
|
||||||
|
|
||||||
Godot AI bridges Claude Code, Codex, Antigravity, DeepSeek Harness, and other MCP clients with your editor — inspect scenes, create nodes, modify properties, run tests, search project files, and more, all from a prompt.
|
|
||||||
|
|
||||||
## Quick Start
|
|
||||||
|
|
||||||
1. For a fresh install, verify and extract the signed v4 exact-tree archive;
|
|
||||||
for v3 migration, follow `docs/v4-migration.md` in the repository
|
|
||||||
2. Enable the plugin: **Project > Project Settings > Plugins > Godot AI**
|
|
||||||
3. Pick your MCP client in the **Godot AI** dock and press **Configure**
|
|
||||||
|
|
||||||
The plugin auto-starts the MCP server and connects over WebSocket. No manual configuration required.
|
|
||||||
|
|
||||||
## Requirements
|
|
||||||
|
|
||||||
- Godot 4.7+ within the 4.x line
|
|
||||||
- [uv](https://docs.astral.sh/uv/) (used to install the Python server)
|
|
||||||
<details>
|
|
||||||
<summary>Install uv</summary>
|
|
||||||
|
|
||||||
**macOS / Linux:**
|
|
||||||
```bash
|
|
||||||
curl -LsSf https://astral.sh/uv/install.sh | sh
|
|
||||||
```
|
|
||||||
|
|
||||||
**Windows (PowerShell):**
|
|
||||||
```powershell
|
|
||||||
powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex"
|
|
||||||
```
|
|
||||||
|
|
||||||
**Homebrew (macOS / Linux):**
|
|
||||||
```bash
|
|
||||||
brew install uv
|
|
||||||
```
|
|
||||||
|
|
||||||
**pipx:**
|
|
||||||
```bash
|
|
||||||
pipx install uv
|
|
||||||
```
|
|
||||||
|
|
||||||
See the [uv install docs](https://docs.astral.sh/uv/getting-started/installation/) for more options.
|
|
||||||
|
|
||||||
</details>
|
|
||||||
- An MCP client ([Claude Code](https://docs.anthropic.com/en/docs/claude-code) | [Codex](https://openai.com/index/codex/) | [Antigravity](https://www.antigravity.dev/))
|
|
||||||
|
|
||||||
## Documentation
|
|
||||||
|
|
||||||
Full documentation, contributing guide, and source code: [github.com/hi-godot/godot-ai](https://github.com/hi-godot/godot-ai)
|
|
||||||
|
|
||||||
## License
|
|
||||||
|
|
||||||
[MIT](LICENSE)
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1 +0,0 @@
|
|||||||
uid://1kiy8hqyymyj
|
|
||||||
@@ -1,251 +0,0 @@
|
|||||||
@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:
|
|
||||||
# Last line of defence for the path-resolution layer above. A relative (or
|
|
||||||
# empty) destination resolves against the EDITOR's working directory, not the
|
|
||||||
# caller's. The empty-path case is especially deceptive: editor safe-save
|
|
||||||
# runs mkstemp on "-XXXXXX", writes the payload into the project root, then
|
|
||||||
# fails only when renaming back to "" and leaves the random file behind.
|
|
||||||
# Callers gate this already (`McpClient.resolved_config_path_details`), but a
|
|
||||||
# writer that cannot tell where it is writing must refuse rather than guess.
|
|
||||||
if not path.is_absolute_path():
|
|
||||||
return false
|
|
||||||
# If the target is a symlink (stow/chezmoi-managed dotfiles), rename-over
|
|
||||||
# would replace the LINK with a regular file, silently detaching the
|
|
||||||
# config from the user's dotfile repo (#534). Resolve the link chain and
|
|
||||||
# write to the real target so the symlink survives.
|
|
||||||
path = _resolve_symlink_target(path)
|
|
||||||
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)
|
|
||||||
|
|
||||||
# Suffix the temp name with this process's PID so two editors writing the
|
|
||||||
# same config concurrently (both clicking Configure) can't interleave
|
|
||||||
# bytes on a shared fixed ".tmp" path (#534). Each process stages its own
|
|
||||||
# temp file; the final rename remains the atomic commit point.
|
|
||||||
var tmp_path := "%s.tmp.%d" % [path, OS.get_process_id()]
|
|
||||||
var file := _open_restricted_temp(tmp_path, target_mode)
|
|
||||||
if file == null:
|
|
||||||
return false
|
|
||||||
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 before rename. The temp was already
|
|
||||||
# restricted before this handle opened, so this is defence in depth rather
|
|
||||||
# than the first point at which secrets become owner-only.
|
|
||||||
if not _apply_mode(tmp_path, target_mode):
|
|
||||||
DirAccess.remove_absolute(tmp_path)
|
|
||||||
return false
|
|
||||||
|
|
||||||
# Verify the staged temp landed intact before committing it anywhere. The
|
|
||||||
# copy-fallback path below already guards this (`_written_size_matches` at
|
|
||||||
# the rename-fallback check); the rename path was the one gap — under
|
|
||||||
# disk-full/quota the temp can be silently truncated, and an unverified
|
|
||||||
# rename would swap a truncated file over the live target while the
|
|
||||||
# caller is told the write succeeded (#687).
|
|
||||||
if not _written_size_matches(tmp_path, content):
|
|
||||||
DirAccess.remove_absolute(tmp_path)
|
|
||||||
return false
|
|
||||||
|
|
||||||
# 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:
|
|
||||||
if not _apply_mode(backup_path, target_mode):
|
|
||||||
## The backup contains the same secrets as the live config. Do
|
|
||||||
## not leave that copy behind — or touch the destination — when
|
|
||||||
## it cannot be restricted to the intended mode.
|
|
||||||
DirAccess.remove_absolute(backup_path)
|
|
||||||
DirAccess.remove_absolute(tmp_path)
|
|
||||||
return false
|
|
||||||
backup_made = true
|
|
||||||
else:
|
|
||||||
## copy_absolute may leave a partial destination on failure. It
|
|
||||||
## contains config bytes under the process umask, so remove it.
|
|
||||||
DirAccess.remove_absolute(backup_path)
|
|
||||||
|
|
||||||
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. A
|
|
||||||
# permission failure is a failed write: fall through to the same
|
|
||||||
# restore/remove path as a partial copy instead of reporting success
|
|
||||||
# with a potentially exposed token-bearing config.
|
|
||||||
if _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.
|
|
||||||
var restored := (
|
|
||||||
DirAccess.copy_absolute(backup_path, path) == OK
|
|
||||||
and _apply_mode(path, target_mode)
|
|
||||||
)
|
|
||||||
if not restored:
|
|
||||||
## The backup's mode was already verified before the swap. Remove
|
|
||||||
## any partial/default-mode destination, then move that restricted
|
|
||||||
## inode back into place. If even the same-directory rename fails,
|
|
||||||
## the complete prior contents remain in `.backup` for recovery.
|
|
||||||
DirAccess.remove_absolute(path)
|
|
||||||
DirAccess.rename_absolute(backup_path, path)
|
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
## Follow a symlink chain at `path` and return the final real target, so the
|
|
||||||
## temp+rename lands on the linked-to file instead of replacing the link.
|
|
||||||
##
|
|
||||||
## Best-effort by design: DirAccess.is_link()/read_link() are only implemented
|
|
||||||
## on platforms with POSIX symlinks (Linux/macOS; on Windows and other
|
|
||||||
## platforms is_link() returns false), and opening the parent dir can fail for
|
|
||||||
## exotic paths. In every "can't tell" case we return `path` unchanged, which
|
|
||||||
## is exactly the pre-#534 behavior — never worse, symlink-preserving where
|
|
||||||
## the engine lets us detect one.
|
|
||||||
static func _resolve_symlink_target(path: String) -> String:
|
|
||||||
var resolved := path
|
|
||||||
# Bounded hops so a symlink cycle can't loop us forever.
|
|
||||||
for _hop in 8:
|
|
||||||
var base_dir := resolved.get_base_dir()
|
|
||||||
var da := DirAccess.open(base_dir)
|
|
||||||
if da == null or not da.is_link(resolved):
|
|
||||||
return resolved
|
|
||||||
var target := da.read_link(resolved)
|
|
||||||
if target.is_empty():
|
|
||||||
return resolved
|
|
||||||
if target.is_relative_path():
|
|
||||||
target = base_dir.path_join(target)
|
|
||||||
resolved = target.simplify_path()
|
|
||||||
return resolved
|
|
||||||
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
## Create/truncate an EMPTY temp file, close it, restrict its mode, then reopen
|
|
||||||
## it without truncation for the payload write. Godot 4.6 on macOS returns
|
|
||||||
## FAILED when set_unix_permissions targets a path whose FileAccess is still
|
|
||||||
## open; the old create -> chmod-while-open -> write sequence therefore emitted
|
|
||||||
## one warning per configured client and briefly wrote secrets under the umask
|
|
||||||
## mode. Closing the empty inode first makes the security ordering real and
|
|
||||||
## removes that platform-specific warning without suppressing genuine errors.
|
|
||||||
static func _open_restricted_temp(path: String, mode: int) -> FileAccess:
|
|
||||||
var created := FileAccess.open(path, FileAccess.WRITE)
|
|
||||||
if created == null:
|
|
||||||
return null
|
|
||||||
created.close()
|
|
||||||
if not _apply_mode(path, mode):
|
|
||||||
DirAccess.remove_absolute(path)
|
|
||||||
return null
|
|
||||||
return FileAccess.open(path, FileAccess.READ_WRITE)
|
|
||||||
|
|
||||||
|
|
||||||
static func _apply_mode(path: String, mode: int) -> bool:
|
|
||||||
# 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 true
|
|
||||||
var err := FileAccess.set_unix_permissions(path, mode)
|
|
||||||
if err == OK or err == ERR_UNAVAILABLE:
|
|
||||||
return true
|
|
||||||
# Surface a real chmod failure (not the Windows no-op) so permission
|
|
||||||
# hardening on a sensitive config doesn't fail completely silently.
|
|
||||||
push_warning("MCP | could not set permissions on %s: %s" % [path, error_string(err)])
|
|
||||||
return false
|
|
||||||
|
|
||||||
|
|
||||||
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()
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
uid://6fkb5uau0r4h
|
|
||||||
@@ -1,578 +0,0 @@
|
|||||||
@tool
|
|
||||||
class_name McpClient
|
|
||||||
extends RefCounted
|
|
||||||
|
|
||||||
## Descriptor for one MCP client (Cursor, Claude Desktop, Codex, ...).
|
|
||||||
##
|
|
||||||
## Subclasses set fields in `_init()` and MUST NOT carry Callables — strategies
|
|
||||||
## (json/toml/cli) interpret the data. Enforced by
|
|
||||||
## `test_clients.gd::test_descriptors_are_data_only`.
|
|
||||||
##
|
|
||||||
## Why no Callables: per-client `.gd` files get hot-reloaded on disk-mtime
|
|
||||||
## change. A worker thread mid-call into a descriptor lambda races the
|
|
||||||
## bytecode swap and SEGVs (issue #229). Bonus: also obsoletes the stale-
|
|
||||||
## Callable workaround from #192.
|
|
||||||
|
|
||||||
## CONFIGURED_MISMATCH = an entry with our `SERVER_NAME` exists in the user's
|
|
||||||
## client config, but its URL or launch command doesn't match the current
|
|
||||||
## ports/version/exclusions — typical after a setting change or update.
|
|
||||||
## Distinguishing this from `NOT_CONFIGURED` lets the dock surface a "your
|
|
||||||
## saved client configuration is stale" banner instead of conflating it with
|
|
||||||
## "you never configured this client".
|
|
||||||
enum Status { NOT_CONFIGURED, CONFIGURED, CONFIGURED_MISMATCH, ERROR }
|
|
||||||
|
|
||||||
|
|
||||||
## Lowercase string label for a `Status` value. Single source of truth so the
|
|
||||||
## MCP `client_status` tool, the dock, and the verify-after-write diagnostic
|
|
||||||
## in `McpClientConfigurator` all emit the same names — agents pattern-match
|
|
||||||
## against this set, so a fifth value being silently introduced would break
|
|
||||||
## them.
|
|
||||||
static func status_label(status: McpClient.Status) -> String:
|
|
||||||
match status:
|
|
||||||
Status.CONFIGURED:
|
|
||||||
return "configured"
|
|
||||||
Status.NOT_CONFIGURED:
|
|
||||||
return "not_configured"
|
|
||||||
Status.CONFIGURED_MISMATCH:
|
|
||||||
return "configured_mismatch"
|
|
||||||
return "error"
|
|
||||||
|
|
||||||
|
|
||||||
## One-line configure success message, shared by every strategy so the dock
|
|
||||||
## and the `client_manage` tool describe the transport that was actually
|
|
||||||
## written. Command-shape clients register the stdio `godot-ai attach`
|
|
||||||
## bridge — the URL-era "(HTTP: <url>)" suffix would name a transport the
|
|
||||||
## write never touched (found live in the #838 Windows smoke).
|
|
||||||
static func configured_message(client: McpClient, server_url: String) -> String:
|
|
||||||
if client.command_shape != CommandShape.NONE:
|
|
||||||
return "%s configured (stdio attach)" % client.display_name
|
|
||||||
return "%s configured (HTTP: %s)" % [client.display_name, server_url]
|
|
||||||
|
|
||||||
var id: String = "" ## stable key, e.g. "cursor"
|
|
||||||
var display_name: String = "" ## "Cursor"
|
|
||||||
var config_type: String = "" ## "json" | "toml" | "yaml" | "cli" | "dsh"
|
|
||||||
## False when the client's settings format cannot be round-tripped safely by
|
|
||||||
## the matching strategy. Status checks remain read-only; Configure and Remove
|
|
||||||
## return the existing manual instructions without touching the file.
|
|
||||||
var automatic_config_edits: bool = true
|
|
||||||
|
|
||||||
## True when the config file is JSONC (Zed ships a `//` header). Honored by the
|
|
||||||
## JSON strategy's reads only — comments are stripped from a throwaway parse
|
|
||||||
## copy, so the row shows a real status instead of a permanent parse error
|
|
||||||
## (#914). Requires `automatic_config_edits = false`; the strategy ignores it
|
|
||||||
## otherwise rather than let a re-serializing Configure drop the comments.
|
|
||||||
var config_allows_comments: bool = false
|
|
||||||
|
|
||||||
# JSON / TOML clients ------------------------------------------------------
|
|
||||||
## {"darwin": "~/...", "windows": "$APPDATA/...", "linux": "$XDG_CONFIG_HOME/..."}
|
|
||||||
## Keys may also use "unix" as a shorthand for darwin+linux.
|
|
||||||
var path_template: Dictionary = {}
|
|
||||||
|
|
||||||
## Optional ordered path candidates by platform. Each value is an Array of
|
|
||||||
## templates; one `*` may appear in a directory segment so packaged-app roots
|
|
||||||
## can be discovered without hardcoding publisher hashes.
|
|
||||||
##
|
|
||||||
## Resolution contract:
|
|
||||||
## 1. Existing files win in descriptor order, except that a unique wildcard
|
|
||||||
## match is authoritative even before its config leaf exists. A matching
|
|
||||||
## package root therefore creates inside that package rather than writing
|
|
||||||
## a fallback path that may become invisible after copy-on-write. When
|
|
||||||
## that private leaf is new, Configure seeds it from the first later
|
|
||||||
## existing candidate so read-through content is not shadowed.
|
|
||||||
## 2. If no file or wildcard package match exists, the first non-wildcard
|
|
||||||
## template is the deterministic create target.
|
|
||||||
## 3. Multiple matches within any wildcard group are ambiguous and fail
|
|
||||||
## closed instead of choosing an arbitrary package.
|
|
||||||
##
|
|
||||||
## Exact-file and config-home environment overrides still have higher
|
|
||||||
## priority. When this map has no entry for the current platform,
|
|
||||||
## `path_template` remains the fallback.
|
|
||||||
var config_path_candidates: Dictionary = {}
|
|
||||||
|
|
||||||
## Optional global JSON config files merged by the client from lowest to highest
|
|
||||||
## precedence. Unlike `config_path_candidates`, every existing file participates;
|
|
||||||
## Configure updates the effective last definition, status verifies it, and
|
|
||||||
## Remove clears every global definition transactionally.
|
|
||||||
var config_merge_path_templates: Dictionary = {}
|
|
||||||
## Project-relative tiers that may override the global files. Their root is the
|
|
||||||
## external client's working directory, which the Godot process cannot know.
|
|
||||||
## The strategy checks plausible roots and fails closed instead of mutating them.
|
|
||||||
var config_merge_project_paths: PackedStringArray = PackedStringArray()
|
|
||||||
|
|
||||||
## De-duplicate persistent path-ambiguity warnings across recurring status
|
|
||||||
## refreshes. The actionable message still returns on every resolution; only
|
|
||||||
## the editor-console echo is single-shot until the ambiguity clears/changes.
|
|
||||||
var _last_config_path_warning := ""
|
|
||||||
var _config_path_warning_mutex := Mutex.new()
|
|
||||||
|
|
||||||
## Path inside the config object where the per-server map lives.
|
|
||||||
## Cursor / Claude Desktop / most others: ["mcpServers"]
|
|
||||||
## VS Code: ["servers"]
|
|
||||||
## OpenCode: ["mcp"]
|
|
||||||
var server_key_path: PackedStringArray = PackedStringArray()
|
|
||||||
## Alternative server-map paths accepted by the client, in precedence order
|
|
||||||
## after `server_key_path`. JSON strategy operations select the first non-null
|
|
||||||
## existing path so Configure, status, and Remove agree with the client parser.
|
|
||||||
var server_key_path_aliases: Array[PackedStringArray] = []
|
|
||||||
|
|
||||||
## Field inside the entry dict that holds our server URL.
|
|
||||||
## "url" by default; some clients use "serverUrl" or "httpUrl".
|
|
||||||
var entry_url_field: String = "url"
|
|
||||||
|
|
||||||
## Required entry fields — written on every Configure AND verified by the
|
|
||||||
## default verifier. Use this for transport pins (e.g. `type:
|
|
||||||
## "streamable-http"`) where a missing/wrong value breaks negotiation: a
|
|
||||||
## legacy entry without the pin fails verification and surfaces as drift.
|
|
||||||
##
|
|
||||||
## DO NOT put user-mutable state here (auto-approval lists, `disabled`
|
|
||||||
## flags, opt-in toggles). Verifying those treats every user customisation
|
|
||||||
## as drift, and Configure-All-Mismatched then silently overwrites them
|
|
||||||
## back to defaults — see the `entry_initial_fields` doc below.
|
|
||||||
var entry_extra_fields: Dictionary = {}
|
|
||||||
|
|
||||||
## Default fields written ONLY when the entry doesn't yet exist. Reconfigure
|
|
||||||
## preserves whatever the user (or the client itself) has set; the verifier
|
|
||||||
## ignores these keys entirely. Use for opt-in flags and user-state arrays —
|
|
||||||
## e.g. Roo / Cline / Kilo `alwaysAllow` / `autoApprove` lists, `disabled:
|
|
||||||
## false`, `isActive: true`. The pre-#229 behaviour was equivalent: per-
|
|
||||||
## client `entry_builder` lambdas seeded these as defaults but the
|
|
||||||
## per-client `verify_entry` lambdas only checked transport pins, so a
|
|
||||||
## user-customised array was `CONFIGURED`, not drift. Splitting the field
|
|
||||||
## restores that contract under the data-only descriptor model.
|
|
||||||
var entry_initial_fields: Dictionary = {}
|
|
||||||
|
|
||||||
## Client-owned stdio launch shape. Each strategy renders the shape in its
|
|
||||||
## config language:
|
|
||||||
##
|
|
||||||
## - FLAT — `command` string + `args` array as sibling keys. JSON and YAML
|
|
||||||
## strategies. A client whose docs require a type discriminator next to the
|
|
||||||
## flat keys (VS Code's `type: "stdio"`, Claude Code's fallback file) stays
|
|
||||||
## FLAT and declares it via `command_transport_key` / `command_transport_value`,
|
|
||||||
## so TYPED_FLAT remains reserved vocabulary.
|
|
||||||
## - COMMAND_ARRAY — the launch argv carried as one array. In the JSON
|
|
||||||
## strategy the entry's `command` field IS that array (OpenCode's
|
|
||||||
## `"command": ["uvx", …]`). In the TOML strategy the launcher renders as a
|
|
||||||
## `command = "…"` line plus an `args = […]` array (Codex, Grok) — the name
|
|
||||||
## refers to the argv-as-TOML-array body it emits.
|
|
||||||
## - NESTED_COMMAND — command/args nested inside a sub-object. Reserved; no
|
|
||||||
## current client needs it and strategies reject it with an actionable error.
|
|
||||||
##
|
|
||||||
## CLI-registered clients (`config_type == "cli"`) express the launch through
|
|
||||||
## `cli_register_template` tokens instead; their `command_shape` governs the
|
|
||||||
## JSON-fallback file rendering (Claude Code, #463).
|
|
||||||
##
|
|
||||||
## Values are data-only shared vocabulary; keeping them data-only avoids
|
|
||||||
## reintroducing the descriptor Callable race from #229.
|
|
||||||
enum CommandShape { NONE, FLAT, TYPED_FLAT, COMMAND_ARRAY, NESTED_COMMAND }
|
|
||||||
var command_shape: CommandShape = CommandShape.NONE
|
|
||||||
|
|
||||||
## Optional discriminator required by a client's command transport shape
|
|
||||||
## (for example `type = "stdio"`). Empty means command+args are sufficient.
|
|
||||||
var command_transport_key: String = ""
|
|
||||||
var command_transport_value: Variant = null
|
|
||||||
|
|
||||||
## Whether this client's Windows stdio entry must launch through the
|
|
||||||
## GUI-subsystem pythonw bootstrap (#827). The bootstrap exists for clients
|
|
||||||
## that run console-subsystem MCP commands in a visible terminal (Codex);
|
|
||||||
## Electron-family spawners hide child consoles themselves, and at least one
|
|
||||||
## (Antigravity) hangs tool calls when handed a GUI-subsystem executable
|
|
||||||
## (#863). Set false to write the plain console launcher on Windows.
|
|
||||||
var needs_consoleless_launcher: bool = true
|
|
||||||
|
|
||||||
## Keys from the legacy transport that Configure must delete. Codex removes
|
|
||||||
## `url`, because Codex rejects a server entry containing both URL and stdio
|
|
||||||
## launch fields.
|
|
||||||
var command_legacy_keys: PackedStringArray = PackedStringArray()
|
|
||||||
|
|
||||||
## Keys inside a preserved JSON `env` object that belonged to a legacy launch
|
|
||||||
## shape and must be removed during migration. Other environment values remain
|
|
||||||
## user-owned and survive Configure. Currently consumed by the JSON strategy.
|
|
||||||
var command_env_legacy_keys: PackedStringArray = PackedStringArray()
|
|
||||||
|
|
||||||
## Defaults seeded only for a new entry. Reconfigure preserves user values.
|
|
||||||
## Codex uses this for enabled/startup/tool timeout defaults.
|
|
||||||
var command_initial_fields: Dictionary = {}
|
|
||||||
|
|
||||||
## Declarative documentation of fields owned by the user and timeout fields
|
|
||||||
## supported by this client. Strategies preserve these values and tests pin
|
|
||||||
## the descriptor contract; no control flow lives on the descriptor.
|
|
||||||
var command_user_fields: PackedStringArray = PackedStringArray()
|
|
||||||
var command_timeout_fields: PackedStringArray = PackedStringArray()
|
|
||||||
|
|
||||||
## Paths whose existence implies the user has this client installed.
|
|
||||||
## Used purely for the dock's "installed" badge. `is_installed()` additionally
|
|
||||||
## checks `resolved_config_path()`, so a config relocated via an environment
|
|
||||||
## override is detected without listing it here.
|
|
||||||
var detect_paths: PackedStringArray = PackedStringArray()
|
|
||||||
|
|
||||||
# Config-path env overrides --------------------------------------------------
|
|
||||||
## Some clients name the exact config file in an environment variable
|
|
||||||
## (OpenCode: `$OPENCODE_CONFIG`). When the variable is set and non-empty, it
|
|
||||||
## wins over directory-valued `config_home_env` and `path_template`. Relative
|
|
||||||
## values fail closed because the editor and client may have different working
|
|
||||||
## directories; auto-configuration cannot safely assume they resolve alike.
|
|
||||||
var config_file_env: String = ""
|
|
||||||
|
|
||||||
## Some clients honor an env var that relocates their entire config home
|
|
||||||
## (Codex: `$CODEX_HOME/config.toml`; Claude Code: `$CLAUDE_CONFIG_DIR/.claude.json`).
|
|
||||||
## When `config_home_env` names an env var that is set and non-empty,
|
|
||||||
## `resolved_config_path()` returns `<env value>/<config_home_env_subpath>`
|
|
||||||
## instead of resolving `path_template`. Both fields must be non-empty for the
|
|
||||||
## override to apply. Only declare a mapping when the client's docs guarantee
|
|
||||||
## the env var relocates the exact file we write — a wrong mapping writes the
|
|
||||||
## MCP entry somewhere the client never reads and Configure false-succeeds.
|
|
||||||
## Relative values fail closed for the same reason `config_file_env`'s do.
|
|
||||||
var config_home_env: String = ""
|
|
||||||
## Path of the config file relative to the env var's directory, e.g.
|
|
||||||
## "config.toml". Joined verbatim — no per-OS variants needed because the env
|
|
||||||
## value itself is already an absolute (or ~-prefixed) directory.
|
|
||||||
var config_home_env_subpath: String = ""
|
|
||||||
|
|
||||||
# CLI clients --------------------------------------------------------------
|
|
||||||
var cli_names: PackedStringArray = PackedStringArray()
|
|
||||||
## Argument templates with `{name}` and `{url}` tokens; the strategy
|
|
||||||
## substitutes them at call time. Tokens are matched verbatim — no escaping
|
|
||||||
## semantics, no shell expansion. Command-shape templates additionally use the
|
|
||||||
## whole-element tokens `{command}` / `{args...}` (see `McpCliStrategy.format_args`).
|
|
||||||
## Populated by CLI descriptors (currently `claude_code`; `kimi_code` moved to
|
|
||||||
## mcp.json in #813).
|
|
||||||
var cli_register_template: PackedStringArray = PackedStringArray()
|
|
||||||
var cli_unregister_template: PackedStringArray = PackedStringArray()
|
|
||||||
## Args run to read current state; stdout is scanned for the server name and
|
|
||||||
## URL. Presence of `name` AND `url` → CONFIGURED, name only → MISMATCH,
|
|
||||||
## neither → NOT_CONFIGURED.
|
|
||||||
var cli_status_args: PackedStringArray = PackedStringArray()
|
|
||||||
## Optional scope-aware status template with a `{name}` token, for descriptors
|
|
||||||
## that also take `{scope}` in their register template. `cli_status_args` lists
|
|
||||||
## whatever the CLI resolves for the current directory and cannot say WHICH
|
|
||||||
## scope an entry came from, so a leftover user-scope entry reads as CONFIGURED
|
|
||||||
## while the selected project scope is empty — the dock then shows green over
|
|
||||||
## exactly the "loaded in every workspace" state the scope setting exists to
|
|
||||||
## end (#872). This template must print a `Scope: <user|project|local> …` line
|
|
||||||
## (`claude mcp get <name>` does) and exit non-zero when the server is absent.
|
|
||||||
## Only consulted when the selected scope isn't the one `path_template` reads.
|
|
||||||
var cli_scope_status_template: PackedStringArray = PackedStringArray()
|
|
||||||
|
|
||||||
# Codex / TOML clients -----------------------------------------------------
|
|
||||||
## Dotted TOML path under which our entry lives, e.g. ["mcp_servers", "godot-ai"].
|
|
||||||
## Strategies build the [section."name"] header from this.
|
|
||||||
var toml_section_path: PackedStringArray = PackedStringArray()
|
|
||||||
var toml_legacy_section_aliases: PackedStringArray = PackedStringArray()
|
|
||||||
## Lines (without the [header]) emitted under the section, with `{url}`
|
|
||||||
## tokens. Substituted at call time.
|
|
||||||
var toml_body_template: PackedStringArray = PackedStringArray()
|
|
||||||
|
|
||||||
|
|
||||||
## Resolved absolute config path for this client on the current OS. Exact-file
|
|
||||||
## overrides win first, followed by directory-valued `config_home_env`, then
|
|
||||||
## ordered candidates / `path_template`. Ignoring either override can write a
|
|
||||||
## file the client never reads and false-succeed.
|
|
||||||
func resolved_config_path() -> String:
|
|
||||||
return str(resolved_config_path_details().get("path", ""))
|
|
||||||
|
|
||||||
|
|
||||||
## Detailed sibling used by status/configure/remove so safe resolution
|
|
||||||
## failures reach the dock instead of collapsing into NOT_CONFIGURED. `error`
|
|
||||||
## is empty for ordinary unsupported/missing path mappings to preserve the
|
|
||||||
## long-standing status behavior for clients not installed on this platform.
|
|
||||||
func resolved_config_path_details() -> Dictionary:
|
|
||||||
var details := _resolve_config_path_details()
|
|
||||||
var path := str(details.get("path", ""))
|
|
||||||
if path.is_empty() or path.is_absolute_path():
|
|
||||||
return details
|
|
||||||
## Last gate before every strategy's read/write. `McpPathTemplate.expand`
|
|
||||||
## leaves a token it cannot resolve in place, so an unset `$USERPROFILE` (or
|
|
||||||
## a `~` with no HOME) reaches here as a RELATIVE path rather than as the
|
|
||||||
## root-relative `/godot` that sails through `is_absolute_path()`. Acting on
|
|
||||||
## it would resolve against the editor's own working directory and create a
|
|
||||||
## literal `$USERPROFILE` folder in the Godot project. No descriptor
|
|
||||||
## template is intentionally relative — every one is `~`- or `$VAR`-rooted —
|
|
||||||
## so a non-absolute survivor is always a resolution failure. Report it with
|
|
||||||
## the unexpanded path, which still shows what could not be resolved.
|
|
||||||
return {"path": "", "error": unresolved_config_path_error(display_name, path)}
|
|
||||||
|
|
||||||
|
|
||||||
## Shared wording for a path template `McpPathTemplate.expand` could not fully
|
|
||||||
## resolve. Used by the guard above and by the merge-tier loader in
|
|
||||||
## `_json_strategy.gd`, which resolves its own templates and never passes
|
|
||||||
## through `resolved_config_path_details`.
|
|
||||||
static func unresolved_config_path_error(client_name: String, path: String) -> String:
|
|
||||||
return (
|
|
||||||
"Could not resolve %s's config path: %s did not expand to an absolute "
|
|
||||||
+ "path. Set HOME/USERPROFILE (or the variable the path names) in the "
|
|
||||||
+ "environment the editor was launched from."
|
|
||||||
) % [client_name, path]
|
|
||||||
|
|
||||||
|
|
||||||
func _resolve_config_path_details() -> Dictionary:
|
|
||||||
## Reflected reads: after an in-session self-update, an instance created
|
|
||||||
## before the update can answer Nil for vars the update added, and the
|
|
||||||
## typed calls below would each hard-error (Nil -> Dictionary, #850's
|
|
||||||
## per-row error wall). Fail with the
|
|
||||||
## one repair message instead; the registry's coherence probe drives the
|
|
||||||
## same text on the status path.
|
|
||||||
var candidates: Variant = get("config_path_candidates")
|
|
||||||
var template: Variant = get("path_template")
|
|
||||||
var file_env: Variant = get("config_file_env")
|
|
||||||
if not (candidates is Dictionary) or not (template is Dictionary) or not (file_env is String):
|
|
||||||
return {"path": "", "error": McpClientRegistry.RESTART_TO_FINISH_UPDATE}
|
|
||||||
var file_override := config_file_override_details()
|
|
||||||
if not str(file_override.get("path", "")).is_empty() or not str(file_override.get("error", "")).is_empty():
|
|
||||||
_clear_config_path_warning()
|
|
||||||
return file_override
|
|
||||||
var home_override := config_home_override_details()
|
|
||||||
if not str(home_override.get("path", "")).is_empty() or not str(home_override.get("error", "")).is_empty():
|
|
||||||
_clear_config_path_warning()
|
|
||||||
return home_override
|
|
||||||
var candidate_key := McpPathTemplate.platform_key(candidates)
|
|
||||||
if not candidate_key.is_empty():
|
|
||||||
return _resolve_ordered_config_path_candidates(candidates[candidate_key])
|
|
||||||
_clear_config_path_warning()
|
|
||||||
return {"path": McpPathTemplate.resolve(template), "error": ""}
|
|
||||||
|
|
||||||
|
|
||||||
## The exact-file env override plus any fail-closed diagnostic. Empty path and
|
|
||||||
## error means no override applies (no mapping, unset, or blank env var).
|
|
||||||
func config_file_override_details() -> Dictionary:
|
|
||||||
if config_file_env.is_empty():
|
|
||||||
return {"path": "", "error": ""}
|
|
||||||
## env_lookup, not OS.get_environment: this can run on dock workers (#691).
|
|
||||||
var raw_path := McpPathTemplate.env_lookup(config_file_env).strip_edges()
|
|
||||||
if raw_path.is_empty():
|
|
||||||
return {"path": "", "error": ""}
|
|
||||||
var expanded := McpPathTemplate.expand(raw_path)
|
|
||||||
if not expanded.is_absolute_path():
|
|
||||||
return {
|
|
||||||
"path": "",
|
|
||||||
"error": "%s's $%s override must be an absolute config-file path; got %s" % [
|
|
||||||
display_name, config_file_env, raw_path,
|
|
||||||
],
|
|
||||||
}
|
|
||||||
if DirAccess.dir_exists_absolute(expanded):
|
|
||||||
return {
|
|
||||||
"path": "",
|
|
||||||
"error": "%s's $%s override must point to a config file, not a directory: %s" % [
|
|
||||||
display_name, config_file_env, expanded,
|
|
||||||
],
|
|
||||||
}
|
|
||||||
return {"path": expanded, "error": ""}
|
|
||||||
|
|
||||||
|
|
||||||
func _resolve_ordered_config_path_candidates(templates: Variant) -> Dictionary:
|
|
||||||
if not (templates is Array or templates is PackedStringArray):
|
|
||||||
_clear_config_path_warning()
|
|
||||||
return {"path": "", "error": ""}
|
|
||||||
var ordered_templates: Array = []
|
|
||||||
for template_variant in templates:
|
|
||||||
ordered_templates.append(str(template_variant))
|
|
||||||
var fallback_create_path := ""
|
|
||||||
for index in range(ordered_templates.size()):
|
|
||||||
var template := str(ordered_templates[index])
|
|
||||||
var expanded_template := McpPathTemplate.expand(template)
|
|
||||||
# An empty wildcard group ordinarily means "this package is not
|
|
||||||
# installed", but an unresolved root token produces the same empty
|
|
||||||
# group. Do not silently treat "could not inspect" as "not present" and
|
|
||||||
# fall through to a later writable candidate.
|
|
||||||
if not expanded_template.is_absolute_path():
|
|
||||||
_clear_config_path_warning()
|
|
||||||
return {
|
|
||||||
"path": "",
|
|
||||||
"error": unresolved_config_path_error(display_name, expanded_template),
|
|
||||||
}
|
|
||||||
var group := McpPathTemplate.expand_path_candidates(template)
|
|
||||||
if group.size() > 1:
|
|
||||||
var message := (
|
|
||||||
"%s has multiple matching config package paths for %s: %s. "
|
|
||||||
+ "Remove the stale package installation or edit the intended config manually."
|
|
||||||
) % [display_name, template, ", ".join(group)]
|
|
||||||
_warn_config_path_once(message)
|
|
||||||
return {"path": "", "error": message}
|
|
||||||
if group.is_empty():
|
|
||||||
continue
|
|
||||||
var path := String(group[0])
|
|
||||||
if FileAccess.file_exists(path):
|
|
||||||
_clear_config_path_warning()
|
|
||||||
return {"path": path, "error": ""}
|
|
||||||
# A wildcard only resolves when its package directory exists. Treat that
|
|
||||||
# installation evidence as authoritative and create its private config
|
|
||||||
# directly instead of relying on copy-on-write read-through. Preserve
|
|
||||||
# anything currently visible through read-through by naming the first
|
|
||||||
# later existing candidate as a one-time seed source.
|
|
||||||
if template.contains("*"):
|
|
||||||
var seed := _first_existing_later_candidate(ordered_templates, index + 1)
|
|
||||||
if not str(seed.get("error", "")).is_empty():
|
|
||||||
_clear_config_path_warning()
|
|
||||||
return {"path": "", "error": seed["error"]}
|
|
||||||
_clear_config_path_warning()
|
|
||||||
return {"path": path, "error": "", "seed_path": seed.get("path", "")}
|
|
||||||
if fallback_create_path.is_empty():
|
|
||||||
fallback_create_path = path
|
|
||||||
_clear_config_path_warning()
|
|
||||||
return {"path": fallback_create_path, "error": ""}
|
|
||||||
|
|
||||||
|
|
||||||
## Find a readable seed for a newly-created authoritative wildcard target.
|
|
||||||
## An unresolved later root is an error, not an absent seed: writing without
|
|
||||||
## inspecting it could silently discard configuration visible through the
|
|
||||||
## package's read-through fallback.
|
|
||||||
func _first_existing_later_candidate(templates: Array, start_index: int) -> Dictionary:
|
|
||||||
for index in range(start_index, templates.size()):
|
|
||||||
var template := str(templates[index])
|
|
||||||
var expanded_template := McpPathTemplate.expand(template)
|
|
||||||
if not expanded_template.is_absolute_path():
|
|
||||||
return {
|
|
||||||
"path": "",
|
|
||||||
"error": unresolved_config_path_error(display_name, expanded_template),
|
|
||||||
}
|
|
||||||
var group := McpPathTemplate.expand_path_candidates(template)
|
|
||||||
# A seed is optional. Never choose among an ambiguous later wildcard;
|
|
||||||
# the authoritative target was already resolved by the earlier group.
|
|
||||||
if group.size() != 1:
|
|
||||||
continue
|
|
||||||
var path := String(group[0])
|
|
||||||
if FileAccess.file_exists(path):
|
|
||||||
return {"path": path, "error": ""}
|
|
||||||
return {"path": "", "error": ""}
|
|
||||||
|
|
||||||
|
|
||||||
func _warn_config_path_once(message: String) -> void:
|
|
||||||
_config_path_warning_mutex.lock()
|
|
||||||
var should_warn := message != _last_config_path_warning
|
|
||||||
_last_config_path_warning = message
|
|
||||||
_config_path_warning_mutex.unlock()
|
|
||||||
if should_warn:
|
|
||||||
push_warning(message)
|
|
||||||
|
|
||||||
|
|
||||||
func _clear_config_path_warning() -> void:
|
|
||||||
_config_path_warning_mutex.lock()
|
|
||||||
_last_config_path_warning = ""
|
|
||||||
_config_path_warning_mutex.unlock()
|
|
||||||
|
|
||||||
|
|
||||||
## The config-home env override plus any fail-closed diagnostic. Empty path and
|
|
||||||
## error means no override applies (no mapping declared, env var unset, or env
|
|
||||||
## var empty/whitespace).
|
|
||||||
func config_home_override_details() -> Dictionary:
|
|
||||||
if config_home_env.is_empty() or config_home_env_subpath.is_empty():
|
|
||||||
return {"path": "", "error": ""}
|
|
||||||
## env_lookup, not OS.get_environment: this runs on dock worker threads,
|
|
||||||
## which must not race the spawn window's setenv/unsetenv (#691).
|
|
||||||
var home := McpPathTemplate.env_lookup(config_home_env).strip_edges()
|
|
||||||
if home.is_empty():
|
|
||||||
return {"path": "", "error": ""}
|
|
||||||
# Expand a leading ~ so `CODEX_HOME=~/codex-alt` behaves like the shell.
|
|
||||||
var expanded := McpPathTemplate.expand(home)
|
|
||||||
## Same fail-closed rule as `config_file_override_details`: a relative value
|
|
||||||
## resolves against the EDITOR's working directory, not the client's, so
|
|
||||||
## honouring it silently aims the write at the Godot project directory
|
|
||||||
## instead of the file the client reads. The write layer can only name the
|
|
||||||
## mangled path it was handed; only here do we still know which env var
|
|
||||||
## produced it, so this is where the user-facing explanation belongs.
|
|
||||||
if not expanded.is_absolute_path():
|
|
||||||
return {
|
|
||||||
"path": "",
|
|
||||||
"error": "%s's $%s override must be an absolute config-home directory path; got %s" % [
|
|
||||||
display_name, config_home_env, home,
|
|
||||||
],
|
|
||||||
}
|
|
||||||
return {"path": expanded.path_join(config_home_env_subpath), "error": ""}
|
|
||||||
|
|
||||||
|
|
||||||
## The env-var-relocated config path, or "" when no override applies (no
|
|
||||||
## mapping declared, env var unset, env var empty/whitespace, or a value that
|
|
||||||
## failed closed — `config_home_override_details` carries that diagnostic).
|
|
||||||
func config_home_override() -> String:
|
|
||||||
return str(config_home_override_details().get("path", ""))
|
|
||||||
|
|
||||||
|
|
||||||
## True when a CLI client also declares where its config file lives, so it can
|
|
||||||
## fall back to writing that file directly when the CLI binary isn't on PATH.
|
|
||||||
## #463: Claude Code installed only as a VS Code / Cursor extension exposes no
|
|
||||||
## `claude` binary, but `claude mcp add --scope user` just writes `mcpServers`
|
|
||||||
## into ~/.claude.json — so we can produce the same entry ourselves.
|
|
||||||
func has_json_fallback() -> bool:
|
|
||||||
return config_type == "cli" and not path_template.is_empty() and not server_key_path.is_empty()
|
|
||||||
|
|
||||||
|
|
||||||
## True if the user appears to have this client installed locally.
|
|
||||||
func is_installed() -> bool:
|
|
||||||
if config_type == "cli":
|
|
||||||
if not McpCliFinder.find(_array_from_packed(cli_names)).is_empty():
|
|
||||||
return true
|
|
||||||
# CLI not on PATH. A cli client with a JSON fallback (Claude Code as a
|
|
||||||
# VS Code/Cursor extension, #463) still counts as installed if its
|
|
||||||
# fallback config file already exists.
|
|
||||||
if has_json_fallback():
|
|
||||||
var cfg := resolved_config_path()
|
|
||||||
return not cfg.is_empty() and FileAccess.file_exists(cfg)
|
|
||||||
return false
|
|
||||||
for p in detect_paths:
|
|
||||||
for resolved in McpPathTemplate.expand_path_candidates(p):
|
|
||||||
if FileAccess.file_exists(resolved) or DirAccess.dir_exists_absolute(resolved):
|
|
||||||
return true
|
|
||||||
# Fall back to "config file already exists" — usually means installed at some point.
|
|
||||||
var cfg := resolved_config_path()
|
|
||||||
return not cfg.is_empty() and FileAccess.file_exists(cfg)
|
|
||||||
|
|
||||||
|
|
||||||
static func _array_from_packed(packed: PackedStringArray) -> Array[String]:
|
|
||||||
var out: Array[String] = []
|
|
||||||
for s in packed:
|
|
||||||
out.append(s)
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
## Slice a PackedStringArray into a new PackedStringArray over [from, to).
|
|
||||||
## Used by `_toml_strategy` and `_manual_command` to peel the section path
|
|
||||||
## apart for `[a.b."c"]` header rendering.
|
|
||||||
static func _packed_slice(packed: PackedStringArray, from: int, to: int) -> PackedStringArray:
|
|
||||||
var out := PackedStringArray()
|
|
||||||
for i in range(from, to):
|
|
||||||
out.append(packed[i])
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
## Whether an existing entry's launch text launches Godot AI: an executable
|
|
||||||
## named `godot-ai`, a `godot-ai==<version>` package pin, the bare `godot-ai`
|
|
||||||
## console-script argument, or the `godot_ai` module. Tokens are matched
|
|
||||||
## exactly, so a URL or path that merely contains the name (a docs link, a
|
|
||||||
## project directory) does not count. The post-update major migration
|
|
||||||
## rewrites only such entries; anything else is the user's own server.
|
|
||||||
static func launch_mentions_godot_ai(text: String) -> bool:
|
|
||||||
## Free text (a CLI probe's output) is split on spaces; structured launch
|
|
||||||
## values should go through `launch_values_mention_godot_ai` unsplit.
|
|
||||||
return launch_values_mention_godot_ai(PackedStringArray(text.split(" ", false)))
|
|
||||||
|
|
||||||
|
|
||||||
## Each value is one command, argument or URL. A URI never names an
|
|
||||||
## executable, and Windows separators are normalized before the basename
|
|
||||||
## check so `C:\Program Files\Godot AI\godot-ai.exe` is recognized whole.
|
|
||||||
static func launch_values_mention_godot_ai(values: PackedStringArray) -> bool:
|
|
||||||
for raw_value in values:
|
|
||||||
var value := raw_value.strip_edges().lstrip("\"'[{(").rstrip("\"'])},")
|
|
||||||
if value.is_empty() or value.contains("://"):
|
|
||||||
continue
|
|
||||||
if value == "godot-ai" or value == "godot_ai" or value.begins_with("godot-ai=="):
|
|
||||||
return true
|
|
||||||
var base := value.replace("\\", "/").get_file()
|
|
||||||
if base == "godot-ai" or base == "godot-ai.exe":
|
|
||||||
return true
|
|
||||||
return false
|
|
||||||
|
|
||||||
|
|
||||||
## The launch-bearing fields of an entry, one value each, for the check
|
|
||||||
## above. The entry sits under our server name, so the name itself must not
|
|
||||||
## count.
|
|
||||||
static func entry_launch_values(entry: Dictionary) -> PackedStringArray:
|
|
||||||
var values := PackedStringArray()
|
|
||||||
for key in ["command", "args", "url"]:
|
|
||||||
if not entry.has(key):
|
|
||||||
continue
|
|
||||||
var value: Variant = entry[key]
|
|
||||||
if value is Array:
|
|
||||||
for item in value:
|
|
||||||
values.append(str(item))
|
|
||||||
else:
|
|
||||||
values.append(str(value))
|
|
||||||
return values
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
uid://cyowqr1x12ilg
|
|
||||||
@@ -1,219 +0,0 @@
|
|||||||
@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 terminate the exact captured process 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 the wall-clock budget expired.
|
|
||||||
## cancelled: true if the caller requested a cooperative early stop.
|
|
||||||
## spawn_failed: true if `OS.execute_with_pipe` didn't return a usable PID.
|
|
||||||
## termination_failed: true if an expired/cancelled process or any of its
|
|
||||||
## possible descendants could not be proven stopped. POSIX
|
|
||||||
## only gives this helper exact-PID authority, so timeout and
|
|
||||||
## cancellation are necessarily unproven there.
|
|
||||||
|
|
||||||
const DEFAULT_TIMEOUT_MS := 8000
|
|
||||||
const _POLL_INTERVAL_MS := 50
|
|
||||||
const _QUICK_EXIT_WINDOW_MS := 250
|
|
||||||
const _KILL_GRACE_MS := 500
|
|
||||||
const PortResolver := preload("res://addons/godot_ai/utils/port_resolver.gd")
|
|
||||||
const UvResolution := preload("res://addons/godot_ai/utils/uv_resolution_policy.gd")
|
|
||||||
|
|
||||||
|
|
||||||
static func run(
|
|
||||||
exe: String,
|
|
||||||
args: Array,
|
|
||||||
timeout_ms: int = DEFAULT_TIMEOUT_MS,
|
|
||||||
capture_stderr: bool = true,
|
|
||||||
cancel_check: Callable = Callable(),
|
|
||||||
isolate_uv_resolution: bool = false,
|
|
||||||
) -> Dictionary:
|
|
||||||
if exe.is_empty():
|
|
||||||
return _spawn_failed_result()
|
|
||||||
return _run_piped(
|
|
||||||
exe, args, timeout_ms, capture_stderr, cancel_check, isolate_uv_resolution
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
static func _run_piped(
|
|
||||||
exe: String,
|
|
||||||
args: Array,
|
|
||||||
timeout_ms: int,
|
|
||||||
capture_stderr: bool,
|
|
||||||
cancel_check: Callable,
|
|
||||||
isolate_uv_resolution: bool,
|
|
||||||
) -> Dictionary:
|
|
||||||
|
|
||||||
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)
|
|
||||||
|
|
||||||
PortResolver.lock_process_spawn()
|
|
||||||
var saved_uv_environment := (
|
|
||||||
UvResolution.isolate_environment() if isolate_uv_resolution else {}
|
|
||||||
)
|
|
||||||
var info := OS.execute_with_pipe(spawn_exe, spawn_args)
|
|
||||||
UvResolution.restore_environment(saved_uv_environment)
|
|
||||||
PortResolver.unlock_process_spawn()
|
|
||||||
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)
|
|
||||||
var kill_grant: Dictionary = {}
|
|
||||||
## Most discovery commands finish within a few polls. Do not spend Windows
|
|
||||||
## identity-query time capturing kill authority for an already-exited child.
|
|
||||||
## This short window shares the command deadline and observes cancellation;
|
|
||||||
## a child still running afterward needs the same exact grant as before.
|
|
||||||
var quick_deadline := mini(deadline, Time.get_ticks_msec() + _QUICK_EXIT_WINDOW_MS)
|
|
||||||
var cancellation_requested := false
|
|
||||||
while OS.is_process_running(pid) and Time.get_ticks_msec() < quick_deadline:
|
|
||||||
if cancel_check.is_valid() and bool(cancel_check.call()):
|
|
||||||
cancellation_requested = true
|
|
||||||
break
|
|
||||||
OS.delay_msec(mini(_POLL_INTERVAL_MS, maxi(0, quick_deadline - Time.get_ticks_msec())))
|
|
||||||
if OS.is_process_running(pid):
|
|
||||||
kill_grant = PortResolver.capture_process_kill_grant(pid)
|
|
||||||
|
|
||||||
while OS.is_process_running(pid):
|
|
||||||
var cancelled := cancellation_requested or (cancel_check.is_valid() and bool(cancel_check.call()))
|
|
||||||
if cancelled or Time.get_ticks_msec() >= deadline:
|
|
||||||
## Kill before draining: a pipe read can block while the launcher or
|
|
||||||
## one of its descendants still owns the write end. Windows taskkill
|
|
||||||
## /T gives us tree authority; POSIX OS.kill only targets this exact
|
|
||||||
## PID, so even a dead launcher cannot prove its descendants stopped.
|
|
||||||
var killed: Array[int] = []
|
|
||||||
if not kill_grant.is_empty():
|
|
||||||
killed = PortResolver.kill_exact_processes([kill_grant], false, true)
|
|
||||||
var kill_deadline := Time.get_ticks_msec() + _KILL_GRACE_MS
|
|
||||||
while PortResolver.pid_alive(pid) and Time.get_ticks_msec() < kill_deadline:
|
|
||||||
OS.delay_msec(_POLL_INTERVAL_MS)
|
|
||||||
|
|
||||||
var termination_failed := (
|
|
||||||
PortResolver.pid_alive(pid)
|
|
||||||
or OS.get_name() != "Windows"
|
|
||||||
or not killed.has(pid)
|
|
||||||
)
|
|
||||||
var partial_stdout := ""
|
|
||||||
var partial_stderr := ""
|
|
||||||
if not termination_failed:
|
|
||||||
partial_stdout = _drain_pipe(stdio)
|
|
||||||
partial_stderr = _drain_pipe(stderr_pipe) if capture_stderr else ""
|
|
||||||
_close_pipes(stdio, stderr_pipe)
|
|
||||||
return {
|
|
||||||
"exit_code": -1,
|
|
||||||
"stdout": partial_stdout,
|
|
||||||
"stderr": partial_stderr,
|
|
||||||
"output": _join_streams(partial_stdout, partial_stderr),
|
|
||||||
"timed_out": not cancelled,
|
|
||||||
"cancelled": cancelled,
|
|
||||||
"spawn_failed": false,
|
|
||||||
"termination_failed": termination_failed,
|
|
||||||
}
|
|
||||||
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,
|
|
||||||
"cancelled": false,
|
|
||||||
"spawn_failed": false,
|
|
||||||
"termination_failed": false,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
static func _spawn_failed_result() -> Dictionary:
|
|
||||||
return {
|
|
||||||
"exit_code": -1,
|
|
||||||
"stdout": "",
|
|
||||||
"stderr": "",
|
|
||||||
"output": "",
|
|
||||||
"timed_out": false,
|
|
||||||
"cancelled": false,
|
|
||||||
"spawn_failed": true,
|
|
||||||
"termination_failed": false,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
static func _drain_pipe(pipe: Variant) -> String:
|
|
||||||
if not (pipe is FileAccess):
|
|
||||||
return ""
|
|
||||||
var f := pipe as FileAccess
|
|
||||||
var bytes := PackedByteArray()
|
|
||||||
var max_bytes := 1 << 20 # 1 MiB, far above expected client CLI output.
|
|
||||||
while bytes.size() < max_bytes:
|
|
||||||
var chunk := f.get_buffer(mini(4096, max_bytes - bytes.size()))
|
|
||||||
if chunk.is_empty():
|
|
||||||
break
|
|
||||||
bytes.append_array(chunk)
|
|
||||||
if f.eof_reached():
|
|
||||||
break
|
|
||||||
return bytes.get_string_from_utf8()
|
|
||||||
|
|
||||||
|
|
||||||
static func _join_streams(stdout: String, stderr_text: String) -> String:
|
|
||||||
## Most CLIs write their actionable diagnostics to one stream or the
|
|
||||||
## other, never both — so concatenation gives "the message" without
|
|
||||||
## the caller having to guess which key to read. Newline-separate so
|
|
||||||
## callers that grep don't see two lines run together.
|
|
||||||
if stderr_text.is_empty():
|
|
||||||
return stdout
|
|
||||||
if stdout.is_empty():
|
|
||||||
return stderr_text
|
|
||||||
return "%s\n%s" % [stdout, stderr_text]
|
|
||||||
|
|
||||||
|
|
||||||
static func _close_pipes(stdio: Variant, stderr_pipe: Variant) -> void:
|
|
||||||
if stdio is FileAccess:
|
|
||||||
(stdio as FileAccess).close()
|
|
||||||
if stderr_pipe is FileAccess:
|
|
||||||
(stderr_pipe as FileAccess).close()
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
uid://dhoe3ypkhm12v
|
|
||||||
@@ -1,181 +0,0 @@
|
|||||||
@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 + bounded subprocess lookup) 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 = {}
|
|
||||||
|
|
||||||
const _LOOKUP_TIMEOUT_MS := 3000
|
|
||||||
|
|
||||||
|
|
||||||
## 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 + bounded subprocess lookup (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:
|
|
||||||
## env_lookup, not OS.get_environment: CLI resolution runs on dock
|
|
||||||
## worker threads (configure/remove actions) and must not race the
|
|
||||||
## spawn window's setenv/unsetenv (#691).
|
|
||||||
var shell := McpPathTemplate.env_lookup("SHELL")
|
|
||||||
if shell.is_empty():
|
|
||||||
shell = "/bin/bash"
|
|
||||||
var stripped := exe_name.trim_suffix(".exe")
|
|
||||||
var login_result := McpCliExec.run(shell, ["-lc", "command -v %s" % stripped], _LOOKUP_TIMEOUT_MS, false)
|
|
||||||
if int(login_result.get("exit_code", -1)) == 0:
|
|
||||||
var login_found: String = str(login_result.get("stdout", "")).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 result := McpCliExec.run(lookup, [exe_name], _LOOKUP_TIMEOUT_MS, false)
|
|
||||||
if int(result.get("exit_code", -1)) == 0:
|
|
||||||
var output := str(result.get("stdout", ""))
|
|
||||||
var lines := PackedStringArray(output.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]:
|
|
||||||
## env_lookup, not OS.get_environment — see _resolve()'s worker-thread
|
|
||||||
## note (#691).
|
|
||||||
var home := McpPathTemplate.env_lookup("HOME")
|
|
||||||
if home.is_empty():
|
|
||||||
home = McpPathTemplate.env_lookup("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 := McpPathTemplate.env_lookup("LOCALAPPDATA")
|
|
||||||
var prog := McpPathTemplate.env_lookup("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",
|
|
||||||
]
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
uid://cnp5b6fcwou2y
|
|
||||||
@@ -1,466 +0,0 @@
|
|||||||
@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. Command-shape descriptors additionally use the whole-element launch
|
|
||||||
## tokens `{command}` / `{args...}` (see `format_args`). 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.
|
|
||||||
|
|
||||||
## The descriptor token resolved from `godot_ai/mcp_client_scope` (#872).
|
|
||||||
## Only Claude Code takes it today; any `config_type = "cli"` descriptor can.
|
|
||||||
const SCOPE_TOKEN := "{scope}"
|
|
||||||
|
|
||||||
const _CONFIGURE_TIMEOUT_MS := 10000
|
|
||||||
const _REMOVE_TIMEOUT_MS := 10000
|
|
||||||
const _STATUS_TIMEOUT_MS := 6000
|
|
||||||
const MutationLock := preload("res://addons/godot_ai/utils/client_mutation_lock.gd")
|
|
||||||
|
|
||||||
|
|
||||||
static func configure(
|
|
||||||
client: McpClient,
|
|
||||||
server_name: String,
|
|
||||||
server_url: String,
|
|
||||||
launch: Dictionary = {},
|
|
||||||
) -> Dictionary:
|
|
||||||
## Fail closed before any subprocess runs: a command-shape client without a
|
|
||||||
## verified attach launcher must not register anything (see
|
|
||||||
## docs/client-configuration.md — an ERROR beats an entry known to be broken).
|
|
||||||
var launch_error := command_launch_error(client, launch)
|
|
||||||
if not launch_error.is_empty():
|
|
||||||
return {"status": "error", "message": launch_error}
|
|
||||||
var cli := _resolve_cli(client)
|
|
||||||
if cli.is_empty():
|
|
||||||
return {"status": "error", "message": "%s not found" % client.display_name}
|
|
||||||
return _configure_claimed(client, server_name, server_url, launch, cli)
|
|
||||||
|
|
||||||
|
|
||||||
static func _configure_claimed(
|
|
||||||
client: McpClient,
|
|
||||||
server_name: String,
|
|
||||||
server_url: String,
|
|
||||||
launch: Dictionary,
|
|
||||||
cli: String,
|
|
||||||
) -> Dictionary:
|
|
||||||
|
|
||||||
# Best-effort prior cleanup so re-configure is idempotent. Ordinary absent-
|
|
||||||
# entry/non-zero results remain ignorable; an unproven timeout is not,
|
|
||||||
# because its descendant could race the configure that follows.
|
|
||||||
if not client.cli_unregister_template.is_empty():
|
|
||||||
## #872: a `{scope}` descriptor sweeps EVERY scope, not just the
|
|
||||||
## selected one. Flipping the setting user -> project and pressing
|
|
||||||
## Configure would otherwise leave the old user-scope entry alive —
|
|
||||||
## exactly the "loaded in every unrelated workspace" problem the
|
|
||||||
## setting exists to fix. `mcp remove` on an absent entry is a no-op,
|
|
||||||
## so ordinary results are discarded; ambiguous termination aborts the
|
|
||||||
## sequence. Each extra scope costs one bounded subprocess.
|
|
||||||
for pre_scope in _cleanup_scopes(client):
|
|
||||||
var pre_args := _format_args(
|
|
||||||
client.cli_unregister_template, server_name, server_url, {}, pre_scope
|
|
||||||
)
|
|
||||||
var cleaned := McpCliExec.run(cli, pre_args, _REMOVE_TIMEOUT_MS)
|
|
||||||
var cleanup_failure := _unproven_mutation_failure(
|
|
||||||
client, "Prior cleanup", cleaned
|
|
||||||
)
|
|
||||||
if not cleanup_failure.is_empty():
|
|
||||||
return cleanup_failure
|
|
||||||
|
|
||||||
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, launch)
|
|
||||||
var result := McpCliExec.run(cli, args, _CONFIGURE_TIMEOUT_MS)
|
|
||||||
var termination_failure := _unproven_mutation_failure(client, "Configure", result)
|
|
||||||
if not termination_failure.is_empty():
|
|
||||||
return termination_failure
|
|
||||||
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": McpClient.configured_message(client, 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
|
|
||||||
## the expected target. The matching rule is the only sensible one for "list
|
|
||||||
## MCP entries" output across CLI clients we currently support: name AND
|
|
||||||
## target present → CONFIGURED; name only → MISMATCH; neither →
|
|
||||||
## NOT_CONFIGURED. For URL descriptors the target is `server_url`; for
|
|
||||||
## command-shape descriptors it is the resolved attach launcher path (the
|
|
||||||
## listing prints the registered command line, not a URL). Command-shape CLI
|
|
||||||
## clients with a JSON fallback file get exact drift detection via the JSON
|
|
||||||
## strategy instead — the configurator prefers that path and only lands here
|
|
||||||
## for CLI clients whose state isn't file-readable.
|
|
||||||
static func check_status(
|
|
||||||
client: McpClient, server_name: String, server_url: String, launch: Dictionary = {}
|
|
||||||
) -> McpClient.Status:
|
|
||||||
return check_status_with_cli_path(client, server_name, server_url, _resolve_cli(client), launch)
|
|
||||||
|
|
||||||
|
|
||||||
static func check_status_with_cli_path(
|
|
||||||
client: McpClient, server_name: String, server_url: String, cli: String, launch: Dictionary = {}
|
|
||||||
) -> McpClient.Status:
|
|
||||||
return check_status_details(client, server_name, server_url, cli, launch).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, launch: Dictionary = {}
|
|
||||||
) -> 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 expected_target := server_url
|
|
||||||
if client.command_shape != McpClient.CommandShape.NONE:
|
|
||||||
## Same fail-closed contract as configure: without a verified launcher
|
|
||||||
## there is no target to compare against, and guessing would report a
|
|
||||||
## broken entry as green.
|
|
||||||
var launch_error := command_launch_error(client, launch)
|
|
||||||
if not launch_error.is_empty():
|
|
||||||
return _status_details(McpClient.Status.ERROR, launch_error)
|
|
||||||
expected_target = str(launch.get("command", ""))
|
|
||||||
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(expected_target) < 0:
|
|
||||||
return _status_details(
|
|
||||||
McpClient.Status.CONFIGURED_MISMATCH, "", _probe_output_launches_godot_ai(text)
|
|
||||||
)
|
|
||||||
return _status_details(McpClient.Status.CONFIGURED)
|
|
||||||
|
|
||||||
|
|
||||||
## Empty string when this client's launch requirements are satisfied. A
|
|
||||||
## command-shape descriptor requires a successfully resolved attach launch;
|
|
||||||
## URL descriptors (`CommandShape.NONE`) never require one. Mirrors
|
|
||||||
## `McpJsonStrategy.command_launch_error` / the TOML equivalent.
|
|
||||||
static func command_launch_error(client: McpClient, launch: Dictionary) -> String:
|
|
||||||
if client.command_shape == McpClient.CommandShape.NONE:
|
|
||||||
return ""
|
|
||||||
if not bool(launch.get("ok", false)):
|
|
||||||
return str(launch.get("error", "No compatible attach launcher was found."))
|
|
||||||
return ""
|
|
||||||
|
|
||||||
|
|
||||||
static func _status_details(
|
|
||||||
status: McpClient.Status, error_msg: String = "", owned: bool = false
|
|
||||||
) -> Dictionary:
|
|
||||||
return {"status": status, "error_msg": error_msg, "owned": owned}
|
|
||||||
|
|
||||||
|
|
||||||
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}
|
|
||||||
return _remove_claimed(client, server_name, cli)
|
|
||||||
|
|
||||||
|
|
||||||
static func _remove_claimed(client: McpClient, server_name: String, cli: String) -> Dictionary:
|
|
||||||
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)
|
|
||||||
var termination_failure := _unproven_mutation_failure(client, "Remove", result)
|
|
||||||
if not termination_failure.is_empty():
|
|
||||||
return termination_failure
|
|
||||||
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]}
|
|
||||||
|
|
||||||
|
|
||||||
## A POSIX exact-PID kill cannot prove that a CLI's descendants stopped. Never
|
|
||||||
## issue the next write after that ambiguous boundary: the old process could
|
|
||||||
## otherwise land a stale mutation after the new one. The durable global claim
|
|
||||||
## remains until the user explicitly clears it after stopping those processes.
|
|
||||||
static func _unproven_mutation_failure(
|
|
||||||
client: McpClient, operation: String, result: Dictionary
|
|
||||||
) -> Dictionary:
|
|
||||||
if not bool(result.get("termination_failed", false)):
|
|
||||||
return {}
|
|
||||||
return {
|
|
||||||
"status": "error",
|
|
||||||
"message": (
|
|
||||||
"%s for %s could not be proven stopped. %s"
|
|
||||||
% [operation, client.display_name, MutationLock.recovery_message()]
|
|
||||||
),
|
|
||||||
"termination_failed": true,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
## 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.
|
|
||||||
##
|
|
||||||
## Launch tokens are whole-element only: an element that is exactly
|
|
||||||
## `{command}` becomes the resolved attach launcher path, and an element that
|
|
||||||
## is exactly `{args...}` is spliced into the argv as one element per launch
|
|
||||||
## arg. Whole-element matching keeps a literal brace inside a path or flag
|
|
||||||
## from ever triggering an expansion.
|
|
||||||
##
|
|
||||||
## `scope_override` forces a specific `{scope}` value instead of the live
|
|
||||||
## setting; the configure pre-cleanup uses it to sweep the scopes the user is
|
|
||||||
## NOT currently on. Empty means "resolve from the setting".
|
|
||||||
static func format_args(
|
|
||||||
template: PackedStringArray,
|
|
||||||
server_name: String,
|
|
||||||
server_url: String,
|
|
||||||
launch: Dictionary = {},
|
|
||||||
scope_override: String = "",
|
|
||||||
) -> Array[String]:
|
|
||||||
return _format_args(template, server_name, server_url, launch, scope_override)
|
|
||||||
|
|
||||||
|
|
||||||
static func _format_args(
|
|
||||||
template: PackedStringArray,
|
|
||||||
server_name: String,
|
|
||||||
server_url: String,
|
|
||||||
launch: Dictionary = {},
|
|
||||||
scope_override: String = "",
|
|
||||||
) -> Array[String]:
|
|
||||||
var scope := scope_override if not scope_override.is_empty() else McpSettings.client_scope()
|
|
||||||
var out: Array[String] = []
|
|
||||||
for arg in template:
|
|
||||||
var s := String(arg)
|
|
||||||
if s == "{command}":
|
|
||||||
out.append(str(launch.get("command", "")))
|
|
||||||
continue
|
|
||||||
if s == "{args...}":
|
|
||||||
for launch_arg in launch.get("args", []):
|
|
||||||
out.append(str(launch_arg))
|
|
||||||
continue
|
|
||||||
s = s.replace("{name}", server_name)
|
|
||||||
s = s.replace("{url}", server_url)
|
|
||||||
## Resolved per-call rather than baked into the descriptor so the
|
|
||||||
## setting can change without an editor restart, and so the manual
|
|
||||||
## command shown in the dock always matches what Configure would run.
|
|
||||||
s = s.replace(SCOPE_TOKEN, scope)
|
|
||||||
out.append(s)
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
## Scope-aware status for descriptors that declare `cli_scope_status_template`.
|
|
||||||
## Returns the same `{"status", "error_msg"}` shape as check_status_details.
|
|
||||||
##
|
|
||||||
## Split from the subprocess call so the verdict is a pure function of what the
|
|
||||||
## CLI printed — `_scope_probe_verdict` is unit-tested against real recorded
|
|
||||||
## `claude mcp get` output rather than needing a `claude` binary on the runner.
|
|
||||||
static func check_scope_status_details(
|
|
||||||
client: McpClient,
|
|
||||||
server_name: String,
|
|
||||||
server_url: String,
|
|
||||||
cli: String,
|
|
||||||
launch: Dictionary,
|
|
||||||
expected_scope: String,
|
|
||||||
) -> Dictionary:
|
|
||||||
if cli.is_empty() or client.cli_scope_status_template.is_empty():
|
|
||||||
return _status_details(McpClient.Status.NOT_CONFIGURED)
|
|
||||||
var expected_target := server_url
|
|
||||||
var expected_args: Array[String] = []
|
|
||||||
var expected_type := ""
|
|
||||||
if client.command_shape != McpClient.CommandShape.NONE:
|
|
||||||
## Same fail-closed contract as configure and the plain status probe.
|
|
||||||
var launch_error := command_launch_error(client, launch)
|
|
||||||
if not launch_error.is_empty():
|
|
||||||
return _status_details(McpClient.Status.ERROR, launch_error)
|
|
||||||
expected_target = str(launch.get("command", ""))
|
|
||||||
for launch_arg in launch.get("args", []):
|
|
||||||
expected_args.append(str(launch_arg))
|
|
||||||
expected_type = str(client.command_transport_value)
|
|
||||||
var args := _format_args(client.cli_scope_status_template, server_name, server_url)
|
|
||||||
var result := McpCliExec.run(cli, 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)
|
|
||||||
return _scope_probe_verdict(
|
|
||||||
int(result.get("exit_code", -1)),
|
|
||||||
str(result.get("stdout", "")),
|
|
||||||
expected_scope,
|
|
||||||
expected_target,
|
|
||||||
expected_args,
|
|
||||||
expected_type,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
## The `Scope:` label the probe printed, normalised to a CLIENT_SCOPES value,
|
|
||||||
## or "" when no line was recognisable. Matched on the first word after the
|
|
||||||
## label so the parenthetical blurb ("(shared via .mcp.json)") can change
|
|
||||||
## wording between CLI releases without breaking detection.
|
|
||||||
static func _scope_from_probe_output(text: String) -> String:
|
|
||||||
for raw_line in text.split("\n"):
|
|
||||||
var line := String(raw_line).strip_edges()
|
|
||||||
var marker := line.find("Scope:")
|
|
||||||
if marker < 0:
|
|
||||||
continue
|
|
||||||
var rest := line.substr(marker + 6).strip_edges().to_lower()
|
|
||||||
for scope in McpSettings.CLIENT_SCOPES:
|
|
||||||
var name := String(scope)
|
|
||||||
if rest.begins_with(name):
|
|
||||||
return name
|
|
||||||
return ""
|
|
||||||
|
|
||||||
|
|
||||||
## The probe output names our server; only its launch fields decide whether
|
|
||||||
## the existing entry is ours to rewrite after an update.
|
|
||||||
static func _probe_output_launches_godot_ai(text: String) -> bool:
|
|
||||||
return McpClient.launch_mentions_godot_ai(
|
|
||||||
_field_from_probe_output(text, "Command:")
|
|
||||||
+ " "
|
|
||||||
+ _field_from_probe_output(text, "Args:")
|
|
||||||
+ " "
|
|
||||||
+ _field_from_probe_output(text, "URL:")
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
static func _field_from_probe_output(text: String, label: String) -> String:
|
|
||||||
for raw_line in text.split("\n"):
|
|
||||||
var line := String(raw_line).strip_edges()
|
|
||||||
if line.begins_with(label):
|
|
||||||
return line.substr(label.length()).strip_edges()
|
|
||||||
return ""
|
|
||||||
|
|
||||||
|
|
||||||
## Pure verdict for a scope probe. A non-zero exit is the CLI's "no such
|
|
||||||
## server" signal. An entry resolved from a scope other than the selected one
|
|
||||||
## is MISMATCH, not CONFIGURED: the dock offers Reconfigure, and Configure's
|
|
||||||
## all-scope pre-cleanup is what actually moves it.
|
|
||||||
##
|
|
||||||
## A missing or unrecognised `Scope:` line degrades to the target check alone,
|
|
||||||
## deliberately rather than failing closed. `_verify_post_state` turns any
|
|
||||||
## non-CONFIGURED status after a successful write into an error, so treating an
|
|
||||||
## unparsed label as MISMATCH would mean a future CLI release that reworded or
|
|
||||||
## dropped that line breaks Configure outright — a false error plus a
|
|
||||||
## permanently amber row whose Reconfigure button cannot clear it. The entry
|
|
||||||
## has already been matched by name and exact launcher path at that point; the
|
|
||||||
## only thing in doubt is which scope it came from, and a slightly optimistic
|
|
||||||
## dot is a much cheaper failure than a configure flow that reports success as
|
|
||||||
## failure. Today's claude (2.1.241) always prints the line, so this is a
|
|
||||||
## forward-compatibility hedge, not a live code path.
|
|
||||||
static func _scope_probe_verdict(
|
|
||||||
exit_code: int,
|
|
||||||
text: String,
|
|
||||||
expected_scope: String,
|
|
||||||
expected_target: String,
|
|
||||||
expected_args: Array[String] = [],
|
|
||||||
expected_type: String = "",
|
|
||||||
) -> Dictionary:
|
|
||||||
if exit_code != 0:
|
|
||||||
return _status_details(McpClient.Status.NOT_CONFIGURED)
|
|
||||||
## `resolved_scope` rides along as structured data so callers can act on it
|
|
||||||
## — `_verify_post_state`'s path hint needs to know WHICH scope survived to
|
|
||||||
## name the right file, and parsing it back out of the human-facing message
|
|
||||||
## would couple that hint to this wording (#879). Parsed BEFORE the target
|
|
||||||
## check so both mismatch paths carry it: an entry whose launcher drifted
|
|
||||||
## sends the user to the wrong file just as readily as one whose scope did.
|
|
||||||
var resolved := _scope_from_probe_output(text)
|
|
||||||
var command_matches := (
|
|
||||||
expected_target.is_empty()
|
|
||||||
or _field_from_probe_output(text, "Command:") == expected_target
|
|
||||||
)
|
|
||||||
var launch_details_match := true
|
|
||||||
if not expected_type.is_empty():
|
|
||||||
launch_details_match = (
|
|
||||||
_field_from_probe_output(text, "Type:") == expected_type
|
|
||||||
and _field_from_probe_output(text, "Args:") == " ".join(expected_args)
|
|
||||||
)
|
|
||||||
if not command_matches or not launch_details_match:
|
|
||||||
var drifted := _status_details(
|
|
||||||
McpClient.Status.CONFIGURED_MISMATCH, "", _probe_output_launches_godot_ai(text)
|
|
||||||
)
|
|
||||||
if not resolved.is_empty():
|
|
||||||
drifted["resolved_scope"] = resolved
|
|
||||||
return drifted
|
|
||||||
if resolved.is_empty():
|
|
||||||
return _status_details(McpClient.Status.CONFIGURED)
|
|
||||||
if resolved != expected_scope:
|
|
||||||
var details := _status_details(
|
|
||||||
McpClient.Status.CONFIGURED_MISMATCH,
|
|
||||||
"registered at %s scope, not %s" % [resolved, expected_scope],
|
|
||||||
_probe_output_launches_godot_ai(text),
|
|
||||||
)
|
|
||||||
details["resolved_scope"] = resolved
|
|
||||||
return details
|
|
||||||
return _status_details(McpClient.Status.CONFIGURED)
|
|
||||||
|
|
||||||
|
|
||||||
## True when the descriptor's register template takes `{scope}`, i.e. where
|
|
||||||
## its entry lands is decided by the `godot_ai/mcp_client_scope` setting rather
|
|
||||||
## than fixed by the descriptor. The configurator uses this to decide whether
|
|
||||||
## the JSON-fallback file is still a valid place to read status back from.
|
|
||||||
static func uses_scope_token(client: McpClient) -> bool:
|
|
||||||
return client.cli_register_template.has(SCOPE_TOKEN)
|
|
||||||
|
|
||||||
|
|
||||||
## Public view of the pre-cleanup sweep, for the manual-command text: what the
|
|
||||||
## dock tells the user to run has to match what Configure actually runs (#877).
|
|
||||||
static func cleanup_scopes(client: McpClient) -> Array[String]:
|
|
||||||
return _cleanup_scopes(client)
|
|
||||||
|
|
||||||
|
|
||||||
## Scopes the configure pre-cleanup removes from. A descriptor without the
|
|
||||||
## `{scope}` token has exactly one place its entry can live, so it keeps the
|
|
||||||
## single pass it always had — "" means "resolve the scope normally".
|
|
||||||
static func _cleanup_scopes(client: McpClient) -> Array[String]:
|
|
||||||
if not uses_scope_token(client):
|
|
||||||
return [""]
|
|
||||||
var scopes: Array[String] = []
|
|
||||||
for scope in McpSettings.CLIENT_SCOPES:
|
|
||||||
scopes.append(String(scope))
|
|
||||||
return scopes
|
|
||||||
|
|
||||||
|
|
||||||
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)
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
uid://bvib7d8eabbcm
|
|
||||||
@@ -1,763 +0,0 @@
|
|||||||
@tool
|
|
||||||
class_name McpDshStrategy
|
|
||||||
extends RefCounted
|
|
||||||
|
|
||||||
## Configure / status / remove for DeepSeek Harness (dsh) MCP servers.
|
|
||||||
##
|
|
||||||
## DeepSeek Harness has no `mcp` CLI verb (verified against dsh
|
|
||||||
## 0.1.0-rc.6 — `dsh --help` exposes only `web` and `plugin`). MCP servers
|
|
||||||
## register as `@deepseek-ai/dsh-mcp-client` plugin entries in the HOME patch
|
|
||||||
## layer `$DSH_HOME/cordis.patch.yml` — the machine-local layer dsh applies
|
|
||||||
## over EVERY profile's own patch layer (the web GUI included), so one entry
|
|
||||||
## covers every profile the user boots.
|
|
||||||
##
|
|
||||||
## The patch file is a top-level YAML array of loader patch entries
|
|
||||||
## (`@deepseek-ai/cordis-plugin-include`'s `applyEntryPatches`). A non-insert
|
|
||||||
## row only OVERRIDES an existing bundle row (a new id is skipped with a
|
|
||||||
## warning), so a brand-new server must be added with an `insert` row:
|
|
||||||
##
|
|
||||||
## - insert:
|
|
||||||
## - id: mcp-godot-ai
|
|
||||||
## name: '@deepseek-ai/dsh-mcp-client'
|
|
||||||
## config:
|
|
||||||
## serverName: godot-ai
|
|
||||||
## transport: stdio
|
|
||||||
## command: "..."
|
|
||||||
## args: [...]
|
|
||||||
##
|
|
||||||
## Both shapes verified live against the dsh loader
|
|
||||||
## (`dsh --profile web --dump-config` composes the inserted entry; a plain
|
|
||||||
## row for a new id warns "patch: entry not found" and is skipped). We write
|
|
||||||
## one dedicated insert row per server, preserve every other row byte-for-byte
|
|
||||||
## (including other users' insert rows, overrides, comments, and `!!js`
|
|
||||||
## expressions), and status-verify the entry we wrote — the same file-read
|
|
||||||
## drift contract the JSON/TOML/YAML strategies use.
|
|
||||||
##
|
|
||||||
## The entry's `config` nests the launch under `serverName`/`transport`/
|
|
||||||
## `command`/`args`; `serverName` is the model-facing tool namespace
|
|
||||||
## (`mcp__godot-ai__<tool>`), so it carries the server name like the
|
|
||||||
## `server_key_path` map key does for the other strategies.
|
|
||||||
|
|
||||||
## Loader entry id prefix inside the patch list. The full id is
|
|
||||||
## `<prefix><server_name>` — "mcp-godot-ai" for the godot-ai server.
|
|
||||||
const ENTRY_ID_PREFIX := "mcp-"
|
|
||||||
## The loader plugin that bridges MCP servers into the harness. Must be
|
|
||||||
## installed in the profile's node_modules (dsh ships it as a dependency of
|
|
||||||
## the CLI package). Quoted in the file: `@` is a reserved YAML indicator and
|
|
||||||
## cannot start a plain scalar.
|
|
||||||
const PLUGIN_NAME := "@deepseek-ai/dsh-mcp-client"
|
|
||||||
## Indent of a nested entry header under `- insert:` (children step 2 spaces
|
|
||||||
## deeper), matching the shipped profile patch files.
|
|
||||||
const ENTRY_INDENT := 4
|
|
||||||
|
|
||||||
|
|
||||||
static func entry_id(server_name: String) -> String:
|
|
||||||
return ENTRY_ID_PREFIX + server_name
|
|
||||||
|
|
||||||
|
|
||||||
static func configure(
|
|
||||||
client: McpClient,
|
|
||||||
server_name: String,
|
|
||||||
server_url: String,
|
|
||||||
launch: Dictionary = {},
|
|
||||||
) -> Dictionary:
|
|
||||||
var resolution := client.resolved_config_path_details()
|
|
||||||
var path := str(resolution.get("path", ""))
|
|
||||||
var path_error := str(resolution.get("error", ""))
|
|
||||||
if not path_error.is_empty():
|
|
||||||
return {"status": "error", "message": path_error}
|
|
||||||
if path.is_empty():
|
|
||||||
return {"status": "error", "message": "Could not resolve config path for %s on this OS" % client.display_name}
|
|
||||||
## Fail closed before touching the file — same contract as JSON/TOML/YAML.
|
|
||||||
var launch_error := command_launch_error(client, launch)
|
|
||||||
if not launch_error.is_empty():
|
|
||||||
return {"status": "error", "message": launch_error}
|
|
||||||
|
|
||||||
var read := _read(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"]]}
|
|
||||||
## Fail closed on content that is not a loader patch list: dsh's patch
|
|
||||||
## parser rejects a non-array file at boot, so appending our row to a
|
|
||||||
## top-level mapping would hand the user a broken file (#867 review).
|
|
||||||
## `[]` is a valid empty sequence meaning "no rows"; the literal must not
|
|
||||||
## survive an append (block rows after `[]` are malformed YAML), so only
|
|
||||||
## that line is dropped — comments around it belong to the user and stay.
|
|
||||||
var text := String(read["data"])
|
|
||||||
text = _strip_empty_flow_sequence(text)
|
|
||||||
if not _is_patch_sequence(text):
|
|
||||||
return {
|
|
||||||
"status": "error",
|
|
||||||
"message": "Refusing to overwrite %s: it is not a top-level YAML list of loader entries. Fix or remove the file, then re-run Configure." % path,
|
|
||||||
}
|
|
||||||
|
|
||||||
var existing_config: Variant = null
|
|
||||||
var lines := _split_lines(text)
|
|
||||||
var found := _find_entry_block(lines, entry_id(server_name), true)
|
|
||||||
if found.is_empty():
|
|
||||||
var plain := _find_entry_block(lines, entry_id(server_name), false)
|
|
||||||
if not plain.is_empty():
|
|
||||||
## A plain `- id: mcp-godot-ai` row is inert for a new id — the
|
|
||||||
## loader skips it with a warning and never registers the server.
|
|
||||||
## Replacing the whole row with the insert form migrates it.
|
|
||||||
found = plain
|
|
||||||
if not found.is_empty():
|
|
||||||
## Pass the parsed config through so user-mutable fields (env,
|
|
||||||
## toolCallTimeoutMs, reconnect) survive a reconfigure.
|
|
||||||
existing_config = _extract_config(lines, found)
|
|
||||||
var new_entry := build_entry(client, server_name, server_url, existing_config, launch)
|
|
||||||
## Nested replacement must keep the sequence's existing header indent —
|
|
||||||
## items of one block sequence share indentation, so a hand-written
|
|
||||||
## 2-space file re-emitted at 4 spaces would no longer parse (#867
|
|
||||||
## review). Fresh rows and plain-row migrations keep the 4-space style.
|
|
||||||
var base_indent := ENTRY_INDENT
|
|
||||||
if not found.is_empty():
|
|
||||||
var header_indent := int(found.get("indent", 0))
|
|
||||||
if header_indent > 0:
|
|
||||||
base_indent = header_indent
|
|
||||||
var rendered := render_nested_entry(entry_id(server_name), new_entry, base_indent)
|
|
||||||
var out := ""
|
|
||||||
if found.is_empty():
|
|
||||||
out = _append_row(lines, rendered)
|
|
||||||
else:
|
|
||||||
out = _replace_lines(lines, found, rendered)
|
|
||||||
if not McpAtomicWrite.write(path, out):
|
|
||||||
return {"status": "error", "message": "Cannot write to %s" % path}
|
|
||||||
return {"status": "ok", "message": McpClient.configured_message(client, server_url)}
|
|
||||||
|
|
||||||
|
|
||||||
static func check_status(
|
|
||||||
client: McpClient,
|
|
||||||
server_name: String,
|
|
||||||
server_url: String,
|
|
||||||
launch: Dictionary = {},
|
|
||||||
) -> McpClient.Status:
|
|
||||||
return check_status_details(client, server_name, server_url, launch)["status"]
|
|
||||||
|
|
||||||
|
|
||||||
## Same contract as the other strategies (#711): {status, error_msg}. An
|
|
||||||
## existing-but-unreadable config is ERROR with the diagnostic — not
|
|
||||||
## NOT_CONFIGURED — so the dock row can tell "no config" from "config the
|
|
||||||
## editor can't read" instead of offering a Configure that would fail.
|
|
||||||
static func check_status_details(
|
|
||||||
client: McpClient, server_name: String, server_url: String, launch: Dictionary = {}
|
|
||||||
) -> Dictionary:
|
|
||||||
var resolution := client.resolved_config_path_details()
|
|
||||||
var path := str(resolution.get("path", ""))
|
|
||||||
var path_error := str(resolution.get("error", ""))
|
|
||||||
if not path_error.is_empty():
|
|
||||||
return {"status": McpClient.Status.ERROR, "error_msg": path_error}
|
|
||||||
if path.is_empty() or not FileAccess.file_exists(path):
|
|
||||||
return {"status": McpClient.Status.NOT_CONFIGURED, "error_msg": ""}
|
|
||||||
var read := _read(path)
|
|
||||||
if not read["ok"]:
|
|
||||||
return {
|
|
||||||
"status": McpClient.Status.ERROR,
|
|
||||||
"error_msg": "Cannot read %s: %s" % [path, read["error"]],
|
|
||||||
}
|
|
||||||
var text := String(read["data"])
|
|
||||||
if not _is_patch_sequence(text):
|
|
||||||
## A top-level mapping/scalar is not a dsh patch list: nothing in it
|
|
||||||
## can be "configured", and a nested `- id:` inside a mapping must
|
|
||||||
## not read as ours (#867 review).
|
|
||||||
return {
|
|
||||||
"status": McpClient.Status.ERROR,
|
|
||||||
"error_msg": "%s is not a top-level YAML list of loader entries; fix or remove the file." % path,
|
|
||||||
}
|
|
||||||
var lines := _split_lines(text)
|
|
||||||
var found := _find_entry_block(lines, entry_id(server_name), true)
|
|
||||||
if found.is_empty():
|
|
||||||
## A plain row for our id is inert (the loader skips unknown ids), so
|
|
||||||
## the server is NOT registered no matter what the file says. It only
|
|
||||||
## becomes registered once Configure rewrites it as an insert row.
|
|
||||||
return {"status": McpClient.Status.NOT_CONFIGURED, "error_msg": ""}
|
|
||||||
var config := _extract_config(lines, found)
|
|
||||||
if config.is_empty():
|
|
||||||
return {"status": McpClient.Status.NOT_CONFIGURED, "error_msg": ""}
|
|
||||||
## An entry exists but no verified launcher does — mirror the other
|
|
||||||
## strategies: this is an environment ERROR, not entry drift.
|
|
||||||
var launch_error := command_launch_error(client, launch)
|
|
||||||
if not launch_error.is_empty():
|
|
||||||
return {"status": McpClient.Status.ERROR, "error_msg": launch_error}
|
|
||||||
if verify_entry(client, config, server_name, server_url, launch):
|
|
||||||
return {"status": McpClient.Status.CONFIGURED, "error_msg": ""}
|
|
||||||
return {
|
|
||||||
"status": McpClient.Status.CONFIGURED_MISMATCH,
|
|
||||||
"error_msg": "",
|
|
||||||
"owned": McpClient.launch_values_mention_godot_ai(McpClient.entry_launch_values(config)),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
static func remove(client: McpClient, server_name: String) -> Dictionary:
|
|
||||||
var resolution := client.resolved_config_path_details()
|
|
||||||
var path := str(resolution.get("path", ""))
|
|
||||||
var path_error := str(resolution.get("error", ""))
|
|
||||||
if not path_error.is_empty():
|
|
||||||
return {"status": "error", "message": path_error}
|
|
||||||
if path.is_empty() or not FileAccess.file_exists(path):
|
|
||||||
return {"status": "ok", "message": "Not configured"}
|
|
||||||
var read := _read(path)
|
|
||||||
if not read["ok"]:
|
|
||||||
return {"status": "error", "message": "Refusing to rewrite %s: %s." % [path, read["error"]]}
|
|
||||||
var text := String(read["data"])
|
|
||||||
if not _is_patch_sequence(text):
|
|
||||||
## Same fail-closed contract as configure (#867 review): never modify
|
|
||||||
## a file that dsh cannot load as a patch list.
|
|
||||||
return {
|
|
||||||
"status": "error",
|
|
||||||
"message": "Refusing to rewrite %s: it is not a top-level YAML list of loader entries. Fix or remove the file, then re-run Remove." % path,
|
|
||||||
}
|
|
||||||
var lines := _split_lines(text)
|
|
||||||
var found := _find_entry_block(lines, entry_id(server_name), true)
|
|
||||||
if found.is_empty():
|
|
||||||
found = _find_entry_block(lines, entry_id(server_name), false)
|
|
||||||
if found.is_empty():
|
|
||||||
return {"status": "ok", "message": "%s configuration removed" % client.display_name}
|
|
||||||
var out := _remove_lines(lines, found)
|
|
||||||
if _is_blank_or_comment_only(out):
|
|
||||||
## dsh's patch parser requires a top-level YAML array: an empty or
|
|
||||||
## comment-only patch file FAILS BOOT (`loadOptionalPatches` throws on
|
|
||||||
## a non-array file), while an absent file means "no layer". After our
|
|
||||||
## row is gone, an all-blank file must therefore be deleted, not
|
|
||||||
## written back.
|
|
||||||
if FileAccess.file_exists(path):
|
|
||||||
var remove_err := DirAccess.remove_absolute(path)
|
|
||||||
if remove_err != OK:
|
|
||||||
return {"status": "error", "message": "Cannot remove %s (error %d)" % [path, remove_err]}
|
|
||||||
return {"status": "ok", "message": "%s configuration removed" % client.display_name}
|
|
||||||
if not McpAtomicWrite.write(path, out):
|
|
||||||
return {"status": "error", "message": "Cannot write to %s" % path}
|
|
||||||
return {"status": "ok", "message": "%s configuration removed" % client.display_name}
|
|
||||||
|
|
||||||
|
|
||||||
## Build the `config` dict written inside the loader entry. Command mode
|
|
||||||
## (FLAT, #838) nests `serverName`/`transport`/`command`/`args`; URL mode nests
|
|
||||||
## `serverName`/`transport`/`url`. Existing user state (env, timeouts,
|
|
||||||
## reconnect) survives a reconfigure via the deep copy.
|
|
||||||
static func build_entry(
|
|
||||||
client: McpClient,
|
|
||||||
server_name: String,
|
|
||||||
server_url: String,
|
|
||||||
existing: Variant = null,
|
|
||||||
launch: Dictionary = {},
|
|
||||||
) -> Dictionary:
|
|
||||||
var entry: Dictionary = (existing as Dictionary).duplicate(true) if existing is Dictionary else {}
|
|
||||||
entry["serverName"] = server_name
|
|
||||||
if client.command_shape == McpClient.CommandShape.FLAT:
|
|
||||||
entry["command"] = str(launch.get("command", ""))
|
|
||||||
entry["args"] = _array_copy(launch.get("args", []))
|
|
||||||
if not client.command_transport_key.is_empty():
|
|
||||||
entry[client.command_transport_key] = client.command_transport_value
|
|
||||||
for key in client.command_initial_fields:
|
|
||||||
if not entry.has(key):
|
|
||||||
entry[key] = client.command_initial_fields[key]
|
|
||||||
for key in client.command_legacy_keys:
|
|
||||||
entry.erase(String(key))
|
|
||||||
return entry
|
|
||||||
if client.command_shape == McpClient.CommandShape.NONE:
|
|
||||||
## Single URL-mode source of truth (#867 review): build_entry's URL
|
|
||||||
## branch delegates to build_url_entry so the two can never drift.
|
|
||||||
return build_url_entry(client, server_name, server_url, existing)
|
|
||||||
return {}
|
|
||||||
|
|
||||||
|
|
||||||
## URL-mode sibling for the manual-instruction fallback text and the
|
|
||||||
## build_entry URL branch. DeepSeek Harness' mcp-client bridge accepts
|
|
||||||
## `transport: streamable-http` with a `url`, so the entry keeps the same
|
|
||||||
## `serverName` namespace with an HTTP transport instead of the stdio attach
|
|
||||||
## bridge. Existing user state survives via the deep copy.
|
|
||||||
static func build_url_entry(
|
|
||||||
client: McpClient, server_name: String, server_url: String, existing: Variant = null
|
|
||||||
) -> Dictionary:
|
|
||||||
var entry: Dictionary = (existing as Dictionary).duplicate(true) if existing is Dictionary else {}
|
|
||||||
entry["serverName"] = server_name
|
|
||||||
entry["transport"] = "streamable-http"
|
|
||||||
if not client.entry_url_field.is_empty():
|
|
||||||
entry[client.entry_url_field] = server_url
|
|
||||||
return entry
|
|
||||||
|
|
||||||
|
|
||||||
## Verify a stored `config` dict matches the current launch. Command mode
|
|
||||||
## checks every launch-affecting value exactly and requires legacy URL keys
|
|
||||||
## gone; serverName must match the pinned namespace. URL mode checks the url
|
|
||||||
## and the streamable-http pin.
|
|
||||||
static func verify_entry(
|
|
||||||
client: McpClient,
|
|
||||||
config: Dictionary,
|
|
||||||
server_name: String,
|
|
||||||
server_url: String,
|
|
||||||
launch: Dictionary = {},
|
|
||||||
) -> bool:
|
|
||||||
if config.get("serverName") != server_name:
|
|
||||||
return false
|
|
||||||
if client.command_shape != McpClient.CommandShape.NONE:
|
|
||||||
if client.command_shape != McpClient.CommandShape.FLAT or not bool(launch.get("ok", false)):
|
|
||||||
return false
|
|
||||||
for key in client.command_legacy_keys:
|
|
||||||
if config.has(String(key)):
|
|
||||||
return false
|
|
||||||
if config.get("command") != launch.get("command"):
|
|
||||||
return false
|
|
||||||
if not _arrays_equal(config.get("args", null), launch.get("args", null)):
|
|
||||||
return false
|
|
||||||
if not client.command_transport_key.is_empty():
|
|
||||||
if config.get(client.command_transport_key, null) != client.command_transport_value:
|
|
||||||
return false
|
|
||||||
return true
|
|
||||||
if config.get(client.entry_url_field, "") != server_url:
|
|
||||||
return false
|
|
||||||
return config.get("transport", "") == "streamable-http"
|
|
||||||
|
|
||||||
|
|
||||||
## Empty string when this client's launch requirements are satisfied.
|
|
||||||
## Mirrors `McpYamlStrategy.command_launch_error`; the patch-list dialect
|
|
||||||
## supports FLAT (and the URL fallback) only.
|
|
||||||
static func command_launch_error(client: McpClient, launch: Dictionary) -> String:
|
|
||||||
if client.command_shape == McpClient.CommandShape.NONE:
|
|
||||||
return ""
|
|
||||||
if client.command_shape != McpClient.CommandShape.FLAT:
|
|
||||||
return "%s uses a command shape not supported by DeepSeek Harness config yet" % client.display_name
|
|
||||||
if not bool(launch.get("ok", false)):
|
|
||||||
return str(launch.get("error", "No compatible attach launcher was found."))
|
|
||||||
return ""
|
|
||||||
|
|
||||||
|
|
||||||
# --- Rendering --------------------------------------------------------------
|
|
||||||
|
|
||||||
## Render a full `- insert:` row containing one nested loader entry. Public
|
|
||||||
## seam for the dock's manual-instruction text so the pasted YAML matches
|
|
||||||
## what Configure would write byte-for-byte.
|
|
||||||
static func render_insert_row(entry_id_value: String, config: Dictionary) -> PackedStringArray:
|
|
||||||
var lines: PackedStringArray = ["- insert:"]
|
|
||||||
lines.append_array(render_nested_entry(entry_id_value, config))
|
|
||||||
return lines
|
|
||||||
|
|
||||||
|
|
||||||
## Render one nested loader entry (the `- id:` block inside an `insert`
|
|
||||||
## list). `base_indent` defaults to the 4-space style used by the shipped
|
|
||||||
## patch files; a nested replacement reuses the indent the existing entry was
|
|
||||||
## found at so the block sequence keeps a single indentation (#867 review).
|
|
||||||
static func render_nested_entry(
|
|
||||||
entry_id_value: String, config: Dictionary, base_indent: int = ENTRY_INDENT
|
|
||||||
) -> PackedStringArray:
|
|
||||||
var lines: PackedStringArray = []
|
|
||||||
lines.append("%s- id: %s" % [_indent_str(base_indent), entry_id_value])
|
|
||||||
lines.append("%sname: '%s'" % [_indent_str(base_indent + 2), PLUGIN_NAME])
|
|
||||||
lines.append("%sconfig:" % _indent_str(base_indent + 2))
|
|
||||||
for key in config:
|
|
||||||
lines.append_array(_emit_field(String(key), config[key], base_indent + 4))
|
|
||||||
return lines
|
|
||||||
|
|
||||||
|
|
||||||
## Emit one `key: value` line (or a nested block for Dictionary values) at
|
|
||||||
## the given indent. Arrays render in flow style; scalars through the shared
|
|
||||||
## YAML emitter so quoting rules stay identical across the two YAML dialects.
|
|
||||||
static func _emit_field(key: String, value: Variant, indent: int) -> PackedStringArray:
|
|
||||||
var lines: PackedStringArray = []
|
|
||||||
var prefix := _indent_str(indent)
|
|
||||||
if value is Dictionary:
|
|
||||||
lines.append("%s%s:" % [prefix, key])
|
|
||||||
for sub_key in value:
|
|
||||||
lines.append_array(_emit_field(String(sub_key), value[sub_key], indent + 2))
|
|
||||||
elif value is Array or value is PackedStringArray:
|
|
||||||
lines.append("%s%s: %s" % [prefix, key, McpYamlStrategy.emit_flow_array(_array_copy(value))])
|
|
||||||
else:
|
|
||||||
lines.append("%s%s: %s" % [prefix, key, McpYamlStrategy.emit_scalar(value)])
|
|
||||||
return lines
|
|
||||||
|
|
||||||
|
|
||||||
# --- Line-level parsing (no YAML parser in Godot) ---------------------------
|
|
||||||
|
|
||||||
## Split file text into lines, dropping only a single trailing newline so the
|
|
||||||
## remainder round-trips byte-for-byte. `text` is never split on the empty
|
|
||||||
## string, so an empty file yields an empty array.
|
|
||||||
static func _split_lines(text: String) -> PackedStringArray:
|
|
||||||
if text.is_empty():
|
|
||||||
return PackedStringArray()
|
|
||||||
var lines := text.split("\n")
|
|
||||||
if lines.size() > 0 and String(lines[lines.size() - 1]).is_empty():
|
|
||||||
lines.remove_at(lines.size() - 1)
|
|
||||||
return lines
|
|
||||||
|
|
||||||
|
|
||||||
## Locate the block owned by our loader entry:
|
|
||||||
## require_nested=true — the `- id: <entry_id>` line inside an `insert`
|
|
||||||
## list (indent > 0), plus its indented children.
|
|
||||||
## require_nested=false — a plain top-level `- id: <entry_id>` row (indent
|
|
||||||
## 0), plus its indented children.
|
|
||||||
## Returns {"start": int, "end": int, "indent": int} (end exclusive) or {}
|
|
||||||
## when absent. The header indent is detected dynamically so a hand-written
|
|
||||||
## file using non-2-space indentation is still found; children are lines
|
|
||||||
## deeper than the header. `end` stops right AFTER the last content line:
|
|
||||||
## trailing blank and comment lines between our entry and the next sibling
|
|
||||||
## belong to the file, not to our block, so `_replace_lines` / `_remove_lines`
|
|
||||||
## (which splice [start, end)) never delete them (#867 review).
|
|
||||||
static func _find_entry_block(lines: PackedStringArray, entry_id_value: String, require_nested: bool) -> Dictionary:
|
|
||||||
for i in range(lines.size()):
|
|
||||||
var indent := _indent_of(lines[i])
|
|
||||||
if require_nested and indent <= 0:
|
|
||||||
continue
|
|
||||||
if not require_nested and indent != 0:
|
|
||||||
continue
|
|
||||||
var parsed := _parse_entry_header(lines[i])
|
|
||||||
if parsed.is_empty() or parsed.get("id") != entry_id_value:
|
|
||||||
continue
|
|
||||||
## A nested match must actually live inside an `- insert:` row: a
|
|
||||||
## `- id:` list under some other top-level row (an override row's own
|
|
||||||
## sequence, say) is not a loader entry and is not ours to touch.
|
|
||||||
if require_nested and not _under_insert_row(lines, i):
|
|
||||||
continue
|
|
||||||
var j := i + 1
|
|
||||||
var last_content := i
|
|
||||||
while j < lines.size():
|
|
||||||
var l := lines[j]
|
|
||||||
if _is_blank_or_comment(l):
|
|
||||||
j += 1
|
|
||||||
continue
|
|
||||||
if _indent_of(l) <= indent:
|
|
||||||
break
|
|
||||||
last_content = j
|
|
||||||
j += 1
|
|
||||||
return {"start": i, "end": last_content + 1, "indent": indent}
|
|
||||||
return {}
|
|
||||||
|
|
||||||
|
|
||||||
## True when the nearest preceding 0-indent content line above `index` is an
|
|
||||||
## `- insert:` row header. Blank and comment lines are skipped; hitting the
|
|
||||||
## top of the file without a header means the nested line is orphaned and
|
|
||||||
## cannot be a loader entry.
|
|
||||||
static func _under_insert_row(lines: PackedStringArray, index: int) -> bool:
|
|
||||||
for i in range(index - 1, -1, -1):
|
|
||||||
if _is_blank_or_comment(lines[i]):
|
|
||||||
continue
|
|
||||||
if _indent_of(lines[i]) == 0:
|
|
||||||
return lines[i].strip_edges().begins_with("- insert:")
|
|
||||||
return false
|
|
||||||
|
|
||||||
|
|
||||||
## Parse a `- id: <value>` header line into {"id": String}. Returns {} for
|
|
||||||
## any other line. The value may be plain or single/double-quoted.
|
|
||||||
static func _parse_entry_header(line: String) -> Dictionary:
|
|
||||||
var stripped := line.strip_edges()
|
|
||||||
if not stripped.begins_with("- "):
|
|
||||||
return {}
|
|
||||||
var body := stripped.substr(2).strip_edges()
|
|
||||||
if not body.begins_with("id:"):
|
|
||||||
return {}
|
|
||||||
var value := body.substr(3).strip_edges()
|
|
||||||
if value.begins_with("\"") and value.ends_with("\"") and value.length() >= 2:
|
|
||||||
var parsed: Variant = JSON.parse_string(value)
|
|
||||||
if parsed is String:
|
|
||||||
value = parsed
|
|
||||||
elif value.begins_with("'") and value.ends_with("'") and value.length() >= 2:
|
|
||||||
value = value.substr(1, value.length() - 2)
|
|
||||||
return {"id": value}
|
|
||||||
|
|
||||||
|
|
||||||
## Extract the `config:` sub-dict from a found entry block. Fields may nest
|
|
||||||
## (env, reconnect); deeper values are parsed with the same scalar coercion
|
|
||||||
## the YAML strategy uses. Returns {} when the block has no config block.
|
|
||||||
static func _extract_config(lines: PackedStringArray, block: Dictionary) -> Dictionary:
|
|
||||||
var start := int(block.get("start", -1))
|
|
||||||
var end := int(block.get("end", -1))
|
|
||||||
if start < 0:
|
|
||||||
return {}
|
|
||||||
var config_indent := -1
|
|
||||||
var config_start := -1
|
|
||||||
for i in range(start, end):
|
|
||||||
var stripped := lines[i].strip_edges()
|
|
||||||
if _is_blank_or_comment(lines[i]):
|
|
||||||
continue
|
|
||||||
if stripped == "config:":
|
|
||||||
config_indent = _indent_of(lines[i])
|
|
||||||
config_start = i
|
|
||||||
break
|
|
||||||
if config_start < 0:
|
|
||||||
return {}
|
|
||||||
return _parse_block_lines(lines, config_start + 1, end, config_indent)
|
|
||||||
|
|
||||||
|
|
||||||
## Parse sibling `key: value` lines at a fixed minimum indent into a
|
|
||||||
## Dictionary. Nested blocks (a key with no inline value) recurse and their
|
|
||||||
## child lines are consumed by the recursion — the parent index advances past
|
|
||||||
## them so they are never re-parsed as top-level config fields (#867 review).
|
|
||||||
## Flow arrays parse back into Arrays via JSON. Quoted and plain scalars
|
|
||||||
## strip via the YAML strategy's shared `coerce_scalar`.
|
|
||||||
static func _parse_block_lines(lines: PackedStringArray, start: int, end: int, min_indent: int) -> Dictionary:
|
|
||||||
var out: Dictionary = {}
|
|
||||||
var i := start
|
|
||||||
while i < end:
|
|
||||||
var l := lines[i]
|
|
||||||
if _is_blank_or_comment(l):
|
|
||||||
i += 1
|
|
||||||
continue
|
|
||||||
var indent := _indent_of(l)
|
|
||||||
if indent <= min_indent:
|
|
||||||
break
|
|
||||||
var stripped := l.strip_edges()
|
|
||||||
var colon := stripped.find(":")
|
|
||||||
if colon < 0:
|
|
||||||
i += 1
|
|
||||||
continue
|
|
||||||
var key := stripped.substr(0, colon).strip_edges()
|
|
||||||
var value := stripped.substr(colon + 1).strip_edges()
|
|
||||||
if value.is_empty():
|
|
||||||
## Nested block: find where its children end (a line at or above
|
|
||||||
## the block key's indent), parse only that span, and jump past it.
|
|
||||||
var child_end := i + 1
|
|
||||||
while child_end < end:
|
|
||||||
if not _is_blank_or_comment(lines[child_end]) and _indent_of(lines[child_end]) <= indent:
|
|
||||||
break
|
|
||||||
child_end += 1
|
|
||||||
out[key] = _parse_block_lines(lines, i + 1, child_end, indent)
|
|
||||||
i = child_end
|
|
||||||
else:
|
|
||||||
## Shared with the YAML strategy so the two dialects can never
|
|
||||||
## drift on quoting/typing rules (#867 review).
|
|
||||||
out[key] = McpYamlStrategy.coerce_scalar(value)
|
|
||||||
i += 1
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
# --- Rewrites ---------------------------------------------------------------
|
|
||||||
|
|
||||||
## Append a new `- insert:` row at the end of the file. A blank separator
|
|
||||||
## line keeps rows visually distinct; the file always ends with a newline.
|
|
||||||
static func _append_row(lines: PackedStringArray, nested_lines: PackedStringArray) -> String:
|
|
||||||
var parts: Array[String] = []
|
|
||||||
for l in lines:
|
|
||||||
parts.append(l)
|
|
||||||
## Collapse trailing blank lines so the appended row is not orphaned
|
|
||||||
## below stray empties; one separator blank keeps rows readable.
|
|
||||||
while parts.size() > 0 and String(parts[parts.size() - 1]).strip_edges().is_empty():
|
|
||||||
parts.remove_at(parts.size() - 1)
|
|
||||||
if parts.size() > 0:
|
|
||||||
parts.append("")
|
|
||||||
parts.append("- insert:")
|
|
||||||
for l in nested_lines:
|
|
||||||
parts.append(l)
|
|
||||||
return _join_lines(parts)
|
|
||||||
|
|
||||||
|
|
||||||
## Replace the lines owned by our entry. When the found block is a plain
|
|
||||||
## top-level row (indent 0 header), the whole row — including its children —
|
|
||||||
## is replaced by the insert form. When it is a nested block inside an
|
|
||||||
## insert row, only that block's lines are replaced, leaving sibling entries
|
|
||||||
## and comments in the same row byte-for-byte intact.
|
|
||||||
static func _replace_lines(lines: PackedStringArray, block: Dictionary, nested_lines: PackedStringArray) -> String:
|
|
||||||
var start := int(block.get("start", -1))
|
|
||||||
var end := int(block.get("end", -1))
|
|
||||||
var parts: Array[String] = []
|
|
||||||
for i in range(lines.size()):
|
|
||||||
if i == start:
|
|
||||||
## A nested block keeps the surrounding `- insert:` row, so only
|
|
||||||
## the nested entry lines (indent > 0) are spliced in.
|
|
||||||
if _indent_of(lines[start]) > 0:
|
|
||||||
for l in nested_lines:
|
|
||||||
parts.append(l)
|
|
||||||
else:
|
|
||||||
parts.append("- insert:")
|
|
||||||
for l in nested_lines:
|
|
||||||
parts.append(l)
|
|
||||||
if i >= start and i < end:
|
|
||||||
continue
|
|
||||||
parts.append(lines[i])
|
|
||||||
return _join_lines(parts)
|
|
||||||
|
|
||||||
|
|
||||||
## Remove our entry. A nested block is spliced out; if its enclosing insert
|
|
||||||
## row is left without any nested entries (only the bare `- insert:` header,
|
|
||||||
## blanks, and comments), the row header goes too so the file stays a valid
|
|
||||||
## patch list. A plain row for our id is removed wholesale.
|
|
||||||
static func _remove_lines(lines: PackedStringArray, block: Dictionary) -> String:
|
|
||||||
var start := int(block.get("start", -1))
|
|
||||||
var end := int(block.get("end", -1))
|
|
||||||
var nested := _indent_of(lines[start]) > 0
|
|
||||||
var parts: Array[String] = []
|
|
||||||
if nested:
|
|
||||||
## Find the enclosing `- insert:` row header: the nearest preceding
|
|
||||||
## 0-indent `- ` line.
|
|
||||||
var row_start := -1
|
|
||||||
for i in range(start - 1, -1, -1):
|
|
||||||
if _indent_of(lines[i]) == 0 and lines[i].strip_edges().begins_with("- "):
|
|
||||||
row_start = i
|
|
||||||
break
|
|
||||||
## Row end: first 0-indent non-blank/comment line after the block.
|
|
||||||
var row_end := lines.size()
|
|
||||||
for i in range(end, lines.size()):
|
|
||||||
if _indent_of(lines[i]) == 0 and not _is_blank_or_comment(lines[i]):
|
|
||||||
row_end = i
|
|
||||||
break
|
|
||||||
var has_other_entries := false
|
|
||||||
for i in range(row_start + 1, row_end):
|
|
||||||
if i >= start and i < end:
|
|
||||||
continue
|
|
||||||
if _is_blank_or_comment(lines[i]):
|
|
||||||
continue
|
|
||||||
if _indent_of(lines[i]) > 0 and lines[i].strip_edges().begins_with("- "):
|
|
||||||
has_other_entries = true
|
|
||||||
break
|
|
||||||
if has_other_entries or row_start < 0:
|
|
||||||
## Only our block goes; sibling entries and any trailing
|
|
||||||
## comments in the same row survive.
|
|
||||||
for i in range(lines.size()):
|
|
||||||
if i >= start and i < end:
|
|
||||||
continue
|
|
||||||
parts.append(lines[i])
|
|
||||||
else:
|
|
||||||
## The row held only our entry: drop the `- insert:` header and
|
|
||||||
## our block, but keep trailing blank/comment lines — a comment
|
|
||||||
## the user wrote above the next row must survive (#867 review).
|
|
||||||
for i in range(lines.size()):
|
|
||||||
if i >= row_start and i < end:
|
|
||||||
continue
|
|
||||||
parts.append(lines[i])
|
|
||||||
parts = _trim_trailing_blanks(parts)
|
|
||||||
else:
|
|
||||||
## Plain top-level row for our id: drop the whole block.
|
|
||||||
for i in range(lines.size()):
|
|
||||||
if i >= start and i < end:
|
|
||||||
continue
|
|
||||||
parts.append(lines[i])
|
|
||||||
parts = _trim_trailing_blanks(parts)
|
|
||||||
return _join_lines(parts)
|
|
||||||
|
|
||||||
|
|
||||||
## Remove trailing blank lines left behind by a removal.
|
|
||||||
static func _trim_trailing_blanks(parts: Array[String]) -> Array[String]:
|
|
||||||
while parts.size() > 0 and String(parts[parts.size() - 1]).strip_edges().is_empty():
|
|
||||||
parts.remove_at(parts.size() - 1)
|
|
||||||
return parts
|
|
||||||
|
|
||||||
|
|
||||||
## True when the text holds no patch rows at all — blank lines and comments
|
|
||||||
## only. Such a file cannot be written back (see `remove`), because dsh's
|
|
||||||
## patch parser rejects a non-array file at boot.
|
|
||||||
static func _is_blank_or_comment_only(text: String) -> bool:
|
|
||||||
for line in text.split("\n"):
|
|
||||||
var stripped := line.strip_edges()
|
|
||||||
if not stripped.is_empty() and not stripped.begins_with("#"):
|
|
||||||
return false
|
|
||||||
return true
|
|
||||||
|
|
||||||
|
|
||||||
## When the document's only content line is the empty flow sequence `[]`,
|
|
||||||
## return the text with that one line removed — blanks and comments stay,
|
|
||||||
## because they belong to the user. Any other document comes back unchanged;
|
|
||||||
## a `[]` mixed with real rows is left for `_is_patch_sequence` to refuse.
|
|
||||||
static func _strip_empty_flow_sequence(text: String) -> String:
|
|
||||||
var lines := _split_lines(text)
|
|
||||||
var bracket_line := -1
|
|
||||||
for i in range(lines.size()):
|
|
||||||
if _is_blank_or_comment(lines[i]):
|
|
||||||
continue
|
|
||||||
if bracket_line < 0 and lines[i].strip_edges() == "[]":
|
|
||||||
bracket_line = i
|
|
||||||
continue
|
|
||||||
return text
|
|
||||||
if bracket_line < 0:
|
|
||||||
return text
|
|
||||||
var parts: Array[String] = []
|
|
||||||
for i in range(lines.size()):
|
|
||||||
if i != bracket_line:
|
|
||||||
parts.append(lines[i])
|
|
||||||
if parts.is_empty():
|
|
||||||
return ""
|
|
||||||
return _join_lines(parts)
|
|
||||||
|
|
||||||
|
|
||||||
## True when the text is a valid loader patch list (or empty/comment-only /
|
|
||||||
## the empty flow sequence `[]`, which all mean "no layer"). Anything else —
|
|
||||||
## a top-level mapping or scalar — would make dsh fail boot, so Configure
|
|
||||||
## refuses to write into it instead of producing a broken file (#867 review).
|
|
||||||
## Only 0-indent content lines define the top-level structure: they must all
|
|
||||||
## be `- ` rows. Deeper lines are row children and are not inspected. `[]` is
|
|
||||||
## valid only as the COMPLETE document — appending rows after the empty flow
|
|
||||||
## sequence would produce malformed YAML, so a mixed `[]` + rows file fails
|
|
||||||
## the check too.
|
|
||||||
static func _is_patch_sequence(text: String) -> bool:
|
|
||||||
if text.strip_edges().is_empty():
|
|
||||||
return true
|
|
||||||
var saw_brackets := false
|
|
||||||
var content_count := 0
|
|
||||||
for line in text.split("\n"):
|
|
||||||
var stripped := line.strip_edges()
|
|
||||||
if stripped.is_empty() or stripped.begins_with("#"):
|
|
||||||
continue
|
|
||||||
content_count += 1
|
|
||||||
if stripped == "[]":
|
|
||||||
saw_brackets = true
|
|
||||||
continue
|
|
||||||
if _indent_of(line) == 0 and not stripped.begins_with("- "):
|
|
||||||
return false
|
|
||||||
if saw_brackets and content_count > 1:
|
|
||||||
return false
|
|
||||||
return true
|
|
||||||
|
|
||||||
|
|
||||||
static func _join_lines(parts: Array) -> String:
|
|
||||||
if parts.is_empty():
|
|
||||||
return ""
|
|
||||||
var lines: Array[String] = []
|
|
||||||
for p in parts:
|
|
||||||
lines.append(String(p))
|
|
||||||
return "\n".join(lines) + "\n"
|
|
||||||
|
|
||||||
|
|
||||||
# --- Small helpers ----------------------------------------------------------
|
|
||||||
|
|
||||||
static func _indent_str(n: int) -> String:
|
|
||||||
var out := ""
|
|
||||||
for i in range(n):
|
|
||||||
out += " "
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
static func _indent_of(line: String) -> int:
|
|
||||||
var n := 0
|
|
||||||
while n < line.length() and (line[n] == " " or line[n] == "\t"):
|
|
||||||
n += 1
|
|
||||||
return n
|
|
||||||
|
|
||||||
|
|
||||||
static func _is_blank_or_comment(line: String) -> bool:
|
|
||||||
var stripped := line.strip_edges()
|
|
||||||
return stripped.is_empty() or stripped.begins_with("#")
|
|
||||||
|
|
||||||
|
|
||||||
static func _array_copy(value: Variant) -> Array:
|
|
||||||
if value is Array:
|
|
||||||
return (value as Array).duplicate(true)
|
|
||||||
if value is PackedStringArray:
|
|
||||||
return McpClient._array_from_packed(value)
|
|
||||||
return []
|
|
||||||
|
|
||||||
|
|
||||||
static func _arrays_equal(left: Variant, right: Variant) -> bool:
|
|
||||||
if not (left is Array or left is PackedStringArray):
|
|
||||||
return false
|
|
||||||
if not (right is Array or right is PackedStringArray):
|
|
||||||
return false
|
|
||||||
var left_array := _array_copy(left)
|
|
||||||
var right_array := _array_copy(right)
|
|
||||||
if left_array.size() != right_array.size():
|
|
||||||
return false
|
|
||||||
for i in range(left_array.size()):
|
|
||||||
if left_array[i] != right_array[i]:
|
|
||||||
return false
|
|
||||||
return true
|
|
||||||
|
|
||||||
|
|
||||||
## Returns {"ok": true, "data": String} when the file is absent or readable,
|
|
||||||
## and {"ok": false, "error": String} when unreadable. Callers must NOT fall
|
|
||||||
## back to an empty string on the error path — doing so blows away the user's
|
|
||||||
## other patch rows on the next write.
|
|
||||||
static func _read(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 (%s)" % error_string(err)}
|
|
||||||
var t := f.get_as_text()
|
|
||||||
f.close()
|
|
||||||
return {"ok": true, "data": t}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
uid://ch0brn7swb6qx
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1 +0,0 @@
|
|||||||
uid://g8a4iijpk22w
|
|
||||||
@@ -1,353 +0,0 @@
|
|||||||
@tool
|
|
||||||
class_name McpManualCommand
|
|
||||||
extends RefCounted
|
|
||||||
|
|
||||||
const SHELL_POSIX := "posix"
|
|
||||||
const SHELL_POWERSHELL := "powershell"
|
|
||||||
## Keep this intersection deliberately small. PowerShell treats a leading `@`
|
|
||||||
## as splatting syntax and commas as list separators, while POSIX shells accept
|
|
||||||
## both literally; quoting either is safer than trying to infer token position.
|
|
||||||
const _SHELL_BARE_SAFE_CHARS := "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_+=:./-"
|
|
||||||
|
|
||||||
## 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,
|
|
||||||
launch: Dictionary = {},
|
|
||||||
project_roots: PackedStringArray = PackedStringArray(),
|
|
||||||
) -> String:
|
|
||||||
if client.command_shape == McpClient.CommandShape.NONE:
|
|
||||||
return "%s has no authenticated v4 configuration path; do not persist the backend URL." % client.display_name
|
|
||||||
match client.config_type:
|
|
||||||
"cli":
|
|
||||||
return _build_cli(client, server_name, server_url, resolved_path, launch, project_roots)
|
|
||||||
"json":
|
|
||||||
return _build_json(client, server_name, server_url, resolved_path, launch, project_roots)
|
|
||||||
"toml":
|
|
||||||
return _build_toml(client, server_name, server_url, resolved_path, launch)
|
|
||||||
"yaml":
|
|
||||||
return _build_yaml(client, server_name, server_url, resolved_path, launch)
|
|
||||||
"dsh":
|
|
||||||
return _build_dsh(client, server_name, server_url, resolved_path, launch)
|
|
||||||
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. The attach launcher path
|
|
||||||
## inside a command-shape line stays absolute — status verification compares
|
|
||||||
## the registered command against the resolved launcher verbatim.)
|
|
||||||
static func _build_cli(
|
|
||||||
client: McpClient,
|
|
||||||
server_name: String,
|
|
||||||
server_url: String,
|
|
||||||
resolved_path: String = "",
|
|
||||||
launch: Dictionary = {},
|
|
||||||
project_roots: PackedStringArray = PackedStringArray(),
|
|
||||||
) -> String:
|
|
||||||
if client.cli_register_template.is_empty() or client.cli_names.is_empty():
|
|
||||||
return ""
|
|
||||||
var shell_kind := _shell_kind_for_platform()
|
|
||||||
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 cmd := ""
|
|
||||||
if client.command_shape != McpClient.CommandShape.NONE:
|
|
||||||
var launch_error := McpCliStrategy.command_launch_error(client, launch)
|
|
||||||
if not launch_error.is_empty():
|
|
||||||
cmd = "Attach launch command unavailable: %s" % launch_error
|
|
||||||
else:
|
|
||||||
var args := McpCliStrategy.format_args(client.cli_register_template, server_name, server_url, launch)
|
|
||||||
var parts: Array[String] = [short_name]
|
|
||||||
for arg in args:
|
|
||||||
parts.append(String(arg))
|
|
||||||
var lines := _sweep_lines(client, server_name, server_url, short_name, shell_kind)
|
|
||||||
lines.append(_render_command_line(parts, shell_kind))
|
|
||||||
cmd = _format_shell_block(lines, shell_kind) + _sweep_caveat(client)
|
|
||||||
else:
|
|
||||||
var args := McpCliStrategy.format_args(client.cli_register_template, server_name, server_url)
|
|
||||||
var parts: Array[String] = [short_name]
|
|
||||||
for arg in args:
|
|
||||||
parts.append(String(arg))
|
|
||||||
var lines := _sweep_lines(client, server_name, server_url, short_name, shell_kind)
|
|
||||||
lines.append(_render_command_line(parts, shell_kind))
|
|
||||||
cmd = _format_shell_block(lines, shell_kind) + _sweep_caveat(client)
|
|
||||||
# #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, launch, project_roots),
|
|
||||||
]
|
|
||||||
return cmd
|
|
||||||
|
|
||||||
|
|
||||||
## #877: Configure's first act is the pre-cleanup — it runs the unregister
|
|
||||||
## template once per scope the descriptor can write to (`_cli_strategy.gd`
|
|
||||||
## `configure`) before registering. Rendering only the register line made this
|
|
||||||
## hint disagree with what the button does, which is the worst property a
|
|
||||||
## "run this manually" string can have. The removes go in the SAME shell block
|
|
||||||
## as the register, in the order Configure runs them, so the whole thing stays
|
|
||||||
## one copy-paste rather than a labelled command plus loose prose.
|
|
||||||
static func _sweep_lines(
|
|
||||||
client: McpClient,
|
|
||||||
server_name: String,
|
|
||||||
server_url: String,
|
|
||||||
short_name: String,
|
|
||||||
shell_kind: String,
|
|
||||||
) -> Array[String]:
|
|
||||||
var lines: Array[String] = []
|
|
||||||
if client.cli_unregister_template.is_empty():
|
|
||||||
return lines
|
|
||||||
for pre_scope in McpCliStrategy.cleanup_scopes(client):
|
|
||||||
var args := McpCliStrategy.format_args(
|
|
||||||
client.cli_unregister_template, server_name, server_url, {}, pre_scope
|
|
||||||
)
|
|
||||||
var parts: Array[String] = [short_name]
|
|
||||||
for arg in args:
|
|
||||||
parts.append(String(arg))
|
|
||||||
lines.append(_render_command_line(parts, shell_kind))
|
|
||||||
return lines
|
|
||||||
|
|
||||||
|
|
||||||
## The one thing the commands themselves cannot show: that the project pass
|
|
||||||
## resolves `.mcp.json` against the CLI's cwd, so it can delete an entry from a
|
|
||||||
## repository that has nothing to do with this project. Only for `{scope}`
|
|
||||||
## descriptors — a single implicit-scope pass removes exactly the entry the
|
|
||||||
## register is about to rewrite, which needs no warning. Kept outside the shell
|
|
||||||
## block so pasting the block never picks up prose.
|
|
||||||
static func _sweep_caveat(client: McpClient) -> String:
|
|
||||||
if client.cli_unregister_template.is_empty() or not McpCliStrategy.uses_scope_token(client):
|
|
||||||
return ""
|
|
||||||
return (
|
|
||||||
"\n\nThe project-scope remove rewrites the .mcp.json in the directory the"
|
|
||||||
+ " editor was launched from — not necessarily this project — so it drops a"
|
|
||||||
+ " hand-maintained godot-ai entry there. Other servers in that file are left"
|
|
||||||
+ " alone."
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
static func _shell_kind_for_platform() -> String:
|
|
||||||
return SHELL_POWERSHELL if OS.get_name() == "Windows" else SHELL_POSIX
|
|
||||||
|
|
||||||
|
|
||||||
## Render a command for one explicitly named shell. The label is load-bearing:
|
|
||||||
## POSIX and PowerShell use different escaping for embedded single quotes, so
|
|
||||||
## presenting the command without its target shell invites a bad copy/paste.
|
|
||||||
static func _format_shell_command(parts: Array[String], shell_kind: String) -> String:
|
|
||||||
return _format_shell_block([_render_command_line(parts, shell_kind)], shell_kind)
|
|
||||||
|
|
||||||
|
|
||||||
## One label over N command lines. The label is per-block, not per-line:
|
|
||||||
## repeating "Run in PowerShell:" four times would bury the single line that
|
|
||||||
## actually registers.
|
|
||||||
static func _format_shell_block(lines: Array[String], shell_kind: String) -> String:
|
|
||||||
var label := "Run in PowerShell:" if shell_kind == SHELL_POWERSHELL else "Run in a POSIX shell:"
|
|
||||||
return "%s\n%s" % [label, "\n".join(lines)]
|
|
||||||
|
|
||||||
|
|
||||||
static func _render_command_line(parts: Array[String], shell_kind: String) -> String:
|
|
||||||
var rendered: Array[String] = []
|
|
||||||
for part in parts:
|
|
||||||
rendered.append(_shell_display_arg(part, shell_kind))
|
|
||||||
return " ".join(rendered)
|
|
||||||
|
|
||||||
|
|
||||||
## Quote one argv element for the paste-into-terminal hint. Single-quoted
|
|
||||||
## strings are literal in both supported shells, but embedded single quotes
|
|
||||||
## have shell-specific spellings. Backslashes, double quotes, dollar signs,
|
|
||||||
## and PowerShell backticks remain byte-for-byte unchanged inside the quotes.
|
|
||||||
static func _shell_display_arg(arg: String, shell_kind: String) -> String:
|
|
||||||
if arg.is_empty():
|
|
||||||
return "''"
|
|
||||||
var stays_bare := true
|
|
||||||
for index in range(arg.length()):
|
|
||||||
if _SHELL_BARE_SAFE_CHARS.find(arg.substr(index, 1)) < 0:
|
|
||||||
stays_bare = false
|
|
||||||
break
|
|
||||||
if stays_bare:
|
|
||||||
return arg
|
|
||||||
if shell_kind == SHELL_POWERSHELL:
|
|
||||||
return "'%s'" % arg.replace("'", "''")
|
|
||||||
return "'%s'" % arg.replace("'", "'\"'\"'")
|
|
||||||
|
|
||||||
|
|
||||||
static func _build_json(
|
|
||||||
client: McpClient,
|
|
||||||
server_name: String,
|
|
||||||
server_url: String,
|
|
||||||
resolved_path: String,
|
|
||||||
launch: Dictionary = {},
|
|
||||||
project_roots: PackedStringArray = PackedStringArray(),
|
|
||||||
) -> String:
|
|
||||||
var target := McpJsonStrategy.manual_target_details(client, server_name, resolved_path, project_roots)
|
|
||||||
var target_note := ""
|
|
||||||
if not target.get("ok", false):
|
|
||||||
target_note = "Target inspection failed: %s" % str(target.get("error", "cannot inspect config"))
|
|
||||||
target = {"path": resolved_path, "key_path": client.server_key_path}
|
|
||||||
var target_path := str(target.get("path", resolved_path))
|
|
||||||
var key_path: PackedStringArray = target.get("key_path", client.server_key_path)
|
|
||||||
var key := key_path[0] if key_path.size() > 0 else "mcpServers"
|
|
||||||
if client.command_shape != McpClient.CommandShape.NONE:
|
|
||||||
var lines: Array[String] = []
|
|
||||||
var launch_error := McpJsonStrategy.command_launch_error(client, launch)
|
|
||||||
if launch_error.is_empty():
|
|
||||||
var command_entry := McpJsonStrategy.build_entry(client, server_url, null, launch)
|
|
||||||
lines.append("Edit %s and add under \"%s\":" % [target_path, key])
|
|
||||||
lines.append(" \"%s\": %s" % [server_name, _format_entry_inline(command_entry)])
|
|
||||||
else:
|
|
||||||
lines.append("Attach launch command unavailable: %s" % launch_error)
|
|
||||||
if not target_note.is_empty():
|
|
||||||
lines.append("")
|
|
||||||
lines.append(target_note)
|
|
||||||
return "\n".join(lines)
|
|
||||||
var entry := McpJsonStrategy.build_entry(client, server_url)
|
|
||||||
var instructions := "Edit %s and add under \"%s\":\n \"%s\": %s" % [target_path, key, server_name, _format_entry_inline(entry)]
|
|
||||||
if not target_note.is_empty():
|
|
||||||
instructions += "\n\n" + target_note
|
|
||||||
return instructions
|
|
||||||
|
|
||||||
|
|
||||||
static func _build_toml(
|
|
||||||
client: McpClient,
|
|
||||||
_server_name: String,
|
|
||||||
server_url: String,
|
|
||||||
resolved_path: String,
|
|
||||||
launch: Dictionary = {},
|
|
||||||
) -> String:
|
|
||||||
var header := _toml_header(client)
|
|
||||||
if client.command_shape != McpClient.CommandShape.NONE:
|
|
||||||
var lines: Array[String] = []
|
|
||||||
var rendered := McpTomlStrategy.render_body(client, server_url, launch)
|
|
||||||
if bool(rendered.get("ok", false)):
|
|
||||||
lines.append("Edit %s and add:" % resolved_path)
|
|
||||||
lines.append(" %s" % header)
|
|
||||||
for body_line in rendered.get("lines", []):
|
|
||||||
lines.append(" %s" % str(body_line))
|
|
||||||
else:
|
|
||||||
lines.append("Attach launch command unavailable: %s" % str(rendered.get("error", "no compatible launcher found")))
|
|
||||||
return "\n".join(lines)
|
|
||||||
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)
|
|
||||||
|
|
||||||
|
|
||||||
static func _build_yaml(
|
|
||||||
client: McpClient,
|
|
||||||
server_name: String,
|
|
||||||
server_url: String,
|
|
||||||
resolved_path: String,
|
|
||||||
launch: Dictionary = {},
|
|
||||||
) -> String:
|
|
||||||
var key := client.server_key_path[0] if client.server_key_path.size() > 0 else "mcp_servers"
|
|
||||||
if client.command_shape != McpClient.CommandShape.NONE:
|
|
||||||
var lines: Array[String] = []
|
|
||||||
var launch_error := McpYamlStrategy.command_launch_error(client, launch)
|
|
||||||
if launch_error.is_empty():
|
|
||||||
var command_entry := McpYamlStrategy.build_entry(client, server_url, null, launch)
|
|
||||||
lines.append("Edit %s and add under '%s':" % [resolved_path, key])
|
|
||||||
for entry_line in McpYamlStrategy.render_entry_lines(server_name, command_entry):
|
|
||||||
lines.append(String(entry_line))
|
|
||||||
else:
|
|
||||||
lines.append("Attach launch command unavailable: %s" % launch_error)
|
|
||||||
return "\n".join(lines)
|
|
||||||
var entry := McpYamlStrategy.build_entry(client, server_url)
|
|
||||||
var lines: Array[String] = [
|
|
||||||
"Edit %s and add under '%s':" % [resolved_path, key],
|
|
||||||
" %s:" % server_name,
|
|
||||||
]
|
|
||||||
for k in entry:
|
|
||||||
lines.append(" %s: %s" % [k, str(entry[k])])
|
|
||||||
return "\n".join(lines)
|
|
||||||
|
|
||||||
|
|
||||||
## DeepSeek Harness: the user pastes a loader `insert` row into the home
|
|
||||||
## patch layer (`$DSH_HOME/cordis.patch.yml`). Auto-configure writes exactly
|
|
||||||
## these lines, so the manual text matches the file Configure would produce
|
|
||||||
## byte-for-byte.
|
|
||||||
static func _build_dsh(
|
|
||||||
client: McpClient,
|
|
||||||
server_name: String,
|
|
||||||
server_url: String,
|
|
||||||
resolved_path: String,
|
|
||||||
launch: Dictionary = {},
|
|
||||||
) -> String:
|
|
||||||
var lines: Array[String] = ["Edit %s and add this loader entry (an `insert` row; a plain `- id:` row only overrides an existing bundle id and would be skipped):" % resolved_path]
|
|
||||||
var entry_id_value := McpDshStrategy.entry_id(server_name)
|
|
||||||
if client.command_shape != McpClient.CommandShape.NONE:
|
|
||||||
var launch_error := McpDshStrategy.command_launch_error(client, launch)
|
|
||||||
if launch_error.is_empty():
|
|
||||||
var command_entry := McpDshStrategy.build_entry(client, server_name, server_url, null, launch)
|
|
||||||
for row_line in McpDshStrategy.render_insert_row(entry_id_value, command_entry):
|
|
||||||
lines.append(String(row_line))
|
|
||||||
else:
|
|
||||||
lines.append("Attach launch command unavailable: %s" % launch_error)
|
|
||||||
return "\n".join(lines)
|
|
||||||
var url_entry := McpDshStrategy.build_url_entry(client, server_name, server_url)
|
|
||||||
for row_line in McpDshStrategy.render_insert_row(entry_id_value, url_entry):
|
|
||||||
lines.append(String(row_line))
|
|
||||||
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)
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
uid://ct1wmgfk408x0
|
|
||||||
@@ -1,242 +0,0 @@
|
|||||||
@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.
|
|
||||||
|
|
||||||
## #691: dock worker threads (client-status refresh, configure/remove
|
|
||||||
## actions) and the #678 startup walk's discovery worker expand these
|
|
||||||
## templates off the main thread, while the spawn step mutates the
|
|
||||||
## process-global environment around `OS.create_process`
|
|
||||||
## (`GODOT_AI_OWNER_PID`, `GODOT_AI_PLUGIN_SPAWNED`, `PYTHONPATH`,
|
|
||||||
## `GODOT_AI_DISABLE_TELEMETRY`). A glibc `getenv` racing a concurrent
|
|
||||||
## `setenv` can return a freed pointer — rare but process-fatal. All env
|
|
||||||
## reads in this layer therefore go through `env_lookup`: on the MAIN
|
|
||||||
## thread it reads live and refreshes a mutex-guarded snapshot; off the
|
|
||||||
## main thread it serves from the snapshot, so no `OS.get_environment`
|
|
||||||
## runs concurrently with the spawn window's mutations. Callers pre-warm
|
|
||||||
## every var their workers can touch via `warm_env_snapshot` (plugin
|
|
||||||
## `_enter_tree` and the dock's phase-1 refresh prep, both main-thread,
|
|
||||||
## both before any worker starts).
|
|
||||||
static var _env_snapshot := {}
|
|
||||||
static var _env_snapshot_mutex := Mutex.new()
|
|
||||||
|
|
||||||
## Every var this layer and its sibling consumers (`_base.gd`
|
|
||||||
## `config_file_override_details`, `config_home_override_details`, `_cli_finder.gd` lookups,
|
|
||||||
## `client_configurator.gd` mode/trace reads) can touch off-main.
|
|
||||||
## Descriptor-declared config-file/config-home env names are passed as extras
|
|
||||||
## by the warm callers.
|
|
||||||
const _BASE_ENV_VARS: Array[String] = [
|
|
||||||
"HOME",
|
|
||||||
"USERPROFILE",
|
|
||||||
"XDG_CONFIG_HOME",
|
|
||||||
"APPDATA",
|
|
||||||
"LOCALAPPDATA",
|
|
||||||
"SHELL",
|
|
||||||
"ProgramFiles",
|
|
||||||
"GODOT_AI_MODE",
|
|
||||||
"GODOT_AI_STARTUP_TRACE",
|
|
||||||
## #804 (#752 adoption): _find_venv_python reads this via env_lookup on
|
|
||||||
## the dock's worker path; without pre-warming, a set override reads as
|
|
||||||
## empty there and is silently ignored — the exact misconfiguration the
|
|
||||||
## push_warning in client_configurator.gd exists to surface.
|
|
||||||
"GODOT_AI_VENV_PYTHON",
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
## Thread-safe env read (#691). Main thread: live read + snapshot refresh.
|
|
||||||
## Worker thread: snapshot only, so it can never race a main-thread
|
|
||||||
## setenv/unsetenv. A worker read of a never-warmed var returns "" — the
|
|
||||||
## same value an unset var reads as — never a live OS.get_environment,
|
|
||||||
## which would reintroduce the race for exactly the vars nobody thought
|
|
||||||
## to warm. Missing warm-up degrades resolution; it must not touch the
|
|
||||||
## process-global environment off-main.
|
|
||||||
static func env_lookup(name: String) -> String:
|
|
||||||
if OS.get_thread_caller_id() == OS.get_main_thread_id():
|
|
||||||
var live := OS.get_environment(name)
|
|
||||||
_env_snapshot_mutex.lock()
|
|
||||||
_env_snapshot[name] = live
|
|
||||||
_env_snapshot_mutex.unlock()
|
|
||||||
return live
|
|
||||||
_env_snapshot_mutex.lock()
|
|
||||||
var cached: Variant = _env_snapshot.get(name, null)
|
|
||||||
_env_snapshot_mutex.unlock()
|
|
||||||
if cached != null:
|
|
||||||
return str(cached)
|
|
||||||
return ""
|
|
||||||
|
|
||||||
|
|
||||||
## Main-thread pre-warm so subsequent worker reads never touch the real
|
|
||||||
## environment. Idempotent; safe to call before every worker dispatch.
|
|
||||||
static func warm_env_snapshot(extra_vars: PackedStringArray = PackedStringArray()) -> void:
|
|
||||||
for var_name in _BASE_ENV_VARS:
|
|
||||||
env_lookup(var_name)
|
|
||||||
for var_name in extra_vars:
|
|
||||||
if not String(var_name).is_empty():
|
|
||||||
env_lookup(String(var_name))
|
|
||||||
|
|
||||||
|
|
||||||
## Pick the right entry from a {"darwin": ..., "windows": ..., "linux": ...} map.
|
|
||||||
static func resolve(template_map: Dictionary) -> String:
|
|
||||||
var key := platform_key(template_map)
|
|
||||||
if key.is_empty():
|
|
||||||
return ""
|
|
||||||
var template: String = template_map[key]
|
|
||||||
return expand(template)
|
|
||||||
|
|
||||||
|
|
||||||
## Return the platform-specific key present in a descriptor map. `unix` is a
|
|
||||||
## shorthand for macOS and Linux. Public so descriptors can use the same
|
|
||||||
## platform selection for ordered path-candidate arrays as for one path.
|
|
||||||
static func platform_key(template_map: Dictionary) -> String:
|
|
||||||
var key := _os_key()
|
|
||||||
if template_map.has(key):
|
|
||||||
return key
|
|
||||||
if (key == "darwin" or key == "linux") and template_map.has("unix"):
|
|
||||||
return "unix"
|
|
||||||
return ""
|
|
||||||
|
|
||||||
|
|
||||||
## Expand one path template into zero or more concrete paths. A single `*` is
|
|
||||||
## allowed inside one DIRECTORY segment (for example `Packages/Claude_*`). The
|
|
||||||
## wildcard is resolved by enumerating that segment's parent; the remaining
|
|
||||||
## suffix may name a file that does not exist yet, which lets callers derive a
|
|
||||||
## deterministic create target for a fresh packaged-app install.
|
|
||||||
##
|
|
||||||
## Multiple wildcards fail closed and return no candidates. A wildcard final
|
|
||||||
## segment may identify an installation directory for `detect_paths`. Returned
|
|
||||||
## paths are sorted for deterministic tests and diagnostics; callers still
|
|
||||||
## reject ambiguous config groups rather than picking one.
|
|
||||||
static func expand_path_candidates(template: String) -> PackedStringArray:
|
|
||||||
var expanded := expand(template)
|
|
||||||
if expanded.is_empty():
|
|
||||||
return PackedStringArray()
|
|
||||||
var star := expanded.find("*")
|
|
||||||
if star < 0:
|
|
||||||
return PackedStringArray([expanded])
|
|
||||||
if expanded.find("*", star + 1) >= 0:
|
|
||||||
return PackedStringArray()
|
|
||||||
|
|
||||||
var slash_before := maxi(expanded.rfind("/", star), expanded.rfind("\\", star))
|
|
||||||
var forward_after := expanded.find("/", star)
|
|
||||||
var backward_after := expanded.find("\\", star)
|
|
||||||
var slash_after := forward_after
|
|
||||||
if slash_after < 0 or (backward_after >= 0 and backward_after < slash_after):
|
|
||||||
slash_after = backward_after
|
|
||||||
if slash_before < 0:
|
|
||||||
return PackedStringArray()
|
|
||||||
|
|
||||||
var parent := expanded.substr(0, slash_before)
|
|
||||||
var pattern := (
|
|
||||||
expanded.substr(slash_before + 1)
|
|
||||||
if slash_after < 0
|
|
||||||
else expanded.substr(slash_before + 1, slash_after - slash_before - 1)
|
|
||||||
)
|
|
||||||
var suffix := "" if slash_after < 0 else expanded.substr(slash_after + 1)
|
|
||||||
var pattern_star := pattern.find("*")
|
|
||||||
if pattern_star < 0:
|
|
||||||
return PackedStringArray()
|
|
||||||
var prefix := pattern.substr(0, pattern_star)
|
|
||||||
var ending := pattern.substr(pattern_star + 1)
|
|
||||||
var dir := DirAccess.open(parent)
|
|
||||||
if dir == null:
|
|
||||||
return PackedStringArray()
|
|
||||||
|
|
||||||
var matches := PackedStringArray()
|
|
||||||
for child in dir.get_directories():
|
|
||||||
if _wildcard_segment_matches(String(child), prefix, ending):
|
|
||||||
var matched_path := parent.path_join(String(child))
|
|
||||||
matches.append(matched_path if suffix.is_empty() else matched_path.path_join(suffix))
|
|
||||||
matches.sort()
|
|
||||||
return matches
|
|
||||||
|
|
||||||
|
|
||||||
## Substitute env vars and ~ in a single template string.
|
|
||||||
##
|
|
||||||
## A token that cannot be resolved — the var is unset and no home-derived
|
|
||||||
## fallback applies, or a dock worker's env snapshot was never warmed with it —
|
|
||||||
## is LEFT IN PLACE rather than replaced with "". Substituting "" is what turns
|
|
||||||
## `$USERPROFILE/godot` into `/godot`: a root-relative path that
|
|
||||||
## `is_absolute_path()` accepts, so every fail-closed guard downstream waves it
|
|
||||||
## through and the caller reads or writes a directory the user never named.
|
|
||||||
## `$USERPROFILE` is the easy repro (unset off Windows, and the only one of
|
|
||||||
## these vars with no fallback), but `$HOME`/`$XDG_CONFIG_HOME`/`$APPDATA`/
|
|
||||||
## `$LOCALAPPDATA` reach the same state whenever `_home()` is empty, which the
|
|
||||||
## missing-warm-up degradation above makes reachable on a worker thread.
|
|
||||||
##
|
|
||||||
## The surviving token keeps the result non-absolute, so those guards catch it,
|
|
||||||
## and the unexpanded path they report still shows what failed to resolve.
|
|
||||||
## `~` is left alone for the same reason: collapsing `~/godot` to the relative
|
|
||||||
## `godot` aims it at the editor's own working directory.
|
|
||||||
static func expand(template: String) -> String:
|
|
||||||
if template.is_empty():
|
|
||||||
return ""
|
|
||||||
var out := template
|
|
||||||
if out.begins_with("~/") or out == "~":
|
|
||||||
var home := _home()
|
|
||||||
if not home.is_empty():
|
|
||||||
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:
|
|
||||||
continue
|
|
||||||
var value := env_lookup(var_name)
|
|
||||||
if value.is_empty():
|
|
||||||
value = _home_fallback(var_name)
|
|
||||||
if value.is_empty():
|
|
||||||
continue
|
|
||||||
out = out.replace(token, value)
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
## Home-derived default for a var the environment does not define. Returns ""
|
|
||||||
## when home itself is unresolvable so `expand` leaves the token alone: rooting
|
|
||||||
## these at an empty home yields a RELATIVE path, not an absent one —
|
|
||||||
## `"".path_join(".config")` is `.config`, which reads against the editor's
|
|
||||||
## working directory rather than the user's config dir. `USERPROFILE` has no
|
|
||||||
## fallback (it is the thing `_home()` itself falls back to).
|
|
||||||
static func _home_fallback(var_name: String) -> String:
|
|
||||||
var home := _home()
|
|
||||||
if home.is_empty():
|
|
||||||
return ""
|
|
||||||
match var_name:
|
|
||||||
"XDG_CONFIG_HOME":
|
|
||||||
return home.path_join(".config")
|
|
||||||
"APPDATA":
|
|
||||||
return home.path_join("AppData/Roaming")
|
|
||||||
"LOCALAPPDATA":
|
|
||||||
return home.path_join("AppData/Local")
|
|
||||||
"HOME":
|
|
||||||
return home
|
|
||||||
return ""
|
|
||||||
|
|
||||||
|
|
||||||
static func _os_key() -> String:
|
|
||||||
match OS.get_name():
|
|
||||||
"macOS":
|
|
||||||
return "darwin"
|
|
||||||
"Windows":
|
|
||||||
return "windows"
|
|
||||||
_:
|
|
||||||
return "linux"
|
|
||||||
|
|
||||||
|
|
||||||
static func _wildcard_segment_matches(value: String, prefix: String, ending: String) -> bool:
|
|
||||||
# Prefix/suffix tests alone allow the two fixed portions to overlap inside a
|
|
||||||
# too-short value. Glob semantics require room for both portions even when
|
|
||||||
# `*` matches an empty string.
|
|
||||||
if value.length() < prefix.length() + ending.length():
|
|
||||||
return false
|
|
||||||
if OS.get_name() == "Windows":
|
|
||||||
return value.to_lower().begins_with(prefix.to_lower()) and value.to_lower().ends_with(ending.to_lower())
|
|
||||||
return value.begins_with(prefix) and value.ends_with(ending)
|
|
||||||
|
|
||||||
|
|
||||||
static func _home() -> String:
|
|
||||||
var h := env_lookup("HOME")
|
|
||||||
if h.is_empty():
|
|
||||||
h = env_lookup("USERPROFILE")
|
|
||||||
return h
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
uid://5pd418va35ms
|
|
||||||
@@ -1,156 +0,0 @@
|
|||||||
@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 path below.
|
|
||||||
##
|
|
||||||
## Paths, not preloads (#736): a preload array pulled all client descriptor
|
|
||||||
## scripts into the boot-time compile closure of everything that preloads
|
|
||||||
## this registry (plugin.gd via client_configurator.gd and mcp_dock.gd),
|
|
||||||
## stalling "Initializing plugins" on every editor boot. Descriptors are
|
|
||||||
## only needed when the dock refreshes client statuses or a client_*
|
|
||||||
## command runs, so they load lazily on first registry access.
|
|
||||||
|
|
||||||
const _CLIENT_SCRIPT_PATHS := [
|
|
||||||
"res://addons/godot_ai/clients/claude_code.gd",
|
|
||||||
"res://addons/godot_ai/clients/claude_desktop.gd",
|
|
||||||
"res://addons/godot_ai/clients/codex.gd",
|
|
||||||
"res://addons/godot_ai/clients/codebuddy.gd",
|
|
||||||
"res://addons/godot_ai/clients/grok.gd",
|
|
||||||
"res://addons/godot_ai/clients/antigravity.gd",
|
|
||||||
"res://addons/godot_ai/clients/cursor.gd",
|
|
||||||
"res://addons/godot_ai/clients/windsurf.gd",
|
|
||||||
"res://addons/godot_ai/clients/vscode.gd",
|
|
||||||
"res://addons/godot_ai/clients/vscode_insiders.gd",
|
|
||||||
"res://addons/godot_ai/clients/zed.gd",
|
|
||||||
"res://addons/godot_ai/clients/gemini_cli.gd",
|
|
||||||
"res://addons/godot_ai/clients/cline.gd",
|
|
||||||
"res://addons/godot_ai/clients/kilo_code.gd",
|
|
||||||
"res://addons/godot_ai/clients/roo_code.gd",
|
|
||||||
"res://addons/godot_ai/clients/zoo_code.gd",
|
|
||||||
"res://addons/godot_ai/clients/kiro.gd",
|
|
||||||
"res://addons/godot_ai/clients/trae.gd",
|
|
||||||
"res://addons/godot_ai/clients/opencode.gd",
|
|
||||||
"res://addons/godot_ai/clients/qwen_code.gd",
|
|
||||||
"res://addons/godot_ai/clients/kimi_code.gd",
|
|
||||||
"res://addons/godot_ai/clients/hermes.gd",
|
|
||||||
"res://addons/godot_ai/clients/pi.gd",
|
|
||||||
"res://addons/godot_ai/clients/deepseek_harness.gd",
|
|
||||||
]
|
|
||||||
|
|
||||||
static var _instances: Array[McpClient] = []
|
|
||||||
static var _by_id: Dictionary = {}
|
|
||||||
## First registry access can come from the dock's client-status refresh
|
|
||||||
## worker thread while the main thread hits it via a client_* command —
|
|
||||||
## serialize the one-time load so a racing thread can never observe a
|
|
||||||
## half-built registry. load() itself is thread-safe via ResourceLoader.
|
|
||||||
static var _load_mutex := Mutex.new()
|
|
||||||
## True when even a fresh rebuild yields instances missing base-schema
|
|
||||||
## fields — the deep stale-script state after an in-session self-update
|
|
||||||
## (#850; docs/releasing.md release-shape rules). Only an editor restart
|
|
||||||
## heals it; callers surface RESTART_TO_FINISH_UPDATE instead of erroring
|
|
||||||
## per client. Never reset within a session: rebuilding again cannot help,
|
|
||||||
## it would only repeat the load work and warning on every dock sweep.
|
|
||||||
static var _stale_session := false
|
|
||||||
|
|
||||||
const RESTART_TO_FINISH_UPDATE := (
|
|
||||||
"Godot AI was updated in this editor session. Restart the editor to finish the update."
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
static func all() -> Array[McpClient]:
|
|
||||||
_ensure_loaded()
|
|
||||||
return _instances
|
|
||||||
|
|
||||||
|
|
||||||
static func get_by_id(id: String) -> McpClient:
|
|
||||||
_ensure_loaded()
|
|
||||||
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:
|
|
||||||
_ensure_loaded()
|
|
||||||
return _by_id.has(id)
|
|
||||||
|
|
||||||
|
|
||||||
## True when this editor session is running a self-update whose script
|
|
||||||
## reloads left descriptor state unusable. Client operations short-circuit
|
|
||||||
## with RESTART_TO_FINISH_UPDATE rather than spamming per-field errors.
|
|
||||||
static func stale_session_detected() -> bool:
|
|
||||||
_ensure_loaded()
|
|
||||||
return _stale_session
|
|
||||||
|
|
||||||
|
|
||||||
## An instance is coherent when fields added to the CURRENT McpClient schema
|
|
||||||
## read back with their declared types. After an in-session self-update,
|
|
||||||
## hot-patched or pre-update instances answer Nil for vars the update added
|
|
||||||
## (#850: `config_path_candidates` and
|
|
||||||
## `config_file_env` read as Nil, crashing platform_key / String()). The
|
|
||||||
## reflected `get()` avoids typed-access errors on such instances.
|
|
||||||
static func _instance_is_coherent(inst: Object) -> bool:
|
|
||||||
if inst == null:
|
|
||||||
return false
|
|
||||||
return (
|
|
||||||
inst.get("config_path_candidates") is Dictionary
|
|
||||||
and inst.get("config_file_env") is String
|
|
||||||
and inst.get("path_template") is Dictionary
|
|
||||||
and inst.get("server_key_path_aliases") is Array
|
|
||||||
and inst.get("config_merge_path_templates") is Dictionary
|
|
||||||
and inst.get("config_merge_project_paths") is PackedStringArray
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
static func _cache_is_coherent() -> bool:
|
|
||||||
return not _instances.is_empty() and _instance_is_coherent(_instances[0])
|
|
||||||
|
|
||||||
|
|
||||||
static func _ensure_loaded() -> void:
|
|
||||||
if _stale_session:
|
|
||||||
return
|
|
||||||
if _cache_is_coherent():
|
|
||||||
return
|
|
||||||
_load_mutex.lock()
|
|
||||||
## Re-check under the lock: another thread may have rebuilt (or concluded
|
|
||||||
## staleness) while this one waited.
|
|
||||||
if not _stale_session and not _cache_is_coherent():
|
|
||||||
## Covers both the first load and the post-self-update rebuild: this
|
|
||||||
## registry file can survive an update unchanged, so its statics keep
|
|
||||||
## serving pre-update instances to freshly reloaded callers. A rebuild
|
|
||||||
## instantiates from the reloaded descriptor scripts, which repairs
|
|
||||||
## every case except a stale base Script object itself.
|
|
||||||
_load()
|
|
||||||
if not _instances.is_empty() and not _cache_is_coherent():
|
|
||||||
_stale_session = true
|
|
||||||
push_warning("MCP | %s" % RESTART_TO_FINISH_UPDATE)
|
|
||||||
_load_mutex.unlock()
|
|
||||||
|
|
||||||
|
|
||||||
static func _load() -> void:
|
|
||||||
## Build into locals and publish whole containers last, so the lock-free
|
|
||||||
## fast path in _ensure_loaded can never see a partially-filled registry.
|
|
||||||
var instances: Array[McpClient] = []
|
|
||||||
var by_id: Dictionary = {}
|
|
||||||
for path in _CLIENT_SCRIPT_PATHS:
|
|
||||||
var script := load(path) as GDScript
|
|
||||||
if script == null:
|
|
||||||
push_warning("MCP | failed to load client descriptor %s" % path)
|
|
||||||
continue
|
|
||||||
var inst: McpClient = script.new()
|
|
||||||
if inst.id.is_empty():
|
|
||||||
push_warning("MCP | client descriptor %s has empty id" % 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
|
|
||||||
_by_id = by_id
|
|
||||||
_instances = instances
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
uid://bxougoq8xwg1
|
|
||||||
@@ -1,754 +0,0 @@
|
|||||||
@tool
|
|
||||||
class_name McpTomlStrategy
|
|
||||||
extends RefCounted
|
|
||||||
|
|
||||||
## TOML upsert for URL entries and client-owned command entries.
|
|
||||||
##
|
|
||||||
## This remains deliberately smaller than a general TOML parser, but the
|
|
||||||
## parts that affect migration are semantic: assignments retain their whole
|
|
||||||
## value span (including multiline arrays/strings), and command/args status
|
|
||||||
## verification decodes TOML strings and arrays rather than comparing text.
|
|
||||||
|
|
||||||
|
|
||||||
static func configure(
|
|
||||||
client: McpClient,
|
|
||||||
_server_name: String,
|
|
||||||
server_url: String,
|
|
||||||
launch: Dictionary = {},
|
|
||||||
) -> Dictionary:
|
|
||||||
var resolution := client.resolved_config_path_details()
|
|
||||||
var path := str(resolution.get("path", ""))
|
|
||||||
var path_error := str(resolution.get("error", ""))
|
|
||||||
if not path_error.is_empty():
|
|
||||||
return {"status": "error", "message": path_error}
|
|
||||||
if path.is_empty():
|
|
||||||
return {"status": "error", "message": "Could not resolve config path for %s" % client.display_name}
|
|
||||||
|
|
||||||
var seed_path := str(resolution.get("seed_path", ""))
|
|
||||||
var read_path := seed_path if not FileAccess.file_exists(path) and not seed_path.is_empty() else path
|
|
||||||
var read := _read_or_init(read_path)
|
|
||||||
if not read["ok"]:
|
|
||||||
return {"status": "error", "message": "Refusing to overwrite %s: %s. Fix or move the file, then re-run Configure." % [read_path, read["error"]]}
|
|
||||||
|
|
||||||
var rendered := render_body(client, server_url, launch)
|
|
||||||
if not bool(rendered.get("ok", false)):
|
|
||||||
return {"status": "error", "message": str(rendered.get("error", "Could not build the TOML entry."))}
|
|
||||||
|
|
||||||
var lines: Array[String] = _split_lines(String(read["data"]))
|
|
||||||
var body: Array[String] = rendered["lines"]
|
|
||||||
var pinned_keys: Dictionary = rendered["pinned_keys"]
|
|
||||||
var initial_keys: Dictionary = rendered["initial_keys"]
|
|
||||||
var removed_keys: Dictionary = rendered["removed_keys"]
|
|
||||||
|
|
||||||
var section := _find_section(lines, _all_headers(client))
|
|
||||||
var header := _primary_header(client)
|
|
||||||
var new_lines: Array[String] = [header]
|
|
||||||
|
|
||||||
if section.is_empty():
|
|
||||||
new_lines.append_array(body)
|
|
||||||
var output_fresh: Array[String] = []
|
|
||||||
output_fresh.append_array(lines)
|
|
||||||
if not output_fresh.is_empty() and not output_fresh[-1].strip_edges().is_empty():
|
|
||||||
output_fresh.append("")
|
|
||||||
output_fresh.append_array(new_lines)
|
|
||||||
if not McpAtomicWrite.write(path, _join_lines(output_fresh)):
|
|
||||||
return {"status": "error", "message": "Cannot write to %s" % path}
|
|
||||||
return {"status": "ok", "message": McpClient.configured_message(client, server_url)}
|
|
||||||
|
|
||||||
var old_items := _value_items(lines, int(section["start"]) + 1, int(section["end"]))
|
|
||||||
var old_by_key := {}
|
|
||||||
for item in old_items:
|
|
||||||
var old_key := str(item.get("key", ""))
|
|
||||||
if not old_key.is_empty() and not old_by_key.has(old_key):
|
|
||||||
old_by_key[old_key] = item
|
|
||||||
|
|
||||||
var body_items := _value_items(body, 0, body.size())
|
|
||||||
var emitted_keys := {}
|
|
||||||
for item in body_items:
|
|
||||||
var key := str(item.get("key", ""))
|
|
||||||
if key.is_empty():
|
|
||||||
new_lines.append_array(item["lines"])
|
|
||||||
continue
|
|
||||||
emitted_keys[key] = true
|
|
||||||
if initial_keys.has(key) and old_by_key.has(key):
|
|
||||||
new_lines.append_array(old_by_key[key]["lines"])
|
|
||||||
else:
|
|
||||||
## Pinned keys always use the freshly rendered span. A generated key
|
|
||||||
## that is neither pinned nor initial is also rendered deterministically.
|
|
||||||
new_lines.append_array(item["lines"])
|
|
||||||
|
|
||||||
## Carry unknown/user-owned assignments and standalone comments verbatim.
|
|
||||||
## Whole item spans prevent multiline arrays/strings from being truncated.
|
|
||||||
for item in old_items:
|
|
||||||
var key := str(item.get("key", ""))
|
|
||||||
if not key.is_empty():
|
|
||||||
if emitted_keys.has(key) or pinned_keys.has(key) or removed_keys.has(key):
|
|
||||||
continue
|
|
||||||
new_lines.append_array(item["lines"])
|
|
||||||
continue
|
|
||||||
var item_lines: Array = item.get("lines", [])
|
|
||||||
for line in item_lines:
|
|
||||||
if not str(line).strip_edges().is_empty():
|
|
||||||
new_lines.append(str(line))
|
|
||||||
|
|
||||||
var output: Array[String] = []
|
|
||||||
output.append_array(_slice(lines, 0, int(section["start"])))
|
|
||||||
output.append_array(new_lines)
|
|
||||||
output.append_array(_slice(lines, int(section["end"]), lines.size()))
|
|
||||||
output = _rewrite_legacy_descendant_headers(output, client)
|
|
||||||
|
|
||||||
if not McpAtomicWrite.write(path, _join_lines(output)):
|
|
||||||
return {"status": "error", "message": "Cannot write to %s" % path}
|
|
||||||
return {"status": "ok", "message": McpClient.configured_message(client, server_url)}
|
|
||||||
|
|
||||||
|
|
||||||
static func check_status(
|
|
||||||
client: McpClient,
|
|
||||||
server_name: String,
|
|
||||||
server_url: String,
|
|
||||||
launch: Dictionary = {},
|
|
||||||
) -> McpClient.Status:
|
|
||||||
return check_status_details(client, server_name, server_url, launch).get("status", McpClient.Status.NOT_CONFIGURED)
|
|
||||||
|
|
||||||
|
|
||||||
static func check_status_details(
|
|
||||||
client: McpClient,
|
|
||||||
_server_name: String,
|
|
||||||
server_url: String,
|
|
||||||
launch: Dictionary = {},
|
|
||||||
) -> Dictionary:
|
|
||||||
var resolution := client.resolved_config_path_details()
|
|
||||||
var path := str(resolution.get("path", ""))
|
|
||||||
var path_error := str(resolution.get("error", ""))
|
|
||||||
if not path_error.is_empty():
|
|
||||||
return {"status": McpClient.Status.ERROR, "error_msg": path_error}
|
|
||||||
if path.is_empty() or not FileAccess.file_exists(path):
|
|
||||||
return {"status": McpClient.Status.NOT_CONFIGURED, "error_msg": ""}
|
|
||||||
var read := _read_or_init(path)
|
|
||||||
if not read["ok"]:
|
|
||||||
return {"status": McpClient.Status.ERROR, "error_msg": String(read["error"])}
|
|
||||||
var lines: Array[String] = _split_lines(String(read["data"]))
|
|
||||||
var section := _find_section(lines, _all_headers(client))
|
|
||||||
if section.is_empty():
|
|
||||||
return {"status": McpClient.Status.NOT_CONFIGURED, "error_msg": ""}
|
|
||||||
|
|
||||||
var items := _value_items(lines, int(section["start"]) + 1, int(section["end"]))
|
|
||||||
var by_key := {}
|
|
||||||
for item in items:
|
|
||||||
var key := str(item.get("key", ""))
|
|
||||||
if not key.is_empty() and not by_key.has(key):
|
|
||||||
by_key[key] = item
|
|
||||||
## Whether the existing entry launches Godot AI at all: a major migration
|
|
||||||
## may rewrite such an entry, never a foreign one (client_job_owner.gd).
|
|
||||||
## Only the launch values count; the section header carries our name.
|
|
||||||
var launch_tokens := PackedStringArray()
|
|
||||||
for launch_key in ["command", "url"]:
|
|
||||||
if by_key.has(launch_key):
|
|
||||||
var decoded := _decode_toml_string(_item_value(by_key[launch_key]))
|
|
||||||
if bool(decoded.get("ok", false)):
|
|
||||||
launch_tokens.append(str(decoded.get("value", "")))
|
|
||||||
if by_key.has("args"):
|
|
||||||
var decoded_args := _decode_toml_string_array(_item_value(by_key["args"]))
|
|
||||||
if bool(decoded_args.get("ok", false)):
|
|
||||||
for argument in decoded_args.get("value", []):
|
|
||||||
launch_tokens.append(str(argument))
|
|
||||||
var mismatch := {
|
|
||||||
"status": McpClient.Status.CONFIGURED_MISMATCH,
|
|
||||||
"error_msg": "",
|
|
||||||
"owned": McpClient.launch_values_mention_godot_ai(launch_tokens),
|
|
||||||
}
|
|
||||||
|
|
||||||
if client.command_shape != McpClient.CommandShape.NONE:
|
|
||||||
if not bool(launch.get("ok", false)):
|
|
||||||
return {
|
|
||||||
"status": McpClient.Status.ERROR,
|
|
||||||
"error_msg": str(launch.get("error", "No compatible attach launcher was found.")),
|
|
||||||
}
|
|
||||||
for legacy_key in client.command_legacy_keys:
|
|
||||||
if by_key.has(String(legacy_key)):
|
|
||||||
return mismatch
|
|
||||||
if not by_key.has("command") or not by_key.has("args"):
|
|
||||||
return mismatch
|
|
||||||
var command_value := _decode_toml_string(_item_value(by_key["command"]))
|
|
||||||
var args_value := _decode_toml_string_array(_item_value(by_key["args"]))
|
|
||||||
if not bool(command_value.get("ok", false)) or not bool(args_value.get("ok", false)):
|
|
||||||
return mismatch
|
|
||||||
if str(command_value.get("value", "")) != str(launch.get("command", "")):
|
|
||||||
return mismatch
|
|
||||||
if not _string_arrays_equal(args_value.get("value", []), launch.get("args", [])):
|
|
||||||
return mismatch
|
|
||||||
if not client.command_transport_key.is_empty():
|
|
||||||
var transport_key := client.command_transport_key
|
|
||||||
if not by_key.has(transport_key):
|
|
||||||
return mismatch
|
|
||||||
var decoded_transport := _decode_toml_scalar(_item_value(by_key[transport_key]))
|
|
||||||
if not bool(decoded_transport.get("ok", false)) or decoded_transport.get("value") != client.command_transport_value:
|
|
||||||
return mismatch
|
|
||||||
return {"status": McpClient.Status.CONFIGURED, "error_msg": ""}
|
|
||||||
|
|
||||||
if not by_key.has("url"):
|
|
||||||
return mismatch
|
|
||||||
var url_value := _decode_toml_string(_item_value(by_key["url"]))
|
|
||||||
if not bool(url_value.get("ok", false)) or str(url_value.get("value", "")) != server_url:
|
|
||||||
return mismatch
|
|
||||||
return {"status": McpClient.Status.CONFIGURED, "error_msg": ""}
|
|
||||||
|
|
||||||
|
|
||||||
static func remove(client: McpClient, _server_name: String) -> Dictionary:
|
|
||||||
var resolution := client.resolved_config_path_details()
|
|
||||||
var path := str(resolution.get("path", ""))
|
|
||||||
var path_error := str(resolution.get("error", ""))
|
|
||||||
if not path_error.is_empty():
|
|
||||||
return {"status": "error", "message": path_error}
|
|
||||||
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)
|
|
||||||
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() and not _is_any_section_header(lines[i]):
|
|
||||||
i += 1
|
|
||||||
continue
|
|
||||||
output.append(lines[i])
|
|
||||||
i += 1
|
|
||||||
|
|
||||||
if not McpAtomicWrite.write(path, _join_lines(output)):
|
|
||||||
return {"status": "error", "message": "Cannot write to %s" % path}
|
|
||||||
return {"status": "ok", "message": "%s configuration removed" % client.display_name}
|
|
||||||
|
|
||||||
|
|
||||||
## Substitute `{url}` in every legacy URL 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
|
|
||||||
|
|
||||||
|
|
||||||
## Encode a TOML basic string. This is intentionally public for the
|
|
||||||
## cross-language fixture test that parses the rendered sample with tomllib.
|
|
||||||
static func encode_basic_string(value: String) -> String:
|
|
||||||
return '"%s"' % value.replace("\\", "\\\\").replace('"', '\\"').replace("\b", "\\b").replace("\t", "\\t").replace("\n", "\\n").replace("\f", "\\f").replace("\r", "\\r")
|
|
||||||
|
|
||||||
|
|
||||||
## Multi-line string-array encoding used by command-shape entries.
|
|
||||||
static func encode_string_array(values: Variant) -> Array[String]:
|
|
||||||
var out: Array[String] = ["["]
|
|
||||||
for value in values:
|
|
||||||
out.append(" %s," % encode_basic_string(str(value)))
|
|
||||||
out.append("]")
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
static func render_body(client: McpClient, server_url: String, launch: Dictionary) -> Dictionary:
|
|
||||||
if client.command_shape == McpClient.CommandShape.NONE:
|
|
||||||
if client.toml_body_template.is_empty():
|
|
||||||
return {"ok": false, "error": "%s descriptor missing toml_body_template" % client.display_name}
|
|
||||||
var legacy_body := format_body(client.toml_body_template, server_url)
|
|
||||||
var legacy_lines: Array[String] = []
|
|
||||||
var pinned := {}
|
|
||||||
var initial := {}
|
|
||||||
for idx in range(legacy_body.size()):
|
|
||||||
legacy_lines.append(String(legacy_body[idx]))
|
|
||||||
var key := _line_key(String(client.toml_body_template[idx]))
|
|
||||||
if key.is_empty():
|
|
||||||
continue
|
|
||||||
if String(client.toml_body_template[idx]).contains("{url}"):
|
|
||||||
pinned[key] = true
|
|
||||||
else:
|
|
||||||
initial[key] = true
|
|
||||||
return {
|
|
||||||
"ok": true,
|
|
||||||
"lines": legacy_lines,
|
|
||||||
"pinned_keys": pinned,
|
|
||||||
"initial_keys": initial,
|
|
||||||
"removed_keys": {},
|
|
||||||
}
|
|
||||||
|
|
||||||
if client.command_shape != McpClient.CommandShape.COMMAND_ARRAY:
|
|
||||||
return {"ok": false, "error": "%s uses a command shape not supported by TOML yet" % client.display_name}
|
|
||||||
if not bool(launch.get("ok", false)):
|
|
||||||
return {"ok": false, "error": str(launch.get("error", "No compatible attach launcher was found."))}
|
|
||||||
|
|
||||||
var command_lines: Array[String] = [
|
|
||||||
"command = %s" % encode_basic_string(str(launch.get("command", ""))),
|
|
||||||
]
|
|
||||||
command_lines.append_array(_encode_assignment("args", launch.get("args", [])))
|
|
||||||
|
|
||||||
var pinned_keys := {"command": true, "args": true}
|
|
||||||
if not client.command_transport_key.is_empty():
|
|
||||||
var encoded_transport := _encode_scalar(client.command_transport_value)
|
|
||||||
if encoded_transport.is_empty():
|
|
||||||
return {
|
|
||||||
"ok": false,
|
|
||||||
"error": "Unsupported TOML transport `%s` for %s" % [
|
|
||||||
client.command_transport_key, client.display_name,
|
|
||||||
],
|
|
||||||
}
|
|
||||||
command_lines.append("%s = %s" % [client.command_transport_key, encoded_transport])
|
|
||||||
pinned_keys[client.command_transport_key] = true
|
|
||||||
|
|
||||||
var initial_keys := {}
|
|
||||||
for key in client.command_initial_fields:
|
|
||||||
var encoded := _encode_assignment(str(key), client.command_initial_fields[key])
|
|
||||||
if encoded.is_empty():
|
|
||||||
return {"ok": false, "error": "Unsupported TOML default `%s` for %s" % [key, client.display_name]}
|
|
||||||
command_lines.append_array(encoded)
|
|
||||||
initial_keys[str(key)] = true
|
|
||||||
|
|
||||||
var removed_keys := {}
|
|
||||||
for key in client.command_legacy_keys:
|
|
||||||
removed_keys[String(key)] = true
|
|
||||||
return {
|
|
||||||
"ok": true,
|
|
||||||
"lines": command_lines,
|
|
||||||
"pinned_keys": pinned_keys,
|
|
||||||
"initial_keys": initial_keys,
|
|
||||||
"removed_keys": removed_keys,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
static func _encode_assignment(key: String, value: Variant) -> Array[String]:
|
|
||||||
if value is Array or value is PackedStringArray:
|
|
||||||
var lines := encode_string_array(value)
|
|
||||||
lines[0] = "%s = %s" % [key, lines[0]]
|
|
||||||
return lines
|
|
||||||
var encoded := _encode_scalar(value)
|
|
||||||
var lines: Array[String] = []
|
|
||||||
if not encoded.is_empty():
|
|
||||||
lines.append("%s = %s" % [key, encoded])
|
|
||||||
return lines
|
|
||||||
|
|
||||||
|
|
||||||
static func _encode_scalar(value: Variant) -> String:
|
|
||||||
if value is String:
|
|
||||||
return encode_basic_string(value)
|
|
||||||
if value is bool:
|
|
||||||
return "true" if value else "false"
|
|
||||||
if value is int or value is float:
|
|
||||||
return str(value)
|
|
||||||
return ""
|
|
||||||
|
|
||||||
|
|
||||||
# --- span-aware merge helpers -------------------------------------------
|
|
||||||
|
|
||||||
static func _value_items(lines: Array[String], from: int, to: int) -> Array[Dictionary]:
|
|
||||||
var out: Array[Dictionary] = []
|
|
||||||
var i := from
|
|
||||||
while i < to:
|
|
||||||
var key := _line_key(lines[i])
|
|
||||||
if key.is_empty():
|
|
||||||
out.append({"key": "", "lines": [lines[i]]})
|
|
||||||
i += 1
|
|
||||||
continue
|
|
||||||
var end := _value_span_end(lines, i, to)
|
|
||||||
out.append({"key": key, "lines": _slice(lines, i, end)})
|
|
||||||
i = end
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
static func _value_span_end(lines: Array[String], start: int, limit: int) -> int:
|
|
||||||
var state := {"quote": "", "square": 0, "curly": 0, "escaped": false}
|
|
||||||
for i in range(start, limit):
|
|
||||||
var begin := 0
|
|
||||||
if i == start:
|
|
||||||
var eq := _assignment_equal(lines[i])
|
|
||||||
begin = eq + 1 if eq >= 0 else 0
|
|
||||||
_scan_toml_value_line(lines[i], begin, state)
|
|
||||||
if str(state["quote"]).is_empty() and int(state["square"]) == 0 and int(state["curly"]) == 0:
|
|
||||||
return i + 1
|
|
||||||
return limit
|
|
||||||
|
|
||||||
|
|
||||||
static func _scan_toml_value_line(line: String, begin: int, state: Dictionary) -> void:
|
|
||||||
var i := begin
|
|
||||||
while i < line.length():
|
|
||||||
var quote := str(state["quote"])
|
|
||||||
if quote == '"""' or quote == "'''":
|
|
||||||
if line.substr(i).begins_with(quote):
|
|
||||||
state["quote"] = ""
|
|
||||||
i += 3
|
|
||||||
continue
|
|
||||||
if quote == '"""' and line.unicode_at(i) == 92 and not bool(state["escaped"]):
|
|
||||||
state["escaped"] = true
|
|
||||||
i += 1
|
|
||||||
continue
|
|
||||||
state["escaped"] = false
|
|
||||||
i += 1
|
|
||||||
continue
|
|
||||||
if quote == '"' or quote == "'":
|
|
||||||
var c := line.unicode_at(i)
|
|
||||||
if quote == '"' and c == 92 and not bool(state["escaped"]):
|
|
||||||
state["escaped"] = true
|
|
||||||
i += 1
|
|
||||||
continue
|
|
||||||
if c == quote.unicode_at(0) and not bool(state["escaped"]):
|
|
||||||
state["quote"] = ""
|
|
||||||
state["escaped"] = false
|
|
||||||
i += 1
|
|
||||||
continue
|
|
||||||
|
|
||||||
if line.substr(i).begins_with('"""'):
|
|
||||||
state["quote"] = '"""'
|
|
||||||
i += 3
|
|
||||||
continue
|
|
||||||
if line.substr(i).begins_with("'''"):
|
|
||||||
state["quote"] = "'''"
|
|
||||||
i += 3
|
|
||||||
continue
|
|
||||||
var c := line.unicode_at(i)
|
|
||||||
if c == 34:
|
|
||||||
state["quote"] = '"'
|
|
||||||
elif c == 39:
|
|
||||||
state["quote"] = "'"
|
|
||||||
elif c == 35:
|
|
||||||
break
|
|
||||||
elif c == 91:
|
|
||||||
state["square"] = int(state["square"]) + 1
|
|
||||||
elif c == 93:
|
|
||||||
state["square"] = maxi(0, int(state["square"]) - 1)
|
|
||||||
elif c == 123:
|
|
||||||
state["curly"] = int(state["curly"]) + 1
|
|
||||||
elif c == 125:
|
|
||||||
state["curly"] = maxi(0, int(state["curly"]) - 1)
|
|
||||||
i += 1
|
|
||||||
|
|
||||||
|
|
||||||
static func _line_key(line: String) -> String:
|
|
||||||
var eq := _assignment_equal(line)
|
|
||||||
if eq <= 0:
|
|
||||||
return ""
|
|
||||||
return line.substr(0, eq).strip_edges()
|
|
||||||
|
|
||||||
|
|
||||||
static func _assignment_equal(line: String) -> int:
|
|
||||||
var quote := 0
|
|
||||||
var escaped := false
|
|
||||||
for i in range(line.length()):
|
|
||||||
var c := line.unicode_at(i)
|
|
||||||
if quote != 0:
|
|
||||||
if quote == 34 and c == 92 and not escaped:
|
|
||||||
escaped = true
|
|
||||||
continue
|
|
||||||
if c == quote and not escaped:
|
|
||||||
quote = 0
|
|
||||||
escaped = false
|
|
||||||
continue
|
|
||||||
if c == 34 or c == 39:
|
|
||||||
quote = c
|
|
||||||
elif c == 35:
|
|
||||||
return -1
|
|
||||||
elif c == 61:
|
|
||||||
return i
|
|
||||||
return -1
|
|
||||||
|
|
||||||
|
|
||||||
# --- semantic value decoding --------------------------------------------
|
|
||||||
|
|
||||||
static func _item_value(item: Dictionary) -> String:
|
|
||||||
var item_lines: Array = item.get("lines", [])
|
|
||||||
if item_lines.is_empty():
|
|
||||||
return ""
|
|
||||||
var first := str(item_lines[0])
|
|
||||||
var eq := _assignment_equal(first)
|
|
||||||
if eq < 0:
|
|
||||||
return ""
|
|
||||||
var parts: Array[String] = [first.substr(eq + 1)]
|
|
||||||
for i in range(1, item_lines.size()):
|
|
||||||
parts.append(str(item_lines[i]))
|
|
||||||
return "\n".join(parts)
|
|
||||||
|
|
||||||
|
|
||||||
static func _decode_toml_scalar(raw: String) -> Dictionary:
|
|
||||||
var cleaned := _without_comments(raw).strip_edges()
|
|
||||||
if cleaned == "true":
|
|
||||||
return {"ok": true, "value": true}
|
|
||||||
if cleaned == "false":
|
|
||||||
return {"ok": true, "value": false}
|
|
||||||
var string_value := _decode_toml_string(cleaned)
|
|
||||||
if bool(string_value.get("ok", false)):
|
|
||||||
return string_value
|
|
||||||
if cleaned.is_valid_int():
|
|
||||||
return {"ok": true, "value": cleaned.to_int()}
|
|
||||||
if cleaned.is_valid_float():
|
|
||||||
return {"ok": true, "value": cleaned.to_float()}
|
|
||||||
return {"ok": false}
|
|
||||||
|
|
||||||
|
|
||||||
static func _decode_toml_string(raw: String) -> Dictionary:
|
|
||||||
var cleaned := _without_comments(raw).strip_edges()
|
|
||||||
if cleaned.length() >= 2 and cleaned.begins_with("'") and cleaned.ends_with("'"):
|
|
||||||
return {"ok": true, "value": cleaned.substr(1, cleaned.length() - 2)}
|
|
||||||
if cleaned.length() < 2 or not cleaned.begins_with('"') or not cleaned.ends_with('"'):
|
|
||||||
return {"ok": false}
|
|
||||||
var parsed: Variant = JSON.parse_string(cleaned)
|
|
||||||
if parsed is String:
|
|
||||||
return {"ok": true, "value": parsed}
|
|
||||||
return {"ok": false}
|
|
||||||
|
|
||||||
|
|
||||||
static func _decode_toml_string_array(raw: String) -> Dictionary:
|
|
||||||
var cleaned := _without_comments(raw).strip_edges()
|
|
||||||
if not cleaned.begins_with("["):
|
|
||||||
return {"ok": false}
|
|
||||||
var i := 1
|
|
||||||
var values: Array[String] = []
|
|
||||||
while true:
|
|
||||||
i = _skip_space(cleaned, i)
|
|
||||||
if i >= cleaned.length():
|
|
||||||
return {"ok": false}
|
|
||||||
if cleaned.unicode_at(i) == 93:
|
|
||||||
i = _skip_space(cleaned, i + 1)
|
|
||||||
return {"ok": i == cleaned.length(), "value": values}
|
|
||||||
var parsed := _parse_string_at(cleaned, i)
|
|
||||||
if not bool(parsed.get("ok", false)):
|
|
||||||
return {"ok": false}
|
|
||||||
values.append(str(parsed.get("value", "")))
|
|
||||||
i = _skip_space(cleaned, int(parsed.get("next", i)))
|
|
||||||
if i >= cleaned.length():
|
|
||||||
return {"ok": false}
|
|
||||||
var c := cleaned.unicode_at(i)
|
|
||||||
if c == 44:
|
|
||||||
i += 1
|
|
||||||
continue
|
|
||||||
if c == 93:
|
|
||||||
continue
|
|
||||||
return {"ok": false}
|
|
||||||
return {"ok": false} # Unreachable; keeps GDScript's return analysis explicit.
|
|
||||||
|
|
||||||
|
|
||||||
static func _parse_string_at(text: String, start: int) -> Dictionary:
|
|
||||||
if start >= text.length():
|
|
||||||
return {"ok": false}
|
|
||||||
var quote := text.unicode_at(start)
|
|
||||||
if quote != 34 and quote != 39:
|
|
||||||
return {"ok": false}
|
|
||||||
var i := start + 1
|
|
||||||
var escaped := false
|
|
||||||
while i < text.length():
|
|
||||||
var c := text.unicode_at(i)
|
|
||||||
if quote == 34 and c == 92 and not escaped:
|
|
||||||
escaped = true
|
|
||||||
i += 1
|
|
||||||
continue
|
|
||||||
if c == quote and not escaped:
|
|
||||||
var raw := text.substr(start, i - start + 1)
|
|
||||||
if quote == 39:
|
|
||||||
return {"ok": true, "value": raw.substr(1, raw.length() - 2), "next": i + 1}
|
|
||||||
var parsed: Variant = JSON.parse_string(raw)
|
|
||||||
if parsed is String:
|
|
||||||
return {"ok": true, "value": parsed, "next": i + 1}
|
|
||||||
return {"ok": false}
|
|
||||||
escaped = false
|
|
||||||
i += 1
|
|
||||||
return {"ok": false}
|
|
||||||
|
|
||||||
|
|
||||||
static func _without_comments(raw: String) -> String:
|
|
||||||
var out: Array[String] = []
|
|
||||||
for line in raw.split("\n"):
|
|
||||||
var quote := 0
|
|
||||||
var escaped := false
|
|
||||||
var kept := ""
|
|
||||||
for i in range(line.length()):
|
|
||||||
var c := line.unicode_at(i)
|
|
||||||
if quote != 0:
|
|
||||||
kept += line.substr(i, 1)
|
|
||||||
if quote == 34 and c == 92 and not escaped:
|
|
||||||
escaped = true
|
|
||||||
continue
|
|
||||||
if c == quote and not escaped:
|
|
||||||
quote = 0
|
|
||||||
escaped = false
|
|
||||||
continue
|
|
||||||
if c == 34 or c == 39:
|
|
||||||
quote = c
|
|
||||||
kept += line.substr(i, 1)
|
|
||||||
elif c == 35:
|
|
||||||
break
|
|
||||||
else:
|
|
||||||
kept += line.substr(i, 1)
|
|
||||||
out.append(kept)
|
|
||||||
return "\n".join(out)
|
|
||||||
|
|
||||||
|
|
||||||
static func _skip_space(text: String, start: int) -> int:
|
|
||||||
var i := start
|
|
||||||
while i < text.length() and text.substr(i, 1) in [" ", "\t", "\r", "\n"]:
|
|
||||||
i += 1
|
|
||||||
return i
|
|
||||||
|
|
||||||
|
|
||||||
static func _string_arrays_equal(left: Variant, right: Variant) -> bool:
|
|
||||||
if not (left is Array or left is PackedStringArray):
|
|
||||||
return false
|
|
||||||
if not (right is Array or right is PackedStringArray):
|
|
||||||
return false
|
|
||||||
if left.size() != right.size():
|
|
||||||
return false
|
|
||||||
for i in range(left.size()):
|
|
||||||
if str(left[i]) != str(right[i]):
|
|
||||||
return false
|
|
||||||
return true
|
|
||||||
|
|
||||||
|
|
||||||
# --- file / section helpers ---------------------------------------------
|
|
||||||
|
|
||||||
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 text := f.get_as_text()
|
|
||||||
f.close()
|
|
||||||
return {"ok": true, "data": text}
|
|
||||||
|
|
||||||
|
|
||||||
static func _join_lines(lines: Array[String]) -> String:
|
|
||||||
var joined := "\n".join(lines)
|
|
||||||
return joined if joined.is_empty() or joined.ends_with("\n") else joined + "\n"
|
|
||||||
|
|
||||||
|
|
||||||
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:
|
|
||||||
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]
|
|
||||||
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 part in parts:
|
|
||||||
if not _is_bare_key(String(part)):
|
|
||||||
return ""
|
|
||||||
return "[%s]" % ".".join(parts)
|
|
||||||
|
|
||||||
|
|
||||||
static func _is_bare_key(value: String) -> bool:
|
|
||||||
if value.is_empty():
|
|
||||||
return false
|
|
||||||
for i in range(value.length()):
|
|
||||||
var c := value.unicode_at(i)
|
|
||||||
var alpha := (c >= 65 and c <= 90) or (c >= 97 and c <= 122)
|
|
||||||
var digit := c >= 48 and c <= 57
|
|
||||||
if not (alpha or digit or c == 45 or c == 95):
|
|
||||||
return false
|
|
||||||
return true
|
|
||||||
|
|
||||||
|
|
||||||
static func _subtable_prefixes(headers: Array[String]) -> Array[String]:
|
|
||||||
var out: Array[String] = []
|
|
||||||
for header in headers:
|
|
||||||
if header.length() > 2 and header.ends_with("]"):
|
|
||||||
out.append(header.substr(0, header.length() - 1) + ".")
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
static func _matches_subtable_prefix(line: String, prefixes: Array[String]) -> bool:
|
|
||||||
var trimmed := line.strip_edges()
|
|
||||||
for prefix in prefixes:
|
|
||||||
if not trimmed.begins_with(prefix):
|
|
||||||
continue
|
|
||||||
var rest := trimmed.substr(prefix.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
|
|
||||||
|
|
||||||
|
|
||||||
static func _matches_any_header(line: String, headers: Array[String]) -> bool:
|
|
||||||
var trimmed := line.strip_edges()
|
|
||||||
for header in headers:
|
|
||||||
if not trimmed.begins_with(header):
|
|
||||||
continue
|
|
||||||
var remainder := trimmed.substr(header.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 {}
|
|
||||||
|
|
||||||
|
|
||||||
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("#")
|
|
||||||
|
|
||||||
|
|
||||||
static func _rewrite_legacy_descendant_headers(
|
|
||||||
lines: Array[String], client: McpClient
|
|
||||||
) -> Array[String]:
|
|
||||||
if client.toml_legacy_section_aliases.is_empty():
|
|
||||||
return lines
|
|
||||||
var primary := _primary_header(client)
|
|
||||||
var primary_prefix := primary.substr(0, primary.length() - 1) + "."
|
|
||||||
var out: Array[String] = []
|
|
||||||
for line in lines:
|
|
||||||
var rewritten := line
|
|
||||||
var trimmed := line.strip_edges()
|
|
||||||
var indent_length := line.find("[")
|
|
||||||
var indent := line.substr(0, indent_length) if indent_length >= 0 else ""
|
|
||||||
for alias in client.toml_legacy_section_aliases:
|
|
||||||
var legacy_prefix := "[%s." % String(alias)
|
|
||||||
if trimmed.begins_with(legacy_prefix):
|
|
||||||
rewritten = indent + primary_prefix + trimmed.substr(legacy_prefix.length())
|
|
||||||
break
|
|
||||||
out.append(rewritten)
|
|
||||||
return out
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
uid://cwdvxgn0aurqv
|
|
||||||
@@ -1,553 +0,0 @@
|
|||||||
@tool
|
|
||||||
class_name McpYamlStrategy
|
|
||||||
extends RefCounted
|
|
||||||
|
|
||||||
## Minimal YAML upsert for Hermes Agent MCP config.
|
|
||||||
##
|
|
||||||
## Hermes reads MCP servers from ~/.hermes/config.yaml under the
|
|
||||||
## `mcp_servers` key (snake_case, YAML). HTTP entries are transport-inferred:
|
|
||||||
## just `url` (plus optional `headers`), no `type` field. We only parse the
|
|
||||||
## `mcp_servers` block and re-emit it; other top-level keys in the user's
|
|
||||||
## config.yaml are preserved verbatim by round-tripping the raw lines around
|
|
||||||
## that block. No general YAML parser — Godot has none in stdlib, and Hermes
|
|
||||||
## only needs this one shape. See issue #640.
|
|
||||||
|
|
||||||
const INDENT := " " # YAML forbids tab indentation; match the 2-space style of ~/.hermes/config.yaml
|
|
||||||
|
|
||||||
|
|
||||||
static func configure(
|
|
||||||
client: McpClient,
|
|
||||||
server_name: String,
|
|
||||||
server_url: String,
|
|
||||||
launch: Dictionary = {},
|
|
||||||
) -> Dictionary:
|
|
||||||
var resolution := client.resolved_config_path_details()
|
|
||||||
var path := str(resolution.get("path", ""))
|
|
||||||
var path_error := str(resolution.get("error", ""))
|
|
||||||
if not path_error.is_empty():
|
|
||||||
return {"status": "error", "message": path_error}
|
|
||||||
if path.is_empty():
|
|
||||||
return {"status": "error", "message": "Could not resolve config path for %s on this OS" % client.display_name}
|
|
||||||
## Fail closed before touching the file — same contract as JSON/TOML.
|
|
||||||
var launch_error := command_launch_error(client, launch)
|
|
||||||
if not launch_error.is_empty():
|
|
||||||
return {"status": "error", "message": launch_error}
|
|
||||||
|
|
||||||
var seed_path := str(resolution.get("seed_path", ""))
|
|
||||||
var read_path := seed_path if not FileAccess.file_exists(path) and not seed_path.is_empty() else path
|
|
||||||
var read := _read(read_path)
|
|
||||||
if not read["ok"]:
|
|
||||||
return {"status": "error", "message": "Refusing to overwrite %s: %s. Fix or move the file, then re-run Configure." % [read_path, read["error"]]}
|
|
||||||
|
|
||||||
var text: String = read["data"]
|
|
||||||
var block := _extract_block(text)
|
|
||||||
var entries: Dictionary = block["entries"]
|
|
||||||
|
|
||||||
# Preserve existing entry's user-mutable keys; force the transport keys.
|
|
||||||
var existing: Dictionary = entries.get(server_name, {})
|
|
||||||
var new_entry := build_entry(client, server_url, existing, launch)
|
|
||||||
entries[server_name] = new_entry
|
|
||||||
|
|
||||||
var out := _assemble(text, block["prefix_lines"], entries, block["suffix_lines"])
|
|
||||||
if not McpAtomicWrite.write(path, out):
|
|
||||||
return {"status": "error", "message": "Cannot write to %s" % path}
|
|
||||||
return {"status": "ok", "message": McpClient.configured_message(client, server_url)}
|
|
||||||
|
|
||||||
|
|
||||||
static func check_status(
|
|
||||||
client: McpClient, server_name: String, server_url: String, launch: Dictionary = {}
|
|
||||||
) -> McpClient.Status:
|
|
||||||
return check_status_details(client, server_name, server_url, launch)["status"]
|
|
||||||
|
|
||||||
|
|
||||||
## Same contract as the JSON/TOML strategies (#711): {status, error_msg}.
|
|
||||||
## An existing-but-unreadable config is ERROR with the diagnostic — not
|
|
||||||
## NOT_CONFIGURED — so the dock row can tell "no config" from "config the
|
|
||||||
## editor can't read" instead of offering a Configure that would fail.
|
|
||||||
static func check_status_details(
|
|
||||||
client: McpClient, server_name: String, server_url: String, launch: Dictionary = {}
|
|
||||||
) -> Dictionary:
|
|
||||||
var resolution := client.resolved_config_path_details()
|
|
||||||
var path := str(resolution.get("path", ""))
|
|
||||||
var path_error := str(resolution.get("error", ""))
|
|
||||||
if not path_error.is_empty():
|
|
||||||
return {"status": McpClient.Status.ERROR, "error_msg": path_error}
|
|
||||||
if path.is_empty() or not FileAccess.file_exists(path):
|
|
||||||
return {"status": McpClient.Status.NOT_CONFIGURED, "error_msg": ""}
|
|
||||||
var read := _read(path)
|
|
||||||
if not read["ok"]:
|
|
||||||
return {
|
|
||||||
"status": McpClient.Status.ERROR,
|
|
||||||
"error_msg": "Cannot read %s: %s" % [path, read["error"]],
|
|
||||||
}
|
|
||||||
var block := _extract_block(String(read["data"]))
|
|
||||||
var entries: Dictionary = block["entries"]
|
|
||||||
if not entries.has(server_name):
|
|
||||||
return {"status": McpClient.Status.NOT_CONFIGURED, "error_msg": ""}
|
|
||||||
var entry: Variant = entries[server_name]
|
|
||||||
if not (entry is Dictionary):
|
|
||||||
return {"status": McpClient.Status.NOT_CONFIGURED, "error_msg": ""}
|
|
||||||
## An entry exists but no verified launcher does — mirror JSON/TOML: this
|
|
||||||
## is an environment ERROR, not entry drift.
|
|
||||||
var launch_error := command_launch_error(client, launch)
|
|
||||||
if not launch_error.is_empty():
|
|
||||||
return {"status": McpClient.Status.ERROR, "error_msg": launch_error}
|
|
||||||
if verify_entry(client, entry, server_url, launch):
|
|
||||||
return {"status": McpClient.Status.CONFIGURED, "error_msg": ""}
|
|
||||||
return {
|
|
||||||
"status": McpClient.Status.CONFIGURED_MISMATCH,
|
|
||||||
"error_msg": "",
|
|
||||||
"owned": McpClient.launch_values_mention_godot_ai(McpClient.entry_launch_values(entry)),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
static func remove(client: McpClient, server_name: String) -> Dictionary:
|
|
||||||
var resolution := client.resolved_config_path_details()
|
|
||||||
var path := str(resolution.get("path", ""))
|
|
||||||
var path_error := str(resolution.get("error", ""))
|
|
||||||
if not path_error.is_empty():
|
|
||||||
return {"status": "error", "message": path_error}
|
|
||||||
if path.is_empty() or not FileAccess.file_exists(path):
|
|
||||||
return {"status": "ok", "message": "Not configured"}
|
|
||||||
var read := _read(path)
|
|
||||||
if not read["ok"]:
|
|
||||||
return {"status": "error", "message": "Refusing to rewrite %s: %s." % [path, read["error"]]}
|
|
||||||
var text: String = read["data"]
|
|
||||||
var block := _extract_block(text)
|
|
||||||
var entries: Dictionary = block["entries"]
|
|
||||||
if not entries.has(server_name):
|
|
||||||
return {"status": "ok", "message": "%s configuration removed" % client.display_name}
|
|
||||||
entries.erase(server_name)
|
|
||||||
var out := _assemble(text, block["prefix_lines"], entries, block["suffix_lines"])
|
|
||||||
if not McpAtomicWrite.write(path, out):
|
|
||||||
return {"status": "error", "message": "Cannot write to %s" % path}
|
|
||||||
return {"status": "ok", "message": "%s configuration removed" % client.display_name}
|
|
||||||
|
|
||||||
|
|
||||||
## Build the entry dict written under mcp_servers[server_name].
|
|
||||||
##
|
|
||||||
## URL mode (`CommandShape.NONE`): Hermes HTTP entries are transport-inferred
|
|
||||||
## — { url: <url> } plus whatever user-mutable keys (headers, enabled, tools,
|
|
||||||
## ...) the existing entry carries. Stdio-bridge keys are the one exception
|
|
||||||
## (see _STDIO_BRIDGE_KEYS). No `type` field.
|
|
||||||
##
|
|
||||||
## Command mode (`CommandShape.FLAT`, #838): flat `command` + `args` keys,
|
|
||||||
## transport inferred the same way — which is exactly why the descriptor's
|
|
||||||
## `command_legacy_keys` (url, headers) must be scrubbed: a Hermes entry with
|
|
||||||
## both a url and a command picks the wrong transport. User keys survive.
|
|
||||||
static func build_entry(
|
|
||||||
client: McpClient,
|
|
||||||
server_url: String,
|
|
||||||
existing: Variant = null,
|
|
||||||
launch: Dictionary = {},
|
|
||||||
) -> Dictionary:
|
|
||||||
if client.command_shape == McpClient.CommandShape.FLAT:
|
|
||||||
var command_entry: Dictionary = (existing as Dictionary).duplicate(true) if existing is Dictionary else {}
|
|
||||||
command_entry["command"] = str(launch.get("command", ""))
|
|
||||||
command_entry["args"] = _array_copy(launch.get("args", []))
|
|
||||||
if not client.command_transport_key.is_empty():
|
|
||||||
command_entry[client.command_transport_key] = client.command_transport_value
|
|
||||||
for key in client.command_initial_fields:
|
|
||||||
if not command_entry.has(key):
|
|
||||||
command_entry[key] = client.command_initial_fields[key]
|
|
||||||
for key in client.command_legacy_keys:
|
|
||||||
command_entry.erase(String(key))
|
|
||||||
return command_entry
|
|
||||||
if client.command_shape != McpClient.CommandShape.NONE:
|
|
||||||
## Every production caller checks command_launch_error first. Keep this
|
|
||||||
## builder defensive too so a future unsupported shape cannot silently
|
|
||||||
## degrade into a flat YAML entry.
|
|
||||||
return {}
|
|
||||||
var entry: Dictionary = {}
|
|
||||||
if existing is Dictionary:
|
|
||||||
## User-mutable keys (headers, enabled, tools, ...) survive a
|
|
||||||
## reconfigure — the same preservation contract the JSON strategy's
|
|
||||||
## entry_initial_fields split implements. Only the stdio-bridge keys
|
|
||||||
## are scrubbed (see _STDIO_BRIDGE_KEYS), then the url is repointed.
|
|
||||||
entry = (existing as Dictionary).duplicate(true)
|
|
||||||
for stale_key in _STDIO_BRIDGE_KEYS:
|
|
||||||
entry.erase(stale_key)
|
|
||||||
entry[client.entry_url_field] = server_url
|
|
||||||
return entry
|
|
||||||
|
|
||||||
|
|
||||||
## Keys a prior stdio-bridge entry (e.g. `command: uvx mcp-proxy`) may carry.
|
|
||||||
## These must NOT survive a URL reconfigure: a Hermes entry with both a url
|
|
||||||
## and a command picks the wrong transport.
|
|
||||||
const _STDIO_BRIDGE_KEYS := ["command", "args", "env"]
|
|
||||||
|
|
||||||
|
|
||||||
## Empty string when this client's launch requirements are satisfied.
|
|
||||||
## Mirrors `McpJsonStrategy.command_launch_error`; YAML supports FLAT only.
|
|
||||||
static func command_launch_error(client: McpClient, launch: Dictionary) -> String:
|
|
||||||
if client.command_shape == McpClient.CommandShape.NONE:
|
|
||||||
return ""
|
|
||||||
if client.command_shape != McpClient.CommandShape.FLAT:
|
|
||||||
return "%s uses a command shape not supported by YAML yet" % client.display_name
|
|
||||||
if not bool(launch.get("ok", false)):
|
|
||||||
return str(launch.get("error", "No compatible attach launcher was found."))
|
|
||||||
return ""
|
|
||||||
|
|
||||||
|
|
||||||
## Verify a stored entry matches.
|
|
||||||
##
|
|
||||||
## URL mode: Hermes entries have no transport type pin, so verification is:
|
|
||||||
## url matches. Extra keys (headers, enabled, tools) are user-mutable and
|
|
||||||
## intentionally NOT checked (mirrors json entry_initial_fields).
|
|
||||||
##
|
|
||||||
## Command mode: every launch-affecting value must match exactly and legacy
|
|
||||||
## URL-transport keys must be gone — their presence is migration drift.
|
|
||||||
static func verify_entry(
|
|
||||||
client: McpClient,
|
|
||||||
entry: Dictionary,
|
|
||||||
server_url: String,
|
|
||||||
launch: Dictionary = {},
|
|
||||||
) -> bool:
|
|
||||||
if client.command_shape != McpClient.CommandShape.NONE:
|
|
||||||
if client.command_shape != McpClient.CommandShape.FLAT or not bool(launch.get("ok", false)):
|
|
||||||
return false
|
|
||||||
for key in client.command_legacy_keys:
|
|
||||||
if entry.has(String(key)):
|
|
||||||
return false
|
|
||||||
if entry.get("command") != launch.get("command"):
|
|
||||||
return false
|
|
||||||
if not _arrays_equal(entry.get("args", null), launch.get("args", null)):
|
|
||||||
return false
|
|
||||||
if not client.command_transport_key.is_empty():
|
|
||||||
if entry.get(client.command_transport_key, null) != client.command_transport_value:
|
|
||||||
return false
|
|
||||||
return true
|
|
||||||
return entry.get(client.entry_url_field, "") == server_url
|
|
||||||
|
|
||||||
|
|
||||||
static func _array_copy(value: Variant) -> Array:
|
|
||||||
if value is Array:
|
|
||||||
return (value as Array).duplicate(true)
|
|
||||||
if value is PackedStringArray:
|
|
||||||
return McpClient._array_from_packed(value)
|
|
||||||
return []
|
|
||||||
|
|
||||||
|
|
||||||
static func _arrays_equal(left: Variant, right: Variant) -> bool:
|
|
||||||
if not (left is Array or left is PackedStringArray):
|
|
||||||
return false
|
|
||||||
if not (right is Array or right is PackedStringArray):
|
|
||||||
return false
|
|
||||||
var left_array := _array_copy(left)
|
|
||||||
var right_array := _array_copy(right)
|
|
||||||
if left_array.size() != right_array.size():
|
|
||||||
return false
|
|
||||||
for i in range(left_array.size()):
|
|
||||||
if left_array[i] != right_array[i]:
|
|
||||||
return false
|
|
||||||
return true
|
|
||||||
|
|
||||||
|
|
||||||
# --- YAML block handling (scoped to mcp_servers) -------------------------
|
|
||||||
|
|
||||||
## Parse the file into three regions:
|
|
||||||
## prefix_lines — everything before `mcp_servers:` (may be empty)
|
|
||||||
## entries — the map of server_name -> {url, ...} under mcp_servers
|
|
||||||
## suffix_lines — everything after the mcp_servers block (may be empty)
|
|
||||||
## This lets us rewrite only the mcp_servers block and keep the rest of the
|
|
||||||
## user's config.yaml byte-for-byte intact.
|
|
||||||
static func _extract_block(text: String) -> Dictionary:
|
|
||||||
## allow_empty must stay true: dropping empty splits would silently strip
|
|
||||||
## the user's blank lines from the preserved prefix/suffix regions on
|
|
||||||
## every rewrite. The parse loops below already skip blank lines.
|
|
||||||
var lines := text.split("\n")
|
|
||||||
var prefix: PackedStringArray = []
|
|
||||||
var entries: Dictionary = {}
|
|
||||||
var suffix: PackedStringArray = []
|
|
||||||
var header_idx := -1
|
|
||||||
for i in range(lines.size()):
|
|
||||||
if lines[i].strip_edges().begins_with("mcp_servers:"):
|
|
||||||
header_idx = i
|
|
||||||
break
|
|
||||||
if header_idx < 0:
|
|
||||||
# No mcp_servers yet — whole file is prefix; block will be appended.
|
|
||||||
prefix = lines.duplicate()
|
|
||||||
return {"prefix_lines": prefix, "entries": entries, "suffix_lines": [], "header_idx": -1}
|
|
||||||
|
|
||||||
for i in range(0, header_idx):
|
|
||||||
prefix.append(lines[i])
|
|
||||||
|
|
||||||
# Determine the indent of the first entry so we can tell sibling
|
|
||||||
# entries (same indent) apart from nested keys (deeper indent) and
|
|
||||||
# parent-level keys (less indent). All server entries under
|
|
||||||
# `mcp_servers:` share one indent level; breaking on 0-indent alone
|
|
||||||
# mis-nests 2-space-indented siblings under the first entry.
|
|
||||||
var entry_indent := -1
|
|
||||||
var probe := header_idx + 1
|
|
||||||
while probe < lines.size() and _is_blank_or_comment(lines[probe]):
|
|
||||||
probe += 1
|
|
||||||
if probe < lines.size():
|
|
||||||
entry_indent = _indent_of(lines[probe])
|
|
||||||
|
|
||||||
# Empty block guard: the first nonblank line after the header must sit
|
|
||||||
# DEEPER than the header itself to be an entry. At or above the header's
|
|
||||||
# indent it is a sibling/parent key — parsing it as an entry would
|
|
||||||
# swallow the user's next top-level key and re-emit it nested under
|
|
||||||
# mcp_servers, corrupting the file.
|
|
||||||
if probe < lines.size() and entry_indent <= _indent_of(lines[header_idx]):
|
|
||||||
for j in range(header_idx + 1, lines.size()):
|
|
||||||
suffix.append(lines[j])
|
|
||||||
return {"prefix_lines": prefix, "entries": entries, "suffix_lines": suffix, "header_idx": header_idx}
|
|
||||||
|
|
||||||
var i := header_idx + 1
|
|
||||||
while i < lines.size():
|
|
||||||
var raw := lines[i]
|
|
||||||
## Comment-only lines inside the block are skipped like blanks —
|
|
||||||
## treating one as an entry header would re-emit it as a bogus
|
|
||||||
## `# comment:` server on rewrite. (Comments INSIDE the rewritten
|
|
||||||
## block are consequently dropped; comments outside the block live
|
|
||||||
## in prefix/suffix and survive verbatim.)
|
|
||||||
if _is_blank_or_comment(raw):
|
|
||||||
i += 1
|
|
||||||
continue
|
|
||||||
# Stop at any line indented less than a sibling entry (parent key
|
|
||||||
# or a new top-level section), or at the header's own level.
|
|
||||||
if _indent_of(raw) < entry_indent:
|
|
||||||
break
|
|
||||||
var entry := _parse_entry(raw, lines, i, entry_indent)
|
|
||||||
if not entry["name"].is_empty():
|
|
||||||
entries[entry["name"]] = entry["data"]
|
|
||||||
i = entry["next_idx"]
|
|
||||||
|
|
||||||
for j in range(i, lines.size()):
|
|
||||||
suffix.append(lines[j])
|
|
||||||
|
|
||||||
return {"prefix_lines": prefix, "entries": entries, "suffix_lines": suffix, "header_idx": header_idx}
|
|
||||||
|
|
||||||
|
|
||||||
## Parse one ` name:` entry starting at `lines[start]`. Consumes all deeper-
|
|
||||||
## indented sublines (url, headers, etc.) and returns the next sibling index.
|
|
||||||
static func _parse_entry(raw: String, lines: PackedStringArray, start: int, entry_indent: int) -> Dictionary:
|
|
||||||
var name := raw.strip_edges().trim_suffix(":").strip_edges()
|
|
||||||
var data: Dictionary = {}
|
|
||||||
var i := start + 1
|
|
||||||
while i < lines.size():
|
|
||||||
var l := lines[i]
|
|
||||||
## Comments inside an entry (e.g. ` # auth for CI`) would parse
|
|
||||||
## as a `# auth for CI` key — skip them like blanks.
|
|
||||||
if _is_blank_or_comment(l):
|
|
||||||
i += 1
|
|
||||||
continue
|
|
||||||
# A line at or above the entry's indent is a sibling/parent key.
|
|
||||||
if _indent_of(l) <= entry_indent:
|
|
||||||
break
|
|
||||||
var stripped := l.strip_edges()
|
|
||||||
var colon := stripped.find(":")
|
|
||||||
if colon < 0:
|
|
||||||
i += 1
|
|
||||||
continue
|
|
||||||
var key := stripped.substr(0, colon).strip_edges()
|
|
||||||
var val := stripped.substr(colon + 1).strip_edges()
|
|
||||||
if val.is_empty():
|
|
||||||
# Nested block (e.g. headers:). Parse as raw sub-dict lines for
|
|
||||||
# preservation; we don't introspect deeper than url at the top.
|
|
||||||
var sub := _parse_subblock(lines, i + 1, entry_indent)
|
|
||||||
data[key] = sub["value"]
|
|
||||||
i = sub["next_idx"]
|
|
||||||
else:
|
|
||||||
data[key] = coerce_scalar(val)
|
|
||||||
i += 1
|
|
||||||
return {"name": name, "data": data, "next_idx": i}
|
|
||||||
|
|
||||||
|
|
||||||
## Parse a nested block (e.g. headers:) as a preserved sub-dictionary of
|
|
||||||
## scalar key/values. Deeper nesting is flattened into scalar strings — fine
|
|
||||||
## for Hermes' known shape (headers are flat key: value).
|
|
||||||
static func _parse_subblock(lines: PackedStringArray, start: int, entry_indent: int) -> Dictionary:
|
|
||||||
var sub: Dictionary = {}
|
|
||||||
var i := start
|
|
||||||
while i < lines.size():
|
|
||||||
var l := lines[i]
|
|
||||||
if _is_blank_or_comment(l):
|
|
||||||
i += 1
|
|
||||||
continue
|
|
||||||
# A line at or above the parent entry's indent ends the nested block.
|
|
||||||
if _indent_of(l) <= entry_indent:
|
|
||||||
break
|
|
||||||
var stripped := l.strip_edges()
|
|
||||||
var colon := stripped.find(":")
|
|
||||||
if colon < 0:
|
|
||||||
i += 1
|
|
||||||
continue
|
|
||||||
var key := stripped.substr(0, colon).strip_edges()
|
|
||||||
var val := stripped.substr(colon + 1).strip_edges()
|
|
||||||
if val.is_empty():
|
|
||||||
i += 1
|
|
||||||
continue
|
|
||||||
sub[key] = coerce_scalar(val)
|
|
||||||
i += 1
|
|
||||||
return {"value": sub, "next_idx": i}
|
|
||||||
|
|
||||||
|
|
||||||
## Reassemble the full file text from prefix + a freshly built mcp_servers
|
|
||||||
## block + suffix. If the block didn't exist before, it is appended.
|
|
||||||
static func _assemble(_text: String, prefix: PackedStringArray, entries: Dictionary, suffix: PackedStringArray) -> String:
|
|
||||||
var out: PackedStringArray = []
|
|
||||||
for l in prefix:
|
|
||||||
out.append(l)
|
|
||||||
# Trim trailing blank lines from prefix so we don't stack double blanks.
|
|
||||||
while out.size() > 0 and out[out.size() - 1].strip_edges().is_empty():
|
|
||||||
out.remove_at(out.size() - 1)
|
|
||||||
|
|
||||||
if not _text.contains("mcp_servers:"):
|
|
||||||
# File existed but had no mcp_servers block — append it.
|
|
||||||
if out.size() > 0:
|
|
||||||
out.append("")
|
|
||||||
out.append("mcp_servers:")
|
|
||||||
for name in entries:
|
|
||||||
out.append_array(_emit_entry(name, entries[name]))
|
|
||||||
else:
|
|
||||||
out.append("mcp_servers:")
|
|
||||||
for name in entries:
|
|
||||||
out.append_array(_emit_entry(name, entries[name]))
|
|
||||||
|
|
||||||
# Suffix: keep as-is.
|
|
||||||
for l in suffix:
|
|
||||||
out.append(l)
|
|
||||||
return "\n".join(out)
|
|
||||||
|
|
||||||
|
|
||||||
## Public rendering seam for the dock's manual-instruction text, so the
|
|
||||||
## pasted YAML matches what Configure would write byte-for-byte.
|
|
||||||
static func render_entry_lines(name: String, data: Dictionary) -> PackedStringArray:
|
|
||||||
return _emit_entry(name, data)
|
|
||||||
|
|
||||||
|
|
||||||
## Emit one ` name:` entry with its scalar keys (top level only; headers
|
|
||||||
## sub-dict is re-emitted as nested scalars; arrays — the command entry's
|
|
||||||
## `args` — are emitted in flow style on one line).
|
|
||||||
static func _emit_entry(name: String, data: Dictionary) -> PackedStringArray:
|
|
||||||
var lines: PackedStringArray = []
|
|
||||||
lines.append(INDENT + "%s:" % name)
|
|
||||||
for key in data:
|
|
||||||
var val = data[key]
|
|
||||||
if val is Dictionary:
|
|
||||||
lines.append(INDENT + INDENT + "%s:" % key)
|
|
||||||
for sk in val:
|
|
||||||
lines.append(INDENT + INDENT + INDENT + "%s: %s" % [sk, emit_scalar(val[sk])])
|
|
||||||
elif val is Array or val is PackedStringArray:
|
|
||||||
lines.append(INDENT + INDENT + "%s: %s" % [key, emit_flow_array(_array_copy(val))])
|
|
||||||
else:
|
|
||||||
lines.append(INDENT + INDENT + "%s: %s" % [key, emit_scalar(val)])
|
|
||||||
return lines
|
|
||||||
|
|
||||||
|
|
||||||
## Flow-style sequence with every item double-quoted. JSON string quoting is
|
|
||||||
## valid YAML double-quote style (shared escape set), so the same encoding
|
|
||||||
## both writes the file and — via JSON.parse_string in `coerce_scalar` —
|
|
||||||
## reads it back for verification. Shared with `McpDshStrategy` for the
|
|
||||||
## DeepSeek Harness patch-list `args` lines.
|
|
||||||
static func emit_flow_array(values: Array) -> String:
|
|
||||||
var parts: Array[String] = []
|
|
||||||
for v in values:
|
|
||||||
parts.append(JSON.stringify(str(v)))
|
|
||||||
return "[%s]" % ", ".join(parts)
|
|
||||||
|
|
||||||
|
|
||||||
static func emit_scalar(v: Variant) -> String:
|
|
||||||
match typeof(v):
|
|
||||||
TYPE_BOOL:
|
|
||||||
return "true" if bool(v) else "false"
|
|
||||||
TYPE_INT:
|
|
||||||
return str(int(v))
|
|
||||||
TYPE_FLOAT:
|
|
||||||
return str(float(v))
|
|
||||||
_:
|
|
||||||
return emit_string_scalar(str(v))
|
|
||||||
|
|
||||||
|
|
||||||
## Plain YAML scalars cannot safely carry ": ", " #", quotes, flow
|
|
||||||
## indicators, or leading indicator characters — a Windows launcher path with
|
|
||||||
## spaces would silently corrupt the entry. Quote exactly when needed so
|
|
||||||
## existing plain values (urls, bools-as-strings) keep their current
|
|
||||||
## byte-shape on rewrite. Backslashes are quoted too: a Windows path parses
|
|
||||||
## either way, but quoting makes the written shape unambiguous for any
|
|
||||||
## consumer. Shared with `McpDshStrategy` for DeepSeek Harness patch entries.
|
|
||||||
static func emit_string_scalar(s: String) -> String:
|
|
||||||
if s.is_empty():
|
|
||||||
return "\"\""
|
|
||||||
var needs_quote := s.begins_with(" ") or s.ends_with(" ")
|
|
||||||
if not needs_quote:
|
|
||||||
for needle in [": ", " #", "\"", "'", "\n", "\t", "\\", "{", "}", "[", "]", ","]:
|
|
||||||
if s.contains(needle):
|
|
||||||
needs_quote = true
|
|
||||||
break
|
|
||||||
if not needs_quote:
|
|
||||||
for prefix in ["#", "-", "?", "&", "*", "!", "|", ">", "%", "@", "`"]:
|
|
||||||
if s.begins_with(prefix):
|
|
||||||
needs_quote = true
|
|
||||||
break
|
|
||||||
return JSON.stringify(s) if needs_quote else s
|
|
||||||
|
|
||||||
|
|
||||||
## Blank and comment-only lines carry no structure — every scan loop skips
|
|
||||||
## them the same way so a `# comment` can never be mistaken for an entry
|
|
||||||
## header or a key/value line.
|
|
||||||
static func _is_blank_or_comment(line: String) -> bool:
|
|
||||||
var stripped := line.strip_edges()
|
|
||||||
return stripped.is_empty() or stripped.begins_with("#")
|
|
||||||
|
|
||||||
|
|
||||||
## Returns the leading-whitespace indent width of a line (spaces + tabs
|
|
||||||
## counted as 1 each). Used to distinguish sibling entries (same indent)
|
|
||||||
## from nested keys (deeper indent) and parent-level keys (less indent).
|
|
||||||
static func _indent_of(line: String) -> int:
|
|
||||||
var n := 0
|
|
||||||
while n < line.length() and (line[n] == " " or line[n] == "\t"):
|
|
||||||
n += 1
|
|
||||||
return n
|
|
||||||
|
|
||||||
|
|
||||||
## Minimal scalar coercion for parsed YAML values. Quotes are stripped;
|
|
||||||
## bare true/false/numbers are typed; double-quoted flow sequences (the
|
|
||||||
## command entry's `args`) parse back into an Array. Good enough for Hermes'
|
|
||||||
## url/headers/command/args. A hand-edited args in block style or with
|
|
||||||
## unquoted items doesn't JSON-parse — it stays a raw string, compares
|
|
||||||
## unequal, and surfaces as CONFIGURED_MISMATCH, which Reconfigure
|
|
||||||
## normalizes back to the flow form. Public: the dsh strategy parses its
|
|
||||||
## patch-list scalars with the same rules, so both YAML dialects share one
|
|
||||||
## coercion (#867 review).
|
|
||||||
static func coerce_scalar(s: String) -> Variant:
|
|
||||||
var t := s.strip_edges()
|
|
||||||
if t.begins_with("[") and t.ends_with("]"):
|
|
||||||
var parsed_array: Variant = JSON.parse_string(t)
|
|
||||||
if parsed_array is Array:
|
|
||||||
return parsed_array
|
|
||||||
return t
|
|
||||||
if t.begins_with("\"") and t.ends_with("\""):
|
|
||||||
var parsed_string: Variant = JSON.parse_string(t)
|
|
||||||
if parsed_string is String:
|
|
||||||
return parsed_string
|
|
||||||
return t.substr(1, t.length() - 2)
|
|
||||||
if t.begins_with("'") and t.ends_with("'"):
|
|
||||||
return t.substr(1, t.length() - 2)
|
|
||||||
if t == "true":
|
|
||||||
return true
|
|
||||||
if t == "false":
|
|
||||||
return false
|
|
||||||
if t.is_valid_int():
|
|
||||||
return t.to_int()
|
|
||||||
if t.is_valid_float():
|
|
||||||
return t.to_float()
|
|
||||||
return t
|
|
||||||
|
|
||||||
|
|
||||||
## Returns {"ok": true, "data": String} when the file is absent or readable,
|
|
||||||
## and {"ok": false, "error": String} when unreadable. Callers must NOT fall
|
|
||||||
## back to an empty string on the error path — doing so blows away the user's
|
|
||||||
## other config.yaml entries on the next write.
|
|
||||||
static func _read(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 (%s)" % error_string(err)}
|
|
||||||
var t := f.get_as_text()
|
|
||||||
f.close()
|
|
||||||
return {"ok": true, "data": t}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
uid://cfnw4oe71ra1l
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
@tool
|
|
||||||
extends McpClient
|
|
||||||
|
|
||||||
|
|
||||||
func _init() -> void:
|
|
||||||
id = "antigravity"
|
|
||||||
display_name = "Antigravity"
|
|
||||||
config_type = "json"
|
|
||||||
## Antigravity moved its shared MCP config from `~/.gemini/antigravity/`
|
|
||||||
## to `~/.gemini/config/` (IDE + CLI now read the same file there); the
|
|
||||||
## old path is left in `detect_paths` below so an existing install is
|
|
||||||
## still recognized, but new/updated entries write to the current path.
|
|
||||||
path_template = {
|
|
||||||
"unix": "~/.gemini/config/mcp_config.json",
|
|
||||||
"windows": "$USERPROFILE/.gemini/config/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}
|
|
||||||
## Attach migration (#838). Antigravity stdio entries are flat
|
|
||||||
## command/args/env with no type discriminator — transport is inferred
|
|
||||||
## from `command` vs `serverUrl` presence (antigravity.google/docs/mcp),
|
|
||||||
## so the legacy `serverUrl` must not survive next to a command.
|
|
||||||
command_shape = McpClient.CommandShape.FLAT
|
|
||||||
command_legacy_keys = PackedStringArray(["serverUrl"])
|
|
||||||
command_initial_fields = {"disabled": false}
|
|
||||||
command_user_fields = PackedStringArray(["disabled", "disabledTools", "authProviderType", "env"])
|
|
||||||
## Antigravity's spawner hangs stdio tool calls when the entry launches a
|
|
||||||
## GUI-subsystem pythonw.exe (#863), and it hides child console windows
|
|
||||||
## itself, so the visible-terminal problem the bootstrap solves (#827)
|
|
||||||
## never applies. Write the plain console launcher on Windows.
|
|
||||||
needs_consoleless_launcher = false
|
|
||||||
detect_paths = PackedStringArray(path_template.values() + [
|
|
||||||
"~/.gemini/antigravity/mcp_config.json",
|
|
||||||
"$USERPROFILE/.gemini/antigravity/mcp_config.json",
|
|
||||||
])
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
uid://b4l1g0apa2hch
|
|
||||||
@@ -1,81 +0,0 @@
|
|||||||
@tool
|
|
||||||
extends McpClient
|
|
||||||
|
|
||||||
|
|
||||||
func _init() -> void:
|
|
||||||
id = "claude_code"
|
|
||||||
display_name = "Claude Code"
|
|
||||||
config_type = "cli"
|
|
||||||
cli_names = PackedStringArray(["claude", "claude.exe"] if OS.get_name() == "Windows" else ["claude"])
|
|
||||||
## Stdio registration through the client-owned `godot-ai attach` bridge
|
|
||||||
## (#838). `--` stops claude's own flag parsing so the attach argv passes
|
|
||||||
## through verbatim; stdio is the CLI's default transport. Scope comes from
|
|
||||||
## the `godot_ai/mcp_client_scope` EditorSetting via the `{scope}` token; it
|
|
||||||
## defaults to `user` — the same ~/.claude.json the pre-attach HTTP entry
|
|
||||||
## lived in — and can be set to `project` to write <project>/.mcp.json so the
|
|
||||||
## server is not loaded in every unrelated workspace.
|
|
||||||
cli_register_template = PackedStringArray(
|
|
||||||
["mcp", "add", "--scope", "{scope}", "{name}", "--", "{command}", "{args...}"]
|
|
||||||
)
|
|
||||||
## Explicit scope: an unscoped `mcp remove` deletes from whichever scope
|
|
||||||
## matches first, so the flag keeps each removal aimed at one known place.
|
|
||||||
## Note what that does and does not protect (#877): the dock's **Remove**
|
|
||||||
## button runs this once, at the selected scope only. **Configure** runs it
|
|
||||||
## once per scope in `CLIENT_SCOPES` before registering, so a Configure at
|
|
||||||
## any setting does delete a `godot-ai` entry from <cwd>/.mcp.json if one is
|
|
||||||
## there — see the cwd caveat below for which directory that actually is.
|
|
||||||
cli_unregister_template = PackedStringArray(["mcp", "remove", "--scope", "{scope}", "{name}"])
|
|
||||||
## Scope caveat: `--scope project` resolves `.mcp.json` against the
|
|
||||||
## *spawned CLI's* working directory, which is whatever the editor process
|
|
||||||
## inherited at launch — not necessarily `res://`. Godot has no API to set
|
|
||||||
## a child's cwd (`OS.execute_with_pipe` takes none), so this is an
|
|
||||||
## accepted limitation rather than something the descriptor can pin. It is
|
|
||||||
## self-consistent: `cli_status_args` runs from the same cwd, so the
|
|
||||||
## read-back finds whatever the register wrote (see
|
|
||||||
## `_scope_diverges_from_json_fallback` in client_configurator.gd).
|
|
||||||
cli_status_args = PackedStringArray(["mcp", "list"])
|
|
||||||
## `mcp list` prints the resolved entry for the cwd without saying which
|
|
||||||
## scope won, so it cannot tell "registered at project scope" from "an old
|
|
||||||
## user-scope entry is shadowing an empty project scope". `mcp get` prints
|
|
||||||
## both the command and a `Scope: …` line, in the same single subprocess,
|
|
||||||
## so scope-token clients probe with it instead (#872). Verified against
|
|
||||||
## claude 2.1.241, which prints one of:
|
|
||||||
## Scope: User config (available in all your projects)
|
|
||||||
## Scope: Project config (shared via .mcp.json)
|
|
||||||
## Scope: Local config (private to you in this project)
|
|
||||||
## and exits 1 with "No MCP server named …" when the entry is absent.
|
|
||||||
cli_scope_status_template = PackedStringArray(["mcp", "get", "{name}"])
|
|
||||||
## #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 for Configure whenever it resolves; this is what gets
|
|
||||||
## written otherwise. `claude mcp add --scope user <name> -- <cmd> <args>`
|
|
||||||
## produces exactly this shape under `mcpServers` in ~/.claude.json
|
|
||||||
## (verified live against claude CLI in an isolated CLAUDE_CONFIG_DIR):
|
|
||||||
## "godot-ai": { "type": "stdio", "command": "<cmd>", "args": [...], "env": {} }
|
|
||||||
## The fallback writer omits the empty `env`; the verifier accepts both.
|
|
||||||
## Status reads this file only while the selected scope is still `user` —
|
|
||||||
## it is the CLI's own store for that scope, and file reads give exact
|
|
||||||
## launch-drift detection that `mcp list` stdout scanning cannot. At
|
|
||||||
## `project`/`local` the entry is somewhere `path_template` cannot see, so
|
|
||||||
## `_scope_diverges_from_json_fallback` (client_configurator.gd) routes
|
|
||||||
## status to `cli_scope_status_template` instead and this file is not read.
|
|
||||||
path_template = {"unix": "~/.claude.json", "windows": "~/.claude.json"}
|
|
||||||
server_key_path = PackedStringArray(["mcpServers"])
|
|
||||||
## Legacy HTTP entries used this shape; command rendering repins it to stdio.
|
|
||||||
entry_extra_fields = {"type": "http"}
|
|
||||||
command_shape = McpClient.CommandShape.FLAT
|
|
||||||
command_transport_key = "type"
|
|
||||||
command_transport_value = "stdio"
|
|
||||||
## Legacy HTTP entries carried a `url`; Claude Code rejects an entry mixing
|
|
||||||
## url with command fields, and the stale `type: "http"` is repinned to
|
|
||||||
## "stdio" by the transport key above.
|
|
||||||
command_legacy_keys = PackedStringArray(["url"])
|
|
||||||
command_user_fields = PackedStringArray(["env"])
|
|
||||||
## Documented: $CLAUDE_CONFIG_DIR relocates Claude Code's config home,
|
|
||||||
## including .claude.json ($CLAUDE_CONFIG_DIR/.claude.json). The preferred
|
|
||||||
## CLI path needs no help — the spawned `claude` binary inherits the
|
|
||||||
## editor's environment and resolves the dir itself — but the JSON
|
|
||||||
## fallback above would otherwise write ~/.claude.json that a relocated
|
|
||||||
## install never reads (#617).
|
|
||||||
config_home_env = "CLAUDE_CONFIG_DIR"
|
|
||||||
config_home_env_subpath = ".claude.json"
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
uid://cp1u1hdpa6f8d
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
@tool
|
|
||||||
extends McpClient
|
|
||||||
|
|
||||||
## Claude Desktop's mcpServers entries launch a local stdio process. The
|
|
||||||
## client-owned `godot-ai attach` bridge keeps that stdio session stable while
|
|
||||||
## adopting or starting the shared HTTP backend as Godot editors come and go.
|
|
||||||
|
|
||||||
|
|
||||||
func _init() -> void:
|
|
||||||
id = "claude_desktop"
|
|
||||||
display_name = "Claude Desktop"
|
|
||||||
config_type = "json"
|
|
||||||
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",
|
|
||||||
}
|
|
||||||
## Store-installed Claude runs inside MSIX AppData virtualization. Godot is
|
|
||||||
## outside that container, so `%APPDATA%` names a different physical file
|
|
||||||
## once Claude has created its private copy. A unique Store package root is
|
|
||||||
## authoritative even before the config leaf exists: create the private file
|
|
||||||
## directly so a later copy-on-write cannot hide an entry written to roaming.
|
|
||||||
## With no Store package, use the conventional roaming path. The wildcard
|
|
||||||
## avoids coupling to the publisher-hash suffix.
|
|
||||||
config_path_candidates = {
|
|
||||||
"windows": [
|
|
||||||
"$LOCALAPPDATA/Packages/Claude_*/LocalCache/Roaming/Claude/claude_desktop_config.json",
|
|
||||||
"$APPDATA/Claude/claude_desktop_config.json",
|
|
||||||
],
|
|
||||||
}
|
|
||||||
detect_paths = PackedStringArray([
|
|
||||||
"$LOCALAPPDATA/Packages/Claude_*",
|
|
||||||
])
|
|
||||||
server_key_path = PackedStringArray(["mcpServers"])
|
|
||||||
command_shape = McpClient.CommandShape.FLAT
|
|
||||||
command_legacy_keys = PackedStringArray(["url"])
|
|
||||||
command_env_legacy_keys = PackedStringArray(["UV_LINK_MODE"])
|
|
||||||
command_user_fields = PackedStringArray(["env", "disabled"])
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
uid://bilntn5n8oqe3
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
@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"
|
|
||||||
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": []}
|
|
||||||
## Attach migration (#838). Cline stdio entries are flat command/args/env;
|
|
||||||
## its schema accepts `type: "stdio"` and normalizes typeless command
|
|
||||||
## entries to it (apps/vscode/src/services/mcp/schemas.ts), so pin the
|
|
||||||
## type — that also repins the legacy "streamableHttp" value instead of
|
|
||||||
## letting it survive the deep-copy and misroute the transport.
|
|
||||||
command_shape = McpClient.CommandShape.FLAT
|
|
||||||
command_transport_key = "type"
|
|
||||||
command_transport_value = "stdio"
|
|
||||||
command_legacy_keys = PackedStringArray(["url", "headers"])
|
|
||||||
command_initial_fields = {"disabled": false, "autoApprove": []}
|
|
||||||
command_user_fields = PackedStringArray([
|
|
||||||
"disabled", "autoApprove", "timeout", "oauth", "metadata",
|
|
||||||
"remoteConfigured", "env", "cwd",
|
|
||||||
])
|
|
||||||
command_timeout_fields = PackedStringArray(["timeout"])
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
uid://d36nywn2nkgts
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
@tool
|
|
||||||
extends McpClient
|
|
||||||
|
|
||||||
## CodeBuddy IDE: https://www.codebuddy.ai/docs/zh/ide/User-guide/MCP
|
|
||||||
## Official docs specify mcpServers + type:stdio + command/args/env.
|
|
||||||
## Global ~/.codebuddy/mcp.json was verified by the reporter in #941.
|
|
||||||
## This descriptor uses the global user scope,
|
|
||||||
## matching the registry's absolute user-path contract; project configuration
|
|
||||||
## remains available manually in CodeBuddy's MCP settings.
|
|
||||||
|
|
||||||
|
|
||||||
func _init() -> void:
|
|
||||||
id = "codebuddy"
|
|
||||||
display_name = "CodeBuddy"
|
|
||||||
config_type = "json"
|
|
||||||
path_template = {
|
|
||||||
"unix": "~/.codebuddy/mcp.json",
|
|
||||||
"windows": "$USERPROFILE/.codebuddy/mcp.json",
|
|
||||||
}
|
|
||||||
server_key_path = PackedStringArray(["mcpServers"])
|
|
||||||
command_shape = McpClient.CommandShape.FLAT
|
|
||||||
command_transport_key = "type"
|
|
||||||
command_transport_value = "stdio"
|
|
||||||
command_legacy_keys = PackedStringArray(["url", "headers"])
|
|
||||||
command_user_fields = PackedStringArray(["env", "description"])
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
uid://cutvvrwaef0rk
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
@tool
|
|
||||||
extends McpClient
|
|
||||||
|
|
||||||
|
|
||||||
func _init() -> void:
|
|
||||||
id = "codex"
|
|
||||||
display_name = "Codex"
|
|
||||||
config_type = "toml"
|
|
||||||
path_template = {"unix": "~/.codex/config.toml", "windows": "$USERPROFILE/.codex/config.toml"}
|
|
||||||
## Documented: when $CODEX_HOME is set, Codex reads config.toml directly
|
|
||||||
## from it instead of ~/.codex (#617).
|
|
||||||
config_home_env = "CODEX_HOME"
|
|
||||||
config_home_env_subpath = "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"])
|
|
||||||
command_shape = McpClient.CommandShape.COMMAND_ARRAY
|
|
||||||
command_legacy_keys = PackedStringArray(["url"])
|
|
||||||
## Initial-only: users may disable the entry or tune either timeout and
|
|
||||||
## Configure preserves that choice. Codex currently defaults to 10s for
|
|
||||||
## startup and 60s per tool; test_run legitimately has a 300s server
|
|
||||||
## budget, so the generated config leaves transport margin at the client.
|
|
||||||
command_initial_fields = {
|
|
||||||
"enabled": true,
|
|
||||||
"startup_timeout_sec": 60,
|
|
||||||
"tool_timeout_sec": 360,
|
|
||||||
}
|
|
||||||
command_timeout_fields = PackedStringArray([
|
|
||||||
"startup_timeout_sec",
|
|
||||||
"tool_timeout_sec",
|
|
||||||
])
|
|
||||||
command_user_fields = PackedStringArray([
|
|
||||||
"enabled",
|
|
||||||
"required",
|
|
||||||
"startup_timeout_sec",
|
|
||||||
"tool_timeout_sec",
|
|
||||||
"enabled_tools",
|
|
||||||
"disabled_tools",
|
|
||||||
"default_tools_approval_mode",
|
|
||||||
"env",
|
|
||||||
"env_vars",
|
|
||||||
"cwd",
|
|
||||||
])
|
|
||||||
detect_paths = PackedStringArray(path_template.values())
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
uid://hdlwcfdr8mdk
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
@tool
|
|
||||||
extends McpClient
|
|
||||||
|
|
||||||
|
|
||||||
func _init() -> void:
|
|
||||||
id = "cursor"
|
|
||||||
display_name = "Cursor"
|
|
||||||
config_type = "json"
|
|
||||||
path_template = {"unix": "~/.cursor/mcp.json", "windows": "$USERPROFILE/.cursor/mcp.json"}
|
|
||||||
server_key_path = PackedStringArray(["mcpServers"])
|
|
||||||
## Attach migration (#838). Cursor's stdio entries are flat command/args/env
|
|
||||||
## (cursor.com/docs/context/mcp). The docs' reference table documents
|
|
||||||
## `type: "stdio"`; pinning it also repins any hand-added `type: "http"`
|
|
||||||
## left on the legacy URL entry, which would otherwise survive the
|
|
||||||
## deep-copy migration and misroute the transport.
|
|
||||||
command_shape = McpClient.CommandShape.FLAT
|
|
||||||
command_transport_key = "type"
|
|
||||||
command_transport_value = "stdio"
|
|
||||||
command_legacy_keys = PackedStringArray(["url"])
|
|
||||||
command_user_fields = PackedStringArray(["env", "envFile"])
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
uid://bvpbssfanukef
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
@tool
|
|
||||||
extends McpClient
|
|
||||||
|
|
||||||
|
|
||||||
func _init() -> void:
|
|
||||||
id = "deepseek_harness"
|
|
||||||
display_name = "DeepSeek Harness"
|
|
||||||
## DeepSeek Harness (dsh) has no `mcp` CLI verb — MCP servers register as
|
|
||||||
## `@deepseek-ai/dsh-mcp-client` plugin entries in the HOME patch layer
|
|
||||||
## `$DSH_HOME/cordis.patch.yml`, which dsh applies over every profile's
|
|
||||||
## own patch layer (the web GUI included). config_type "dsh" routes to
|
|
||||||
## `McpDshStrategy`. Verified live against dsh 0.1.0-rc.6: new entries
|
|
||||||
## must be `insert` rows; plain rows only override existing bundle ids.
|
|
||||||
config_type = "dsh"
|
|
||||||
## Home patch layer, not a per-profile file: one entry covers the web
|
|
||||||
## GUI, headless, tui, and any custom profile.
|
|
||||||
path_template = {
|
|
||||||
"unix": "~/.dsh/cordis.patch.yml",
|
|
||||||
"windows": "~/.dsh/cordis.patch.yml",
|
|
||||||
}
|
|
||||||
## Documented: `$DSH_HOME` relocates the whole harness home, and the
|
|
||||||
## loader reads `$DSH_HOME/cordis.patch.yml` (homePatchPath) — same
|
|
||||||
## false-success-write class as CODEX_HOME (#617), so the override must
|
|
||||||
## be declared.
|
|
||||||
config_home_env = "DSH_HOME"
|
|
||||||
config_home_env_subpath = "cordis.patch.yml"
|
|
||||||
## Installed detection: the harness home directory itself (dsh GUI and
|
|
||||||
## CLI both live there), or the config file once one exists.
|
|
||||||
detect_paths = PackedStringArray(["~/.dsh"])
|
|
||||||
## Attach migration (#838). The loader entry nests the launch under
|
|
||||||
## `config`; the mcp-client plugin requires `transport` next to
|
|
||||||
## command/args, and a `url` must never coexist with command fields.
|
|
||||||
command_shape = McpClient.CommandShape.FLAT
|
|
||||||
command_transport_key = "transport"
|
|
||||||
command_transport_value = "stdio"
|
|
||||||
command_legacy_keys = PackedStringArray(["url"])
|
|
||||||
## Fields the mcp-client plugin documents as user-owned; preserved on
|
|
||||||
## reconfigure and never drift-checked.
|
|
||||||
command_user_fields = PackedStringArray([
|
|
||||||
"env", "cwd", "toolCallTimeoutMs", "failOnStartupError", "reconnect",
|
|
||||||
])
|
|
||||||
command_timeout_fields = PackedStringArray(["toolCallTimeoutMs"])
|
|
||||||
## The bridge accepts `transport: streamable-http` with a `url`, so the
|
|
||||||
## manual instructions may offer the HTTP alternative.
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
uid://cw3pghlevtql8
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
@tool
|
|
||||||
extends McpClient
|
|
||||||
|
|
||||||
|
|
||||||
func _init() -> void:
|
|
||||||
id = "gemini_cli"
|
|
||||||
display_name = "Gemini CLI"
|
|
||||||
config_type = "json"
|
|
||||||
path_template = {
|
|
||||||
"unix": "~/.gemini/settings.json",
|
|
||||||
"windows": "$USERPROFILE/.gemini/settings.json",
|
|
||||||
}
|
|
||||||
server_key_path = PackedStringArray(["mcpServers"])
|
|
||||||
entry_url_field = "httpUrl"
|
|
||||||
## Attach migration (#838). Gemini CLI stdio entries are flat
|
|
||||||
## command/args/env(+cwd); the config is one-of `command` | `url` (SSE) |
|
|
||||||
## `httpUrl` (docs/tools/mcp-server.md), so BOTH URL keys are legacy next
|
|
||||||
## to a command. `trust` bypasses tool confirmations — never seed it.
|
|
||||||
command_shape = McpClient.CommandShape.FLAT
|
|
||||||
command_legacy_keys = PackedStringArray(["httpUrl", "url"])
|
|
||||||
command_user_fields = PackedStringArray([
|
|
||||||
"timeout", "trust", "includeTools", "excludeTools", "env", "cwd",
|
|
||||||
])
|
|
||||||
command_timeout_fields = PackedStringArray(["timeout"])
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
uid://b8288pxninajy
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
@tool
|
|
||||||
extends McpClient
|
|
||||||
|
|
||||||
|
|
||||||
func _init() -> void:
|
|
||||||
id = "grok"
|
|
||||||
display_name = "Grok Build"
|
|
||||||
config_type = "toml"
|
|
||||||
# Grok Build reads MCP servers from ~/.grok/config.toml
|
|
||||||
# (https://x.ai / Grok user guide: MCP servers section).
|
|
||||||
path_template = {
|
|
||||||
"unix": "~/.grok/config.toml",
|
|
||||||
"windows": "$USERPROFILE/.grok/config.toml",
|
|
||||||
}
|
|
||||||
toml_section_path = PackedStringArray(["mcp_servers", "godot-ai"])
|
|
||||||
# Some docs / older notes used an underscore form.
|
|
||||||
toml_legacy_section_aliases = PackedStringArray(["mcp_servers.godot_ai"])
|
|
||||||
## Attach migration (#838). Grok's stdio sections are flat command/args/env
|
|
||||||
## with no type discriminator (docs.x.ai/build/features/mcp-servers);
|
|
||||||
## url/headers are the HTTP form and must not survive next to a command.
|
|
||||||
## Docs default startup_timeout_sec to 30 — a cold `uvx` install of the
|
|
||||||
## pinned package can exceed that, so new entries seed 60 (preserved once
|
|
||||||
## the user tunes it). tool_timeout_sec's documented 6000s default already
|
|
||||||
## clears test_run's 300s budget, so it is left alone. The old body
|
|
||||||
## template's `enabled = true` line was never documented for Grok — it is
|
|
||||||
## no longer seeded, and an existing value survives as a user key.
|
|
||||||
command_shape = McpClient.CommandShape.COMMAND_ARRAY
|
|
||||||
command_legacy_keys = PackedStringArray(["url", "headers"])
|
|
||||||
command_initial_fields = {"startup_timeout_sec": 60}
|
|
||||||
command_user_fields = PackedStringArray([
|
|
||||||
"env", "enabled", "startup_timeout_sec", "tool_timeout_sec",
|
|
||||||
])
|
|
||||||
command_timeout_fields = PackedStringArray(["startup_timeout_sec", "tool_timeout_sec"])
|
|
||||||
detect_paths = PackedStringArray(path_template.values())
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
uid://ckchsj5s3q1b0
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
@tool
|
|
||||||
extends McpClient
|
|
||||||
|
|
||||||
func _init() -> void:
|
|
||||||
id = "hermes"
|
|
||||||
display_name = "Hermes Agent"
|
|
||||||
config_type = "yaml"
|
|
||||||
|
|
||||||
# Hermes reads MCP config from ~/.hermes/config.yaml (YAML), NOT mcp.json.
|
|
||||||
# Verified against the official docs:
|
|
||||||
# https://hermes-agent.nousresearch.com/docs/user-guide/features/mcp
|
|
||||||
# Windows: Hermes stores config under $LOCALAPPDATA/hermes (NOT $APPDATA,
|
|
||||||
# which is Roaming) — confirmed by where the running Hermes process reads.
|
|
||||||
# NOTE: _path_template.expand() only substitutes $VAR tokens, not %VAR%.
|
|
||||||
path_template = {
|
|
||||||
"unix": "~/.hermes/config.yaml",
|
|
||||||
"windows": "$LOCALAPPDATA/hermes/config.yaml"
|
|
||||||
}
|
|
||||||
|
|
||||||
# Hermes uses the snake_case `mcp_servers` key (not `mcpServers`).
|
|
||||||
# PackedStringArray explicitly, matching every other descriptor — an
|
|
||||||
# untyped Array literal relies on implicit conversion that newer Godot
|
|
||||||
# builds enforce more strictly (the #722 CI lesson for Array[String]).
|
|
||||||
server_key_path = PackedStringArray(["mcp_servers"])
|
|
||||||
|
|
||||||
# HTTP entries use `url` (+ optional `headers`); transport is inferred —
|
|
||||||
# there is no `type` field in Hermes MCP config.
|
|
||||||
entry_url_field = "url"
|
|
||||||
|
|
||||||
# No transport pin: Hermes infers streamable-http from the URL.
|
|
||||||
entry_extra_fields = {}
|
|
||||||
entry_initial_fields = {}
|
|
||||||
|
|
||||||
## Attach migration (#838). Hermes stdio entries are flat command/args/env
|
|
||||||
## (hermes-agent.nousresearch.com/docs/user-guide/features/mcp), transport
|
|
||||||
## inferred exactly like the URL form — which is why `url` and the
|
|
||||||
## HTTP-only `headers` must not survive next to a command: an entry with
|
|
||||||
## both picks the wrong transport. `enabled`/`tools`/`env` stay user-owned.
|
|
||||||
command_shape = McpClient.CommandShape.FLAT
|
|
||||||
command_legacy_keys = PackedStringArray(["url", "headers"])
|
|
||||||
command_user_fields = PackedStringArray(["enabled", "tools", "env"])
|
|
||||||
|
|
||||||
# Hermes is "installed" wherever the config.yaml lives; presence of the
|
|
||||||
# file is sufficient for the dock's installed badge.
|
|
||||||
detect_paths = PackedStringArray()
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
uid://ewmadhrvs5d7
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
@tool
|
|
||||||
extends McpClient
|
|
||||||
|
|
||||||
|
|
||||||
func _init() -> void:
|
|
||||||
id = "kilo_code"
|
|
||||||
display_name = "Kilo Code"
|
|
||||||
config_type = "json"
|
|
||||||
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": []}
|
|
||||||
## Attach migration (#838). UNLIKE its Roo siblings the stdio entry must be
|
|
||||||
## TYPELESS: Kilo's v7 platform treats this file as a migration source and
|
|
||||||
## routes on `type` — any http type value sends the entry down the remote
|
|
||||||
## branch where it is dropped for lack of a url, and only a bare
|
|
||||||
## command/args/env entry is verified to work in BOTH the legacy extension
|
|
||||||
## and the v7 migrator (packages/opencode/src/kilocode/mcp-migrator.ts).
|
|
||||||
## `type` therefore joins the legacy keys instead of being repinned.
|
|
||||||
command_shape = McpClient.CommandShape.FLAT
|
|
||||||
command_legacy_keys = PackedStringArray(["url", "type", "headers"])
|
|
||||||
command_initial_fields = {"disabled": false, "alwaysAllow": []}
|
|
||||||
command_user_fields = PackedStringArray([
|
|
||||||
"disabled", "alwaysAllow", "timeout", "cwd", "watchPaths",
|
|
||||||
"disabledTools", "env",
|
|
||||||
])
|
|
||||||
command_timeout_fields = PackedStringArray(["timeout"])
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
uid://dc1x77i1cmb6w
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
@tool
|
|
||||||
extends McpClient
|
|
||||||
|
|
||||||
|
|
||||||
func _init() -> void:
|
|
||||||
id = "kimi_code"
|
|
||||||
display_name = "Kimi Code"
|
|
||||||
## Kimi Code has no `mcp` CLI subcommand (verified against v0.28.1 —
|
|
||||||
## `kimi mcp` falls through to the root --help, and the docs at
|
|
||||||
## moonshotai.github.io/kimi-code/en/customization/mcp confirm servers are
|
|
||||||
## managed via ~/.kimi-code/mcp.json, not a CLI verb). JSON is therefore
|
|
||||||
## the only working config method, not a fallback.
|
|
||||||
config_type = "json"
|
|
||||||
path_template = {"unix": "~/.kimi-code/mcp.json", "windows": "~/.kimi-code/mcp.json"}
|
|
||||||
server_key_path = PackedStringArray(["mcpServers"])
|
|
||||||
entry_extra_fields = {"transport": "http"}
|
|
||||||
## Documented: `$KIMI_CODE_HOME/mcp.json` relocates the config
|
|
||||||
## (moonshotai.github.io/kimi-code/en/customization/mcp) — same
|
|
||||||
## false-success-write class as CODEX_HOME (#617).
|
|
||||||
config_home_env = "KIMI_CODE_HOME"
|
|
||||||
config_home_env_subpath = "mcp.json"
|
|
||||||
## Attach migration (#838). Kimi Code stdio entries are flat
|
|
||||||
## command/args/env(+cwd): "Entries with a `command` field are stdio
|
|
||||||
## servers". `transport` is only defined for SSE-with-url, so the legacy
|
|
||||||
## `transport: "http"` must be removed alongside `url`. Timeout fields are
|
|
||||||
## camelCase, unlike Codex's snake_case.
|
|
||||||
command_shape = McpClient.CommandShape.FLAT
|
|
||||||
command_legacy_keys = PackedStringArray(["url", "transport"])
|
|
||||||
command_user_fields = PackedStringArray([
|
|
||||||
"enabled", "startupTimeoutMs", "toolTimeoutMs", "enabledTools",
|
|
||||||
"disabledTools", "env", "cwd",
|
|
||||||
])
|
|
||||||
command_timeout_fields = PackedStringArray(["startupTimeoutMs", "toolTimeoutMs"])
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
uid://d2whd6a5fofhg
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
@tool
|
|
||||||
extends McpClient
|
|
||||||
|
|
||||||
|
|
||||||
func _init() -> void:
|
|
||||||
id = "kiro"
|
|
||||||
display_name = "Kiro"
|
|
||||||
config_type = "json"
|
|
||||||
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}
|
|
||||||
## Attach migration (#838). Kiro stdio entries are flat command/args/env
|
|
||||||
## with no type discriminator (kiro.dev/docs/mcp/configuration).
|
|
||||||
## `autoApprove` is user-state, same contract as the URL entry's fields.
|
|
||||||
command_shape = McpClient.CommandShape.FLAT
|
|
||||||
command_legacy_keys = PackedStringArray(["url"])
|
|
||||||
command_initial_fields = {"disabled": false}
|
|
||||||
command_user_fields = PackedStringArray(["disabled", "autoApprove", "disabledTools", "env"])
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
uid://dqdmd2jw5qen7
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
@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"
|
|
||||||
## `$HOME` on Windows is deliberate: OpenCode reads ~/.config/... on ALL
|
|
||||||
## platforms (verified via `opencode debug paths`), and
|
|
||||||
## McpPathTemplate._home() falls back to USERPROFILE when HOME is unset —
|
|
||||||
## pinned by test_opencode_client_uses_home_config_on_windows. The documented
|
|
||||||
## `OPENCODE_CONFIG` override names an exact file and must win over this
|
|
||||||
## default for configure, status, remove, and manual instructions.
|
|
||||||
path_template = {
|
|
||||||
"unix": "~/.config/opencode/opencode.json",
|
|
||||||
"windows": "$HOME/.config/opencode/opencode.json",
|
|
||||||
}
|
|
||||||
config_file_env = "OPENCODE_CONFIG"
|
|
||||||
## OpenCode loads BOTH opencode.json and opencode.jsonc from its config
|
|
||||||
## directory and merges them, later file winning per key
|
|
||||||
## (anomalyco/opencode#32447). Writing only opencode.json therefore
|
|
||||||
## leaves a stale `godot-ai` entry in an existing opencode.jsonc in
|
|
||||||
## charge (#1011). Declaring the merge order makes Configure update the
|
|
||||||
## effective last definition, status verify it, and Remove clear both.
|
|
||||||
config_merge_path_templates = {
|
|
||||||
"unix": PackedStringArray([
|
|
||||||
"~/.config/opencode/opencode.json",
|
|
||||||
"~/.config/opencode/opencode.jsonc",
|
|
||||||
]),
|
|
||||||
"windows": PackedStringArray([
|
|
||||||
"$HOME/.config/opencode/opencode.json",
|
|
||||||
"$HOME/.config/opencode/opencode.jsonc",
|
|
||||||
]),
|
|
||||||
}
|
|
||||||
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}
|
|
||||||
## Attach migration (#838). OpenCode local entries carry the launch as ONE
|
|
||||||
## argv array — `"command": ["uvx", …]` with no separate args key — plus a
|
|
||||||
## schema-REQUIRED `type: "local"` (McpLocalConfig in opencode.ai/config.json;
|
|
||||||
## env lives under `environment`, not `env`). The pin rewrites the legacy
|
|
||||||
## `type: "remote"` in place; url/headers are remote-only and must go.
|
|
||||||
command_shape = McpClient.CommandShape.COMMAND_ARRAY
|
|
||||||
command_transport_key = "type"
|
|
||||||
command_transport_value = "local"
|
|
||||||
command_legacy_keys = PackedStringArray(["url", "headers"])
|
|
||||||
command_initial_fields = {"enabled": true}
|
|
||||||
command_user_fields = PackedStringArray(["enabled", "timeout", "environment", "cwd"])
|
|
||||||
command_timeout_fields = PackedStringArray(["timeout"])
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
uid://s8n0vfirf2pj
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
@tool
|
|
||||||
extends McpClient
|
|
||||||
|
|
||||||
|
|
||||||
func _init() -> void:
|
|
||||||
id = "pi"
|
|
||||||
display_name = "Pi Agent"
|
|
||||||
config_type = "json"
|
|
||||||
# pi-codemode-mcp reads MCP server definitions from ~/.pi/agent/mcp.json
|
|
||||||
# (first merge tier; ~/.pi/agent/.mcp.json, .pi/mcp.json, and
|
|
||||||
# .mcp.json merge after, project-scope last). Documented at
|
|
||||||
# github.com/mitsuhiko/pi-codemode-mcp README "Configuration files"
|
|
||||||
# section. Windows path mirrors antigravity's $USERPROFILE choice so
|
|
||||||
# the descriptor round-trips on every supported platform.
|
|
||||||
path_template = {
|
|
||||||
"unix": "~/.pi/agent/mcp.json",
|
|
||||||
"windows": "$USERPROFILE/.pi/agent/mcp.json",
|
|
||||||
}
|
|
||||||
# Pi merges these files in order; later definitions of the same server win.
|
|
||||||
# Keep this separate from config_path_candidates, whose entries are alternatives
|
|
||||||
# rather than merge tiers.
|
|
||||||
config_merge_path_templates = {
|
|
||||||
"unix": PackedStringArray([
|
|
||||||
"~/.pi/agent/mcp.json",
|
|
||||||
"~/.pi/agent/.mcp.json",
|
|
||||||
]),
|
|
||||||
"windows": PackedStringArray([
|
|
||||||
"$USERPROFILE/.pi/agent/mcp.json",
|
|
||||||
"$USERPROFILE/.pi/agent/.mcp.json",
|
|
||||||
]),
|
|
||||||
}
|
|
||||||
config_merge_project_paths = PackedStringArray([".pi/mcp.json", ".mcp.json"])
|
|
||||||
server_key_path = PackedStringArray(["mcpServers"])
|
|
||||||
# pi-codemode-mcp resolves these with nullish precedence:
|
|
||||||
# mcpServers ?? mcp-servers ?? servers. Reuse whichever map already exists
|
|
||||||
# so Configure cannot shadow and deactivate the user's existing servers.
|
|
||||||
var aliases: Array[PackedStringArray] = [
|
|
||||||
PackedStringArray(["mcp-servers"]),
|
|
||||||
PackedStringArray(["servers"]),
|
|
||||||
]
|
|
||||||
server_key_path_aliases = aliases
|
|
||||||
entry_url_field = "url"
|
|
||||||
# pi-codemode-mcp selects stdio from `command` and remote transport from
|
|
||||||
# `url`; it ignores `type`. Keep generated stdio entries minimal and typeless.
|
|
||||||
entry_extra_fields = {}
|
|
||||||
entry_initial_fields = {}
|
|
||||||
# Attach migration (#838). Pi stdio entries are flat command/args/env.
|
|
||||||
# When Configure replaces a legacy URL entry, remove `url`, `headers`, and
|
|
||||||
# any redundant `type` field so the generated stdio entry stays canonical.
|
|
||||||
command_shape = McpClient.CommandShape.FLAT
|
|
||||||
command_legacy_keys = PackedStringArray(["url", "headers", "type"])
|
|
||||||
# `env` is the only user-mutable field pi's docs surface. Future
|
|
||||||
# fields still survive — the strategy deep-copies unknown keys.
|
|
||||||
command_user_fields = PackedStringArray(["env"])
|
|
||||||
# Pi's loader also accepts pure-URL entries (the example mcp.json in
|
|
||||||
# the pi-codemode-mcp repo ships stdio + URL entries side-by-side).
|
|
||||||
# ~/.pi/agent/mcp.json is created by the first pi launch, so its
|
|
||||||
# presence is the strongest install signal. Mirror antigravity's
|
|
||||||
# `detect_paths = path_template.values()` pattern so the dock shows
|
|
||||||
# the installed badge before Configure has run.
|
|
||||||
detect_paths = PackedStringArray(path_template.values())
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
uid://it00ohwkm2nd
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
@tool
|
|
||||||
extends McpClient
|
|
||||||
|
|
||||||
|
|
||||||
func _init() -> void:
|
|
||||||
id = "qwen_code"
|
|
||||||
display_name = "Qwen Code"
|
|
||||||
config_type = "json"
|
|
||||||
path_template = {
|
|
||||||
"unix": "~/.qwen/settings.json",
|
|
||||||
"windows": "$USERPROFILE/.qwen/settings.json",
|
|
||||||
}
|
|
||||||
server_key_path = PackedStringArray(["mcpServers"])
|
|
||||||
entry_url_field = "httpUrl"
|
|
||||||
## Attach migration (#838). Qwen Code is a gemini-cli fork with the same
|
|
||||||
## flat stdio shape and one-of `command` | `url` | `httpUrl` rule
|
|
||||||
## (docs/users/features/mcp.md). Qwen adds `discoveryTimeoutMs` (stdio
|
|
||||||
## discovery handshake cap, default 30s).
|
|
||||||
command_shape = McpClient.CommandShape.FLAT
|
|
||||||
command_legacy_keys = PackedStringArray(["httpUrl", "url"])
|
|
||||||
command_user_fields = PackedStringArray([
|
|
||||||
"timeout", "trust", "includeTools", "excludeTools", "env", "cwd",
|
|
||||||
"discoveryTimeoutMs",
|
|
||||||
])
|
|
||||||
command_timeout_fields = PackedStringArray(["timeout", "discoveryTimeoutMs"])
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
uid://qwb5udkf423q
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
@tool
|
|
||||||
extends McpClient
|
|
||||||
|
|
||||||
|
|
||||||
func _init() -> void:
|
|
||||||
id = "roo_code"
|
|
||||||
display_name = "Roo Code"
|
|
||||||
config_type = "json"
|
|
||||||
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": []}
|
|
||||||
## Attach migration (#838). Roo stdio entries are flat command/args/env
|
|
||||||
## (+cwd); docs state `type` defaults to "stdio" for command configs and
|
|
||||||
## the stdio schema forbids url/headers. Pin type=stdio — it is documented
|
|
||||||
## and repins the legacy "streamable-http" value in place.
|
|
||||||
command_shape = McpClient.CommandShape.FLAT
|
|
||||||
command_transport_key = "type"
|
|
||||||
command_transport_value = "stdio"
|
|
||||||
command_legacy_keys = PackedStringArray(["url", "headers"])
|
|
||||||
command_initial_fields = {"disabled": false, "alwaysAllow": []}
|
|
||||||
command_user_fields = PackedStringArray([
|
|
||||||
"disabled", "alwaysAllow", "timeout", "disabledTools", "watchPaths",
|
|
||||||
"env", "cwd",
|
|
||||||
])
|
|
||||||
command_timeout_fields = PackedStringArray(["timeout"])
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
uid://denjdf50qrf66
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
@tool
|
|
||||||
extends McpClient
|
|
||||||
|
|
||||||
|
|
||||||
func _init() -> void:
|
|
||||||
id = "trae"
|
|
||||||
display_name = "Trae"
|
|
||||||
config_type = "json"
|
|
||||||
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"])
|
|
||||||
## Attach migration (#838). Trae stdio entries are flat command/args/env
|
|
||||||
## with no type discriminator (docs.trae.cn/ide/add-mcp-servers); transport
|
|
||||||
## is inferred from `command` vs `url`, so url/headers are legacy next to a
|
|
||||||
## command. Entry stays minimal — Trae manages enable/disable in its UI,
|
|
||||||
## and its startup/run timeouts ride user-owned env vars, which survive.
|
|
||||||
command_shape = McpClient.CommandShape.FLAT
|
|
||||||
command_legacy_keys = PackedStringArray(["url", "headers"])
|
|
||||||
command_user_fields = PackedStringArray(["env"])
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
uid://cwpu48772vfj1
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
@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"
|
|
||||||
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"}
|
|
||||||
## Attach migration (#838). VS Code stdio entries are flat command/args/env
|
|
||||||
## under `servers` with a documented `type: "stdio"` discriminator
|
|
||||||
## (code.visualstudio.com/docs/agents/reference/mcp-configuration). The
|
|
||||||
## stdio schema is `additionalProperties: false` (mcpConfiguration.ts), so
|
|
||||||
## removing the legacy url/headers is load-bearing — leftovers invalidate
|
|
||||||
## the whole entry, and the pin flips the legacy `type: "http"` in place.
|
|
||||||
command_shape = McpClient.CommandShape.FLAT
|
|
||||||
command_transport_key = "type"
|
|
||||||
command_transport_value = "stdio"
|
|
||||||
command_legacy_keys = PackedStringArray(["url", "headers"])
|
|
||||||
command_user_fields = PackedStringArray(["env", "envFile", "cwd", "sandboxEnabled", "dev"])
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
uid://dl6cm044pihub
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
@tool
|
|
||||||
extends McpClient
|
|
||||||
|
|
||||||
|
|
||||||
func _init() -> void:
|
|
||||||
id = "vscode_insiders"
|
|
||||||
display_name = "VS Code Insiders"
|
|
||||||
config_type = "json"
|
|
||||||
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"}
|
|
||||||
## Attach migration (#838). Identical format to vscode.gd — Insiders ships
|
|
||||||
## the same mcpConfiguration.ts schema; see that descriptor for citations.
|
|
||||||
command_shape = McpClient.CommandShape.FLAT
|
|
||||||
command_transport_key = "type"
|
|
||||||
command_transport_value = "stdio"
|
|
||||||
command_legacy_keys = PackedStringArray(["url", "headers"])
|
|
||||||
command_user_fields = PackedStringArray(["env", "envFile", "cwd", "sandboxEnabled", "dev"])
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
uid://cad5w4ofyg8a2
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
@tool
|
|
||||||
extends McpClient
|
|
||||||
|
|
||||||
|
|
||||||
func _init() -> void:
|
|
||||||
# #623: Windsurf was rebranded to Devin Desktop by Cognition (June 2026).
|
|
||||||
# The id stays "windsurf" — it is the stable registry key used for
|
|
||||||
# configured-status lookups. The MCP config path is unchanged by the
|
|
||||||
# rebrand: per the official docs (docs.devin.ai/desktop/cascade/mcp) the
|
|
||||||
# global config still lives under the platform's `.codeium/windsurf/`
|
|
||||||
# directory (~/.codeium/windsurf/ on unix, $USERPROFILE/.codeium/windsurf/
|
|
||||||
# on Windows), and migrated installs carry their settings over in place.
|
|
||||||
id = "windsurf"
|
|
||||||
display_name = "Devin Desktop (Windsurf)"
|
|
||||||
config_type = "json"
|
|
||||||
path_template = {
|
|
||||||
"unix": "~/.codeium/windsurf/mcp_config.json",
|
|
||||||
"windows": "$USERPROFILE/.codeium/windsurf/mcp_config.json",
|
|
||||||
}
|
|
||||||
server_key_path = PackedStringArray(["mcpServers"])
|
|
||||||
entry_url_field = "serverUrl"
|
|
||||||
## Attach migration (#838). Stdio entries are flat command/args/env
|
|
||||||
## (docs.devin.ai/desktop/cascade/mcp); transport is inferred from
|
|
||||||
## `command` vs `serverUrl` presence and no type field is documented —
|
|
||||||
## do not write one.
|
|
||||||
command_shape = McpClient.CommandShape.FLAT
|
|
||||||
command_legacy_keys = PackedStringArray(["serverUrl"])
|
|
||||||
command_user_fields = PackedStringArray(["env"])
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
uid://b6pqiok2mlsmg
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
@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"
|
|
||||||
## Zed settings are JSONC. Owning a second JSON tokenizer solely to preserve
|
|
||||||
## comments would make the shared JSON strategy much harder to trust. Keep
|
|
||||||
## status checks read-only and give users the exact manual entry instead.
|
|
||||||
automatic_config_edits = false
|
|
||||||
## Reads still have to cope with the `//` header Zed ships, or a stock
|
|
||||||
## install shows a parse error in the row instead of a status.
|
|
||||||
config_allows_comments = true
|
|
||||||
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"])
|
|
||||||
## Attach migration (#838). Current Zed's context_servers entries are an
|
|
||||||
## untagged serde enum discriminated by shape: `command` (string) + args/env
|
|
||||||
## → stdio, `url` → HTTP (zed.dev/docs/ai/mcp; settings_content/project.rs).
|
|
||||||
## BECAUSE the enum is untagged, HTTP-only keys left next to `command`
|
|
||||||
## (`url`, `headers`, `oauth`) make the entry match no variant and break it —
|
|
||||||
## removing them on migration is load-bearing, not cosmetic. `enabled`,
|
|
||||||
## `remote`, and `timeout` are user-state on the stdio variant.
|
|
||||||
command_shape = McpClient.CommandShape.FLAT
|
|
||||||
command_legacy_keys = PackedStringArray(["url", "headers", "oauth"])
|
|
||||||
command_user_fields = PackedStringArray(["enabled", "remote", "timeout", "env"])
|
|
||||||
command_timeout_fields = PackedStringArray(["timeout"])
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
uid://d152l0u0r6fsc
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
@tool
|
|
||||||
extends McpClient
|
|
||||||
|
|
||||||
|
|
||||||
func _init() -> void:
|
|
||||||
id = "zoo_code"
|
|
||||||
display_name = "Zoo Code"
|
|
||||||
config_type = "json"
|
|
||||||
path_template = {
|
|
||||||
"darwin": "~/Library/Application Support/Code/User/globalStorage/zoocodeorganization.zoo-code/settings/mcp_settings.json",
|
|
||||||
"windows": "$APPDATA/Code/User/globalStorage/zoocodeorganization.zoo-code/settings/mcp_settings.json",
|
|
||||||
"linux": "$XDG_CONFIG_HOME/Code/User/globalStorage/zoocodeorganization.zoo-code/settings/mcp_settings.json",
|
|
||||||
}
|
|
||||||
server_key_path = PackedStringArray(["mcpServers"])
|
|
||||||
## Local validation against the installed extension shows Zoo stores MCP
|
|
||||||
## entries in `settings/mcp_settings.json` under `mcpServers`, matching Roo's
|
|
||||||
## shape. Its changelog also references Streamable HTTP support, so pin the
|
|
||||||
## transport explicitly to avoid any typeless entry falling back to SSE.
|
|
||||||
entry_extra_fields = {"type": "streamable-http"}
|
|
||||||
## Preserve user-controlled state across reconfigure, parallel to Roo/Kilo.
|
|
||||||
entry_initial_fields = {"disabled": false, "alwaysAllow": []}
|
|
||||||
## Attach migration (#838). Zoo's stdio zod schema is Roo's: flat
|
|
||||||
## command/args/env(+cwd), `type: z.enum(["stdio"]).optional()`, and
|
|
||||||
## url/headers explicitly forbidden on stdio entries (McpHub.ts) — so both
|
|
||||||
## are legacy keys and the documented type pin repins "streamable-http".
|
|
||||||
command_shape = McpClient.CommandShape.FLAT
|
|
||||||
command_transport_key = "type"
|
|
||||||
command_transport_value = "stdio"
|
|
||||||
command_legacy_keys = PackedStringArray(["url", "headers"])
|
|
||||||
command_initial_fields = {"disabled": false, "alwaysAllow": []}
|
|
||||||
command_user_fields = PackedStringArray([
|
|
||||||
"disabled", "alwaysAllow", "timeout", "watchPaths", "disabledTools",
|
|
||||||
"env", "cwd",
|
|
||||||
])
|
|
||||||
command_timeout_fields = PackedStringArray(["timeout"])
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
uid://fmp0nlzcm3ukl
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1 +0,0 @@
|
|||||||
uid://bmnk8rsotiks2
|
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
@tool
|
|
||||||
extends RefCounted
|
|
||||||
|
|
||||||
## bridge between dispatcher and addon handler
|
|
||||||
|
|
||||||
const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd")
|
|
||||||
|
|
||||||
var _spec: McpCustomToolSpec
|
|
||||||
var _locator: McpServiceLocator
|
|
||||||
var _handler_instance = null # lazily loaded from _spec.script_path
|
|
||||||
|
|
||||||
func _init(spec: McpCustomToolSpec, locator: McpServiceLocator) -> void:
|
|
||||||
_spec = spec
|
|
||||||
_locator = locator
|
|
||||||
|
|
||||||
|
|
||||||
func quiesce_for_script_swap() -> Dictionary:
|
|
||||||
if _handler_instance == null:
|
|
||||||
return {"ok": true}
|
|
||||||
if not _handler_instance.has_method("quiesce_for_script_swap"):
|
|
||||||
return {
|
|
||||||
"ok": false,
|
|
||||||
"error": "Custom tool '%s' must provide quiesce_for_script_swap() before hot updates." % _spec.name,
|
|
||||||
}
|
|
||||||
var result: Variant = _handler_instance.call("quiesce_for_script_swap")
|
|
||||||
if not result is Dictionary or result.get("ok") != true:
|
|
||||||
return {"ok": false, "error": "Custom tool '%s' still owns active work." % _spec.name}
|
|
||||||
return {"ok": true}
|
|
||||||
|
|
||||||
## Invoked by dispatcher._call_handler as .call(params) — SINGLE ARG.
|
|
||||||
## Internally splits into (clean_params, ctx) for the addon handler.
|
|
||||||
func invoke(params: Dictionary) -> Dictionary:
|
|
||||||
## Dock enable/disable gate: a disabled tool is dropped from the
|
|
||||||
## catalog push, but a client holding a stale list (or batch_execute)
|
|
||||||
## can still name it — reject at dispatch too.
|
|
||||||
var registry := McpToolRegistry.get_instance()
|
|
||||||
if registry != null and not registry.is_tool_enabled(_spec.name):
|
|
||||||
return ErrorCodes.make(ErrorCodes.CUSTOM_TOOL_DISABLED,
|
|
||||||
"Custom tool '%s' is disabled in the Godot AI dock" % _spec.name)
|
|
||||||
## Readiness gate (https://github.com/hi-godot/godot-ai/issues/781#issuecomment-5036376599 #1): block writes during play/import.
|
|
||||||
## Checked BEFORE the lazy load so a gated tool's handler script (and any
|
|
||||||
## _init side effects) never runs while the editor is busy.
|
|
||||||
var _readiness := McpConnection.get_readiness()
|
|
||||||
if _spec.requires_writable and (_readiness == "importing" or _readiness == "playing"):
|
|
||||||
return ErrorCodes.make(ErrorCodes.EDITOR_NOT_READY, "Editor is '%s' — write blocked for custom tool '%s'" % [_readiness, _spec.name])
|
|
||||||
if _handler_instance == null:
|
|
||||||
var script := load(_spec.script_path) as GDScript
|
|
||||||
if script == null:
|
|
||||||
return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Cannot load %s" % _spec.script_path)
|
|
||||||
_handler_instance = script.new() # no-arg; per-call context arrives via ctx
|
|
||||||
if not _handler_instance.has_method(_spec.method):
|
|
||||||
return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "%s has no method '%s' for custom tool '%s'" % [_spec.script_path, _spec.method, _spec.name])
|
|
||||||
## Extract _request_id (dispatcher injected it) and strip from params
|
|
||||||
## so the addon sees clean params matching its declared schema (https://github.com/hi-godot/godot-ai/issues/781#issuecomment-5036376599 #2).
|
|
||||||
var request_id: String= params.get("_request_id", "")
|
|
||||||
var clean_params := params.duplicate()
|
|
||||||
clean_params.erase("_request_id")
|
|
||||||
## Construct ctx with transport metadata + live-object locator.
|
|
||||||
var ctx := McpCallContext.new()
|
|
||||||
ctx.request_id = request_id
|
|
||||||
ctx.session_id = _locator.get_connection().get_session_id()
|
|
||||||
ctx.spec = _spec
|
|
||||||
ctx.attach_locator(_locator)
|
|
||||||
ctx.deadline_msec = Time.get_ticks_msec() + _spec.timeout_ms
|
|
||||||
var result: Dictionary = _handler_instance.call(_spec.method, clean_params, ctx)
|
|
||||||
if result.get("_deferred", false):
|
|
||||||
## Enforce the declared contract: batch_execute and the server's
|
|
||||||
## timeout budget both trust spec.deferred, so a non-deferred spec
|
|
||||||
## whose handler defers anyway would report success while its real
|
|
||||||
## reply arrives uncorrelated later.
|
|
||||||
if not _spec.deferred:
|
|
||||||
return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR,
|
|
||||||
"Custom tool '%s' returned a deferred response but its spec declares deferred=false" % _spec.name)
|
|
||||||
if _spec.timeout_ms > 0:
|
|
||||||
## Handlers return the SHARED McpDispatcher.DEFERRED_RESPONSE
|
|
||||||
## const, which Godot makes read-only — stamping the per-spec
|
|
||||||
## budget on it directly is a script error that aborts the call.
|
|
||||||
result = result.duplicate()
|
|
||||||
result["_deferred_timeout_ms"] = _spec.timeout_ms
|
|
||||||
return result
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
uid://b68uel6lsb7jr
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
@tool
|
|
||||||
class_name McpCallContext
|
|
||||||
extends RefCounted
|
|
||||||
|
|
||||||
## Per-call context handed to addon handlers.
|
|
||||||
##
|
|
||||||
## STABLE addon-facing surface (see AGENTS.md — published class_names are
|
|
||||||
## permanent compat surface): `request_id`, `session_id`, `deadline_msec`,
|
|
||||||
## `spec` (read-only), `is_expired()`, `send_deferred()`. Anything
|
|
||||||
## underscore-prefixed is internal wiring owned by the wrapper and may
|
|
||||||
## change between releases — addons must not reach into it. Keeping this
|
|
||||||
## surface narrow now is deliberate: it is much cheaper than breaking
|
|
||||||
## published addons later (#875 review).
|
|
||||||
|
|
||||||
var request_id: String = ""
|
|
||||||
var session_id: String = ""
|
|
||||||
var deadline_msec: int = 0
|
|
||||||
var spec: McpCustomToolSpec = null
|
|
||||||
|
|
||||||
## Internal — injected by custom_tool_wrapper via attach_locator(). Do not
|
|
||||||
## use from addon code; the capability methods below are the contract.
|
|
||||||
var _locator: McpServiceLocator = null
|
|
||||||
|
|
||||||
|
|
||||||
## Internal — called by the wrapper during ctx construction.
|
|
||||||
func attach_locator(locator: McpServiceLocator) -> void:
|
|
||||||
_locator = locator
|
|
||||||
|
|
||||||
|
|
||||||
func is_expired() -> bool:
|
|
||||||
if deadline_msec == 0:
|
|
||||||
return false
|
|
||||||
return Time.get_ticks_msec() > deadline_msec
|
|
||||||
|
|
||||||
|
|
||||||
func send_deferred(payload: Dictionary) -> void:
|
|
||||||
if _locator == null:
|
|
||||||
push_error("McpCallContext: cannot send deferred response, locator is null")
|
|
||||||
return
|
|
||||||
var conn := _locator.get_connection()
|
|
||||||
if conn == null:
|
|
||||||
push_error("McpCallContext: cannot send deferred response, connection is null")
|
|
||||||
return
|
|
||||||
conn.send_deferred_response(request_id, payload)
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
uid://bdgqe4gyian2o
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
@tool
|
|
||||||
class_name McpCustomToolSpec
|
|
||||||
extends RefCounted
|
|
||||||
|
|
||||||
## Descriptor for one custom tool registered by an addon. Mirrors the
|
|
||||||
## clients/_base.gd data-only descriptor pattern: fields are set by the
|
|
||||||
## caller, NO Callables (handler instance is materialized lazily by the
|
|
||||||
## dispatcher from script_path + method, so hot-reload can't SEGV a
|
|
||||||
## worker mid-call — same rationale as McpClient, issue #229).
|
|
||||||
##
|
|
||||||
## Construction: instantiate and set fields, then pass to
|
|
||||||
## McpToolRegistry.register(spec). Do NOT subclass — custom tools are
|
|
||||||
## dynamic (one addon may register several), not file-system-scanned
|
|
||||||
## like clients.
|
|
||||||
|
|
||||||
# --- identity ---
|
|
||||||
var name: String = "" ## tool name, e.g. "gdunit_run". Must not shadow a built-in.
|
|
||||||
var description: String = "" ## shown to the agent by the MCP server
|
|
||||||
var params_schema: Dictionary = {} ## JSON Schema; advertised to the agent for shaping calls. Params are forwarded UNVALIDATED — the handler must validate its own input.
|
|
||||||
|
|
||||||
# --- handler resolution (lazy materialization by dispatcher) ---
|
|
||||||
var script_path: String = "" ## "res://addons/.../handler.gd"; load()ed on first call
|
|
||||||
var method: StringName = &"" ## method on the handler; signature: (params: Dictionary, ctx: McpCallContext) -> Dictionary
|
|
||||||
|
|
||||||
# --- source identity (https://github.com/hi-godot/godot-ai/issues/781#issuecomment-5036376599 #8) ---
|
|
||||||
var source_path: String = "" ## "plugin.cfg" path: "res://addons/gdunit4_mcp/plugin.cfg". Same path = same addon (replace allowed); different path colliding = reject + dock warning. NOTE: self-declared — a collision/ownership policy for cooperating addons, NOT a security boundary (any in-editor code can claim any path).
|
|
||||||
var source: String = "" ## display: "gdunit4_mcp". If empty, registry reads [plugin] name from source_path.
|
|
||||||
|
|
||||||
# --- exposure ---
|
|
||||||
var promoted: bool = false ## opt-in: ask the server to ALSO register this tool as a first-class MCP tool ("custom_<name>") with params_schema attached, so agents get native schemas/validation instead of the custom_manage indirection. The server caps promoted count; overflow stays reachable via custom_manage.
|
|
||||||
|
|
||||||
# --- execution contract ---
|
|
||||||
var timeout_ms: int = 4500 ## deferred timeout → DEFERRED_TIMEOUT_MS_BY_COMMAND["custom:<name>"]
|
|
||||||
var deferred: bool = false ## true → handler may return DEFERRED_RESPONSE, push later via ctx.send_deferred(payload)
|
|
||||||
var requires_writable: bool = false ## https://github.com/hi-godot/godot-ai/issues/781#issuecomment-5036376599 #1: readiness gate. false → reads run any time; true → blocks during play/import
|
|
||||||
var undoable: bool = false ## https://github.com/hi-godot/godot-ai/issues/781#issuecomment-5036376599 #6: must be true to participate in undo=true batch_execute
|
|
||||||
|
|
||||||
# --- budgets (https://github.com/hi-godot/godot-ai/issues/781#issuecomment-5036376599 #5, enforced at register time) ---
|
|
||||||
const MAX_DESCRIPTION_CHARS := 600
|
|
||||||
const MAX_SCHEMA_BYTES := 8192
|
|
||||||
const MAX_TIMEOUT_MS := 120000
|
|
||||||
const MIN_TIMEOUT_MS := 500
|
|
||||||
|
|
||||||
|
|
||||||
## Returns an array of human-readable validation errors (empty = valid).
|
|
||||||
## Called by McpToolRegistry.register() and usable standalone for early
|
|
||||||
## feedback in addon _enter_tree before the registry is even live.
|
|
||||||
func validate() -> Array[String]:
|
|
||||||
var errors: Array[String] = []
|
|
||||||
if name.is_empty():
|
|
||||||
errors.append("name is empty\n")
|
|
||||||
if not name.is_valid_ascii_identifier():
|
|
||||||
errors.append("name '%s' is not a valid identifier\n" % name)
|
|
||||||
if description.length() > MAX_DESCRIPTION_CHARS:
|
|
||||||
errors.append("description exceeds %d chars\n" % MAX_DESCRIPTION_CHARS)
|
|
||||||
if JSON.stringify(params_schema).to_utf8_buffer().size() > MAX_SCHEMA_BYTES:
|
|
||||||
errors.append("params_schema exceeds %d bytes\n" % MAX_SCHEMA_BYTES)
|
|
||||||
if script_path.is_empty() or not ResourceLoader.exists(script_path):
|
|
||||||
errors.append("script_path '%s' does not exist" % script_path)
|
|
||||||
if method.is_empty():
|
|
||||||
errors.append("method is empty")
|
|
||||||
if timeout_ms < MIN_TIMEOUT_MS or timeout_ms > MAX_TIMEOUT_MS:
|
|
||||||
errors.append("timeout_ms %d out of range [%d, %d]" % [timeout_ms, MIN_TIMEOUT_MS, MAX_TIMEOUT_MS])
|
|
||||||
if source_path.is_empty() or not source_path.ends_with("plugin.cfg") or not FileAccess.file_exists(source_path):
|
|
||||||
errors.append("source_path '%s' must be an existing plugin.cfg path" % source_path)
|
|
||||||
return errors
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
uid://h7k8j5tpscwy
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
@tool
|
|
||||||
class_name McpServiceLocator
|
|
||||||
extends RefCounted
|
|
||||||
|
|
||||||
var _connection: McpConnection = null
|
|
||||||
var _log_buffer: McpLogBuffer = null
|
|
||||||
|
|
||||||
func setup(connection: McpConnection, log_buffer: McpLogBuffer) -> void:
|
|
||||||
_connection = connection
|
|
||||||
_log_buffer = log_buffer
|
|
||||||
|
|
||||||
func get_connection() -> McpConnection:
|
|
||||||
return _connection
|
|
||||||
|
|
||||||
func get_log_buffer() -> McpLogBuffer:
|
|
||||||
return _log_buffer
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
uid://dfvphmvo7en3i
|
|
||||||
@@ -1,277 +0,0 @@
|
|||||||
@tool
|
|
||||||
class_name McpToolRegistry
|
|
||||||
extends RefCounted
|
|
||||||
|
|
||||||
## Central registry for custom tools. INSTANCE SINGLETON (not static) —
|
|
||||||
## unlike McpClientRegistry which is read-only/loaded-once/query-only (static
|
|
||||||
## is fine there), this registry is MUTABLE, has LIFECYCLE (clear on teardown),
|
|
||||||
## must EMIT SIGNALS to multiple consumers, and needs DEPENDENCY INJECTION
|
|
||||||
## (dispatcher, locator). Static funcs can't emit instance signals in GDScript,
|
|
||||||
## and static vars create #46 freed-instance regressions across reloads.
|
|
||||||
##
|
|
||||||
## plugin.gd owns the instance (new + setup + clear). Addons access it via
|
|
||||||
## get_instance() so they don't hold a reference. Source identity is
|
|
||||||
## spec.source_path (explicit plugin.cfg path, https://github.com/hi-godot/godot-ai/issues/781#issuecomment-5036376599 #8) — same source_path
|
|
||||||
## = same addon (hot-reload replace); different source_path colliding on
|
|
||||||
## a name = reject + dock warning.
|
|
||||||
|
|
||||||
## singleton instance
|
|
||||||
static var _instance: McpToolRegistry = null
|
|
||||||
|
|
||||||
## Singleton accessor for addons. Returns null if Godot AI isn't loaded.
|
|
||||||
static func get_instance() -> McpToolRegistry:
|
|
||||||
return _instance
|
|
||||||
|
|
||||||
|
|
||||||
## Instance fields (NOT static — lifecycle tied to plugin.gd)
|
|
||||||
var _specs: Dictionary = {} # name -> McpCustomToolSpec
|
|
||||||
var _by_source_path: Dictionary = {} # source_path -> Array[McpCustomToolSpec] (batch unregister index)
|
|
||||||
var _dispatcher: McpDispatcher = null # injected by plugin.gd via setup()
|
|
||||||
var _locator: McpServiceLocator = null # injected by plugin.gd via setup()
|
|
||||||
var _ready: bool = false
|
|
||||||
var _promoted_cnt = 0
|
|
||||||
|
|
||||||
## Instance signals (LEGAL — instance funcs can emit these). Multiple consumers
|
|
||||||
## (plugin.gd, dock, addons) can connect.
|
|
||||||
signal tools_changed # triggers custom_tools_changed push to server
|
|
||||||
signal registry_ready # https://github.com/hi-godot/godot-ai/issues/781#issuecomment-5036376599 #8: emit after Godot AI reload so addons can re-register
|
|
||||||
|
|
||||||
|
|
||||||
## Called by plugin.gd in _enter_tree. Injects live dependencies and registers
|
|
||||||
## the singleton. Addons can now reach the registry via get_instance().
|
|
||||||
func setup(dispatcher: McpDispatcher, locator: McpServiceLocator) -> void:
|
|
||||||
_dispatcher = dispatcher
|
|
||||||
_locator = locator
|
|
||||||
_load_disabled_tools()
|
|
||||||
_instance = self
|
|
||||||
|
|
||||||
|
|
||||||
func register(spec: McpCustomToolSpec) -> bool:
|
|
||||||
if not _validate_one(spec):
|
|
||||||
return false
|
|
||||||
_commit_one(spec)
|
|
||||||
tools_changed.emit()
|
|
||||||
return true
|
|
||||||
|
|
||||||
|
|
||||||
## Atomic batch registration: validate ALL specs first (phase 1, zero
|
|
||||||
## mutation), then commit ALL (phase 2, cannot fail), then emit ONCE.
|
|
||||||
## A phase-1 failure leaves the registry AND dispatcher untouched — a
|
|
||||||
## per-spec register-then-bail loop would leave earlier specs committed
|
|
||||||
## and dispatchable while the server's tool list never hears about them.
|
|
||||||
func batch_register(specs: Array[McpCustomToolSpec]) -> bool:
|
|
||||||
if specs.is_empty():
|
|
||||||
return true
|
|
||||||
## Phase 1 — validate only. Also reject duplicate names WITHIN the
|
|
||||||
## batch: _validate_one checks against committed state (nothing from
|
|
||||||
## this batch is committed yet), and silently letting the last
|
|
||||||
## duplicate win would hide an addon packaging bug.
|
|
||||||
var batch_names := {}
|
|
||||||
for spec in specs:
|
|
||||||
if not _validate_one(spec):
|
|
||||||
return false
|
|
||||||
if batch_names.has(spec.name):
|
|
||||||
push_warning("McpToolRegistry: duplicate name '%s' within batch — rejected" % spec.name)
|
|
||||||
return false
|
|
||||||
batch_names[spec.name] = true
|
|
||||||
## Phase 2 — commit everything, emit once.
|
|
||||||
for spec in specs:
|
|
||||||
_commit_one(spec)
|
|
||||||
tools_changed.emit()
|
|
||||||
return true
|
|
||||||
|
|
||||||
|
|
||||||
## Phase 1 helper: validation + collision policy. NO registry/dispatcher
|
|
||||||
## mutation — the only side effect is filling the display-source fallback
|
|
||||||
## on the spec itself, which is harmless on reject.
|
|
||||||
func _validate_one(spec: McpCustomToolSpec) -> bool:
|
|
||||||
var errors := spec.validate()
|
|
||||||
if not errors.is_empty():
|
|
||||||
push_error("McpToolRegistry: rejecting spec '%s': %s" % [spec.name, ", ".join(errors)])
|
|
||||||
return false
|
|
||||||
## Fill display source from plugin.cfg if addon left it empty.
|
|
||||||
if spec.source.is_empty():
|
|
||||||
spec.source = _read_plugin_name(spec.source_path)
|
|
||||||
## reject-on-collision: same source_path (hot-reload) replaces, different rejects
|
|
||||||
var existing: McpCustomToolSpec= _specs.get(spec.name)
|
|
||||||
if existing != null and existing.source_path != spec.source_path:
|
|
||||||
push_warning("McpToolRegistry: name '%s' collision between %s and %s — rejected" % [spec.name, existing.source_path, spec.source_path])
|
|
||||||
return false
|
|
||||||
return true
|
|
||||||
|
|
||||||
|
|
||||||
## Phase 2 helper: commit one ALREADY-VALIDATED spec. Handles the
|
|
||||||
## hot-reload replace (same source_path — guaranteed by _validate_one).
|
|
||||||
## Never fails, never emits — callers own the tools_changed emit.
|
|
||||||
func _commit_one(spec: McpCustomToolSpec) -> void:
|
|
||||||
var existing: McpCustomToolSpec = _specs.get(spec.name)
|
|
||||||
if existing != null:
|
|
||||||
## Replace: purge the OLD registration from all four dispatcher
|
|
||||||
## dicts. Command name is "custom_tool:<name>" — passing the bare
|
|
||||||
## name would leave a materialized Callable in _handlers and the
|
|
||||||
## hot-reloaded spec would silently never take effect.
|
|
||||||
_dispatcher.unregister("custom_tool:" + existing.name, "custom:" + existing.name)
|
|
||||||
_erase_from_source_path_index_locked(existing)
|
|
||||||
_specs[spec.name] = spec
|
|
||||||
_by_source_path.get_or_add(spec.source_path, []).append(spec)
|
|
||||||
## Bridge to dispatcher lazy registration: register_lazy_handler + register_lazy.
|
|
||||||
## handler_key = "custom:<name>"; wrapper is CustomToolWrapper (https://github.com/hi-godot/godot-ai/issues/781#issuecomment-5036376599 #2).
|
|
||||||
|
|
||||||
var handler_key := "custom:" + spec.name
|
|
||||||
var wrapper_path := "res://addons/godot_ai/custom_tools/custom_tool_wrapper.gd"
|
|
||||||
_dispatcher.register_lazy_handler(handler_key, wrapper_path, [spec, _locator])
|
|
||||||
_dispatcher.register_lazy("custom_tool:" + spec.name, handler_key, &"invoke")
|
|
||||||
|
|
||||||
|
|
||||||
func unregister(name: String) -> bool:
|
|
||||||
var spec: McpCustomToolSpec = _specs.get(name)
|
|
||||||
if spec == null:
|
|
||||||
return false
|
|
||||||
if _dispatcher != null:
|
|
||||||
_dispatcher.unregister("custom_tool:" + name, "custom:" + name)
|
|
||||||
_specs.erase(name)
|
|
||||||
_erase_from_source_path_index_locked(spec)
|
|
||||||
tools_changed.emit()
|
|
||||||
return true
|
|
||||||
|
|
||||||
|
|
||||||
## https://github.com/hi-godot/godot-ai/issues/781#issuecomment-5036376599 #8: batch-unregister all tools from one addon. Pass the source_path
|
|
||||||
## (plugin.cfg path) — the registry removes every spec whose source_path matches.
|
|
||||||
func unregister_source(source_path: String) -> int:
|
|
||||||
var specs: Array = _by_source_path.get(source_path, [])
|
|
||||||
var count := specs.size()
|
|
||||||
for spec in specs.duplicate():
|
|
||||||
if _dispatcher != null:
|
|
||||||
_dispatcher.unregister("custom_tool:" + spec.name, "custom:" + spec.name)
|
|
||||||
_specs.erase(spec.name)
|
|
||||||
_by_source_path.erase(source_path)
|
|
||||||
if count > 0:
|
|
||||||
tools_changed.emit()
|
|
||||||
return count
|
|
||||||
|
|
||||||
|
|
||||||
func all() -> Array[McpCustomToolSpec]:
|
|
||||||
var out: Array[McpCustomToolSpec] = []
|
|
||||||
for spec in _specs.values():
|
|
||||||
out.append(spec)
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
## Enabled specs only — the catalog-push source. Disabled tools stay
|
|
||||||
## registered (the dock still lists them for re-enabling) but are never
|
|
||||||
## advertised to the server and are rejected at dispatch.
|
|
||||||
func enabled() -> Array[McpCustomToolSpec]:
|
|
||||||
var out: Array[McpCustomToolSpec] = []
|
|
||||||
for spec in _specs.values():
|
|
||||||
if is_tool_enabled(spec.name):
|
|
||||||
out.append(spec)
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
# --- per-tool enable state (dock UI) ---
|
|
||||||
## Persisted per-project via EditorSettings project metadata (the same
|
|
||||||
## store the editor uses for per-project UI state — no project.godot
|
|
||||||
## churn, no cross-project bleed). Applies LIVE: toggling re-emits
|
|
||||||
## tools_changed, which re-pushes the filtered catalog to the server.
|
|
||||||
|
|
||||||
const _META_SECTION := "godot_ai"
|
|
||||||
const _META_KEY_DISABLED := "disabled_custom_tools"
|
|
||||||
|
|
||||||
var _disabled_tools: Dictionary = {} # name -> true
|
|
||||||
|
|
||||||
|
|
||||||
func is_tool_enabled(name: String) -> bool:
|
|
||||||
return not _disabled_tools.has(name)
|
|
||||||
|
|
||||||
|
|
||||||
func set_tool_enabled(name: String, tool_enabled: bool) -> void:
|
|
||||||
if tool_enabled:
|
|
||||||
if not _disabled_tools.has(name):
|
|
||||||
return
|
|
||||||
_disabled_tools.erase(name)
|
|
||||||
else:
|
|
||||||
if _disabled_tools.has(name):
|
|
||||||
return
|
|
||||||
_disabled_tools[name] = true
|
|
||||||
_save_disabled_tools()
|
|
||||||
tools_changed.emit()
|
|
||||||
|
|
||||||
|
|
||||||
func _load_disabled_tools() -> void:
|
|
||||||
_disabled_tools.clear()
|
|
||||||
var es := _editor_settings()
|
|
||||||
if es == null:
|
|
||||||
return
|
|
||||||
var stored: Variant = es.get_project_metadata(_META_SECTION, _META_KEY_DISABLED, PackedStringArray())
|
|
||||||
for name in PackedStringArray(stored):
|
|
||||||
_disabled_tools[String(name)] = true
|
|
||||||
|
|
||||||
|
|
||||||
func _save_disabled_tools() -> void:
|
|
||||||
var es := _editor_settings()
|
|
||||||
if es == null:
|
|
||||||
return
|
|
||||||
var names := PackedStringArray()
|
|
||||||
for name in _disabled_tools.keys():
|
|
||||||
names.append(String(name))
|
|
||||||
es.set_project_metadata(_META_SECTION, _META_KEY_DISABLED, names)
|
|
||||||
|
|
||||||
|
|
||||||
static func _editor_settings() -> EditorSettings:
|
|
||||||
if not Engine.is_editor_hint():
|
|
||||||
return null
|
|
||||||
return EditorInterface.get_editor_settings()
|
|
||||||
|
|
||||||
|
|
||||||
func get_spec(name: String) -> McpCustomToolSpec:
|
|
||||||
var out: McpCustomToolSpec = _specs.get(name)
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
func is_ready() -> bool:
|
|
||||||
return _ready
|
|
||||||
|
|
||||||
|
|
||||||
## Called from plugin.gd teardown — wipes everything so stale specs/Callables
|
|
||||||
## don't survive a Godot AI reload (#46 regression class). Signals die with
|
|
||||||
## the instance (consumers disconnect via plugin.gd's own teardown).
|
|
||||||
func clear() -> void:
|
|
||||||
_specs.clear()
|
|
||||||
_by_source_path.clear()
|
|
||||||
_disabled_tools.clear() # in-memory only; persistence reloads on next setup()
|
|
||||||
_ready = false
|
|
||||||
_dispatcher = null
|
|
||||||
_locator = null
|
|
||||||
_instance = null
|
|
||||||
|
|
||||||
|
|
||||||
## Called from plugin.gd _enter_tree after setup(). Addons that missed
|
|
||||||
## _enter_tree during a Godot AI reload listen for registry_ready (via
|
|
||||||
## get_instance().registry_ready.connect(...)) and re-register deterministically.
|
|
||||||
func mark_ready() -> void:
|
|
||||||
_ready = true
|
|
||||||
registry_ready.emit()
|
|
||||||
|
|
||||||
|
|
||||||
# --- internal helpers ---
|
|
||||||
|
|
||||||
func _erase_from_source_path_index_locked(spec: McpCustomToolSpec) -> void:
|
|
||||||
var specs: Array = _by_source_path.get(spec.source_path, [])
|
|
||||||
specs.erase(spec)
|
|
||||||
if specs.is_empty():
|
|
||||||
_by_source_path.erase(spec.source_path)
|
|
||||||
|
|
||||||
|
|
||||||
## Read [plugin] name from a plugin.cfg for display purposes.
|
|
||||||
## Returns "" on any error — display source is best-effort.
|
|
||||||
static func _read_plugin_name(plugin_cfg_path: String) -> String:
|
|
||||||
var cfg := ConfigFile.new()
|
|
||||||
if cfg.load(plugin_cfg_path) != OK:
|
|
||||||
return ""
|
|
||||||
return cfg.get_value("plugin", "name", "")
|
|
||||||
|
|
||||||
static func _get_command_name(spec: McpCustomToolSpec) -> String:
|
|
||||||
return "custom_tool:" + spec.name
|
|
||||||
|
|
||||||
static func _get_handler_name(spec: McpCustomToolSpec) -> String:
|
|
||||||
return "custom:" + spec.name
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
uid://bq2btxtm8flhb
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1 +0,0 @@
|
|||||||
uid://bd1k63iye1bsl
|
|
||||||
@@ -1,529 +0,0 @@
|
|||||||
@tool
|
|
||||||
class_name McpDispatcher
|
|
||||||
extends RefCounted
|
|
||||||
|
|
||||||
## Routes incoming commands to handlers and manages the command queue
|
|
||||||
## with a per-frame time budget.
|
|
||||||
|
|
||||||
var _command_queue: Array[Dictionary] = []
|
|
||||||
var _handlers: Dictionary = {} # command_name -> Callable
|
|
||||||
## Lazy handler registration (#736): plugin.gd registers command names
|
|
||||||
## against a handler key plus a per-handler script path and constructor
|
|
||||||
## args, and the handler script is load()ed and instantiated at the FIRST
|
|
||||||
## dispatch of one of its commands. This keeps the ~30 handler scripts
|
|
||||||
## (and everything they preload) out of plugin.gd's eager compile
|
|
||||||
## closure, which stalled "Initializing plugins" on every editor boot.
|
|
||||||
## Materialized commands are promoted into `_handlers`, so the lazy dicts
|
|
||||||
## are only consulted on the first call per command.
|
|
||||||
var _lazy_handler_specs: Dictionary = {} # handler_key -> {path: String, args: Array}
|
|
||||||
var _lazy_handler_cache: Dictionary = {} # handler_key -> handler instance
|
|
||||||
var _lazy_commands: Dictionary = {} # command_name -> {handler: String, method: StringName}
|
|
||||||
var _pending_deferred: Dictionary = {} # request_id -> {command, started_ms, timeout_ms}
|
|
||||||
var _tick_active := false
|
|
||||||
var _log_buffer
|
|
||||||
var _surfaced_error_tracker
|
|
||||||
## The McpConnection whose pause_processing handlers flip around unsafe
|
|
||||||
## editor operations (#288 guard). Set by plugin.gd; untyped to honor the
|
|
||||||
## self-update field-storage policy. When set, _call_handler restores the
|
|
||||||
## pause depth a crashed handler left unbalanced (#712) — without this a
|
|
||||||
## single handler crash inside a pause window freezes the transport
|
|
||||||
## forever (pause has no watchdog or disconnect reset by design).
|
|
||||||
var pause_target
|
|
||||||
var mcp_logging := true
|
|
||||||
var deferred_timeout_overrides_ms: Dictionary = {}
|
|
||||||
|
|
||||||
const DEFAULT_DEFERRED_TIMEOUT_MS := 4500
|
|
||||||
const DEFERRED_TIMEOUT_MS_BY_COMMAND := {
|
|
||||||
"create_script": 4500,
|
|
||||||
## Fresh-`.gd` writes defer through the same import-settle window as
|
|
||||||
## create_script (#714) — same headroom over IMPORT_SETTLE_MAX_MSEC.
|
|
||||||
"write_file": 4500,
|
|
||||||
"stop_project": 4500,
|
|
||||||
"run_project": 6000,
|
|
||||||
"take_screenshot": 30000,
|
|
||||||
"check_client_status": 30000,
|
|
||||||
"game_eval": 15000,
|
|
||||||
"game_command": 15000,
|
|
||||||
## The editor-side runtime-control timer owns 5s; keep the dispatcher
|
|
||||||
## outside it so the actionable control result wins before DEFERRED_TIMEOUT.
|
|
||||||
"game_debug_control": 6500,
|
|
||||||
"scan_filesystem": 30000,
|
|
||||||
}
|
|
||||||
const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd")
|
|
||||||
const FuzzySuggestions := preload("res://addons/godot_ai/utils/fuzzy_suggestions.gd")
|
|
||||||
const PluginReload := preload("res://addons/godot_ai/utils/plugin_reload.gd")
|
|
||||||
|
|
||||||
|
|
||||||
func _init(log_buffer: McpLogBuffer, surfaced_error_tracker = null) -> void:
|
|
||||||
_log_buffer = log_buffer
|
|
||||||
_surfaced_error_tracker = surfaced_error_tracker
|
|
||||||
|
|
||||||
|
|
||||||
## Register a command handler. The callable receives (params: Dictionary) -> Dictionary.
|
|
||||||
func register(command_name: String, handler: Callable) -> void:
|
|
||||||
_handlers[command_name] = handler
|
|
||||||
|
|
||||||
|
|
||||||
## Declare a lazily-constructed handler (#736). `script_path` is load()ed
|
|
||||||
## and instantiated with `ctor_args` at the first dispatch of any command
|
|
||||||
## registered against `handler_key` via register_lazy. `ctor_args` may hold
|
|
||||||
## plugin-lifetime objects (connection, buffers, the dispatcher itself for
|
|
||||||
## batch); clear() drops them so teardown ordering matches the old eager
|
|
||||||
## registration (#46).
|
|
||||||
func register_lazy_handler(handler_key: String, script_path: String, ctor_args: Array) -> void:
|
|
||||||
_lazy_handler_specs[handler_key] = {"path": script_path, "args": ctor_args}
|
|
||||||
|
|
||||||
|
|
||||||
## Register a command that resolves to `method` on the lazily-constructed
|
|
||||||
## handler declared under `handler_key`. Same dispatch semantics as
|
|
||||||
## register(); only construction timing differs.
|
|
||||||
func register_lazy(command_name: String, handler_key: String, method: StringName) -> void:
|
|
||||||
_lazy_commands[command_name] = {"handler": handler_key, "method": method}
|
|
||||||
|
|
||||||
func unregister(command_name: String, handler_key: String) -> void:
|
|
||||||
_handlers.erase(command_name)
|
|
||||||
_lazy_commands.erase(command_name)
|
|
||||||
## Drop the lazy spec/cache only when no remaining command routes to
|
|
||||||
## this handler key. Custom tools use 1:1 keys ("custom:<name>") so this
|
|
||||||
## always erases for them, but built-in keys serve many commands —
|
|
||||||
## erasing the shared spec would strand the siblings on
|
|
||||||
## "No lazy handler declared" at dispatch time.
|
|
||||||
for entry in _lazy_commands.values():
|
|
||||||
if entry.get("handler", "") == handler_key:
|
|
||||||
return
|
|
||||||
_lazy_handler_cache.erase(handler_key)
|
|
||||||
_lazy_handler_specs.erase(handler_key)
|
|
||||||
|
|
||||||
## Synchronously realize handler-owned work before any add-on script can be
|
|
||||||
## replaced. A handler that cannot prove quiescence keeps the dispatcher live
|
|
||||||
## and makes the caller fail closed.
|
|
||||||
func quiesce_for_script_swap() -> Dictionary:
|
|
||||||
# Deferred timeouts are not proof that the underlying work has returned;
|
|
||||||
# the independent ledger also covers an old composition after a reload.
|
|
||||||
var work: Dictionary = preload("res://addons/godot_ai/utils/script_work.gd").quiescence()
|
|
||||||
if not bool(work.get("ok", false)):
|
|
||||||
return work
|
|
||||||
if not _pending_deferred.is_empty():
|
|
||||||
return {"ok": false, "error": "Wait for pending tool responses before updating."}
|
|
||||||
for handler_key in _lazy_handler_cache:
|
|
||||||
var instance: Variant = _lazy_handler_cache[handler_key]
|
|
||||||
if not is_instance_valid(instance) or not instance.has_method("quiesce_for_script_swap"):
|
|
||||||
return {
|
|
||||||
"ok": false,
|
|
||||||
"error": "Command handler '%s' cannot prove quiescence for script replacement." % handler_key,
|
|
||||||
}
|
|
||||||
var result: Variant = instance.call("quiesce_for_script_swap")
|
|
||||||
if not result is Dictionary or not bool(result.get("ok", false)):
|
|
||||||
return {
|
|
||||||
"ok": false,
|
|
||||||
"error": "Command handler '%s' could not quiesce for script replacement: %s" % [
|
|
||||||
handler_key, result,
|
|
||||||
],
|
|
||||||
}
|
|
||||||
return {"ok": true}
|
|
||||||
|
|
||||||
|
|
||||||
## Drop registered handlers, queued commands, and the log buffer ref so
|
|
||||||
## plugin.gd can release RefCounted handlers before Godot reloads their
|
|
||||||
## class_name scripts (issue #46). After a successful clear(), the dispatcher
|
|
||||||
## is inert. A failed quiesce leaves every reference intact.
|
|
||||||
func clear() -> Dictionary:
|
|
||||||
var quiesced := quiesce_for_script_swap()
|
|
||||||
if not bool(quiesced.get("ok", false)):
|
|
||||||
return quiesced
|
|
||||||
release_after_teardown()
|
|
||||||
return {"ok": true}
|
|
||||||
|
|
||||||
|
|
||||||
## Ordinary plugin teardown is not permission to replace scripts. The root
|
|
||||||
## first stops transport and joins its client/vision workers, then drops this
|
|
||||||
## graph even when a handler cannot certify hot script replacement. Requiring
|
|
||||||
## that stronger certificate here leaks the dispatcher <-> handler cycles at
|
|
||||||
## every editor exit. Hot-update callers must still use clear(), which refuses
|
|
||||||
## to release anything until every materialized handler proves quiescence.
|
|
||||||
func release_after_teardown() -> void:
|
|
||||||
_handlers.clear()
|
|
||||||
## Release lazily-constructed handler instances (and the ctor args that
|
|
||||||
## reference plugin-lifetime objects) at the same teardown point where
|
|
||||||
## eager handler Callables used to be dropped — their destructors must
|
|
||||||
## run while their scripts are still loaded (#46). This also breaks the
|
|
||||||
## dispatcher -> batch handler -> dispatcher ref cycle.
|
|
||||||
_lazy_handler_specs.clear()
|
|
||||||
_lazy_handler_cache.clear()
|
|
||||||
_lazy_commands.clear()
|
|
||||||
_command_queue.clear()
|
|
||||||
_pending_deferred.clear()
|
|
||||||
_log_buffer = null
|
|
||||||
_surfaced_error_tracker = null
|
|
||||||
pause_target = null
|
|
||||||
|
|
||||||
|
|
||||||
## Drop queued-but-unexecuted commands. Called by the connection on
|
|
||||||
## disconnect (#712): commands queued by the previous connection must not
|
|
||||||
## execute under the next one — the requester is gone, its in-flight
|
|
||||||
## futures were already failed server-side, and a mutation landing after
|
|
||||||
## reconnect is a surprise write nobody can correlate. Deferred bookkeeping
|
|
||||||
## has its own reset (clear_deferred_responses).
|
|
||||||
func clear_command_queue() -> void:
|
|
||||||
_command_queue.clear()
|
|
||||||
|
|
||||||
|
|
||||||
## Invoke a registered handler directly by name. Returns the handler's raw
|
|
||||||
## response dict (no request_id or status wrapping). Returns an UNKNOWN_COMMAND
|
|
||||||
## error dict if the command is not registered. Used by batch_execute.
|
|
||||||
func dispatch_direct(command: String, params: Dictionary) -> Dictionary:
|
|
||||||
if not has_command(command):
|
|
||||||
return ErrorCodes.make(ErrorCodes.UNKNOWN_COMMAND, "Unknown command: %s" % command)
|
|
||||||
## Strip the reserved deferred-reply key: only _dispatch may thread it.
|
|
||||||
## A caller-supplied _request_id (e.g. inside a batch_execute
|
|
||||||
## sub-command's params) would flip a deferred-capable handler into
|
|
||||||
## deferred mode against a request id the dispatcher never registered —
|
|
||||||
## the direct caller would get the DEFERRED sentinel instead of a result
|
|
||||||
## and the out-of-band reply would be dropped as expired.
|
|
||||||
if params.has("_request_id"):
|
|
||||||
params = params.duplicate()
|
|
||||||
params.erase("_request_id")
|
|
||||||
return _call_handler(command, params)
|
|
||||||
|
|
||||||
|
|
||||||
## Whether a command is registered (eagerly or lazily).
|
|
||||||
func has_command(command: String) -> bool:
|
|
||||||
return _handlers.has(command) or _lazy_commands.has(command)
|
|
||||||
|
|
||||||
|
|
||||||
## Rank registered commands by similarity to `cmd_name` and return the top `limit`
|
|
||||||
## matches. Uses Godot's built-in String.similarity() (0.0–1.0). Returns an empty
|
|
||||||
## array if no candidates clear the threshold. Used by batch_execute to surface
|
|
||||||
## "did you mean" suggestions when an unknown command is passed.
|
|
||||||
func suggest_similar(cmd_name: String, limit: int = 3, threshold: float = 0.5) -> Array[String]:
|
|
||||||
return FuzzySuggestions.rank(cmd_name, _registered_command_names(), limit, threshold, 0.0, 0.0)
|
|
||||||
|
|
||||||
|
|
||||||
## Union of eagerly-registered and lazily-registered command names.
|
|
||||||
## Materialized lazy commands live in both dicts, so dedupe via keys.
|
|
||||||
func _registered_command_names() -> Array:
|
|
||||||
var names: Dictionary = {}
|
|
||||||
for command in _handlers:
|
|
||||||
names[command] = true
|
|
||||||
for command in _lazy_commands:
|
|
||||||
names[command] = true
|
|
||||||
return names.keys()
|
|
||||||
|
|
||||||
|
|
||||||
## Enqueue a raw command dict received from the WebSocket.
|
|
||||||
func enqueue(cmd: Dictionary) -> void:
|
|
||||||
_command_queue.append(cmd)
|
|
||||||
|
|
||||||
|
|
||||||
func pending_deferred_count() -> int:
|
|
||||||
return _pending_deferred.size()
|
|
||||||
|
|
||||||
|
|
||||||
func clear_deferred_responses() -> void:
|
|
||||||
_pending_deferred.clear()
|
|
||||||
|
|
||||||
|
|
||||||
func has_pending_deferred_response(request_id: String) -> bool:
|
|
||||||
return request_id.is_empty() or _pending_deferred.has(request_id)
|
|
||||||
|
|
||||||
|
|
||||||
func complete_deferred_response(request_id: String) -> bool:
|
|
||||||
if request_id.is_empty():
|
|
||||||
return true
|
|
||||||
if not _pending_deferred.has(request_id):
|
|
||||||
return false
|
|
||||||
_pending_deferred.erase(request_id)
|
|
||||||
return true
|
|
||||||
|
|
||||||
|
|
||||||
## Handlers whose response flows out-of-band (e.g. debugger-channel capture)
|
|
||||||
## return this marker so tick() skips auto-sending a response. The handler is
|
|
||||||
## responsible for pushing the final response via McpConnection._send_json when
|
|
||||||
## the async operation completes. The dispatcher tracks the request_id and emits
|
|
||||||
## DEFERRED_TIMEOUT if the out-of-band response never arrives. The request_id is
|
|
||||||
## threaded through params under the "_request_id" key so the handler can
|
|
||||||
## correlate the response.
|
|
||||||
const DEFERRED_RESPONSE := {"_deferred": true}
|
|
||||||
|
|
||||||
|
|
||||||
## Process queued commands within a frame budget (milliseconds).
|
|
||||||
## Returns an array of response dictionaries to send back.
|
|
||||||
func tick(budget_ms: float = 4.0) -> Array[Dictionary]:
|
|
||||||
## Editor filesystem operations can pump another frame before the handler
|
|
||||||
## returns. That frame must not dispatch the still-queued command again.
|
|
||||||
if _tick_active or PluginReload.is_reload_pending():
|
|
||||||
return []
|
|
||||||
_tick_active = true
|
|
||||||
var responses: Array[Dictionary] = _collect_deferred_timeouts()
|
|
||||||
var start := Time.get_ticks_msec()
|
|
||||||
var idx := 0
|
|
||||||
|
|
||||||
while idx < _command_queue.size() and (Time.get_ticks_msec() - start) < budget_ms:
|
|
||||||
var cmd: Dictionary = _command_queue[idx]
|
|
||||||
var response := _dispatch(cmd)
|
|
||||||
if not response.get("_deferred", false):
|
|
||||||
responses.append(response)
|
|
||||||
idx += 1
|
|
||||||
if PluginReload.is_reload_pending():
|
|
||||||
break
|
|
||||||
|
|
||||||
if idx > 0:
|
|
||||||
_command_queue = _command_queue.slice(idx)
|
|
||||||
|
|
||||||
_tick_active = false
|
|
||||||
return responses
|
|
||||||
|
|
||||||
|
|
||||||
func _dispatch(cmd: Dictionary) -> Dictionary:
|
|
||||||
var request_id: String = cmd.get("request_id", "")
|
|
||||||
var command: String = cmd.get("command", "")
|
|
||||||
var raw_params: Dictionary = cmd.get("params", {})
|
|
||||||
## Duplicate so the internal _request_id key we thread through doesn't
|
|
||||||
## mutate the queued command's params (which is the same dict we're
|
|
||||||
## about to JSON-log below, and which later readers like batch_execute
|
|
||||||
## shouldn't see dispatcher-internal metadata from).
|
|
||||||
var params: Dictionary = raw_params.duplicate()
|
|
||||||
params["_request_id"] = request_id
|
|
||||||
|
|
||||||
if mcp_logging:
|
|
||||||
_log_buffer.log("[recv] %s(%s)" % [command, JSON.stringify(raw_params)])
|
|
||||||
|
|
||||||
var result: Dictionary
|
|
||||||
|
|
||||||
if has_command(command):
|
|
||||||
result = _call_handler(command, params)
|
|
||||||
else:
|
|
||||||
result = ErrorCodes.make(ErrorCodes.UNKNOWN_COMMAND, "Unknown command: %s" % command)
|
|
||||||
|
|
||||||
if result.get("_deferred", false):
|
|
||||||
## A handler may attach `_deferred_timeout_ms` to its deferred sentinel
|
|
||||||
## to claim a per-request budget larger than its command's shared entry
|
|
||||||
## (e.g. game_command's `input_sequence`, which steps frames well past
|
|
||||||
## the 15s that suits one-shot game ops). 0/absent falls back to the
|
|
||||||
## per-command table.
|
|
||||||
_register_deferred(request_id, command, int(result.get("_deferred_timeout_ms", 0)))
|
|
||||||
if mcp_logging:
|
|
||||||
_log_buffer.log("[defer] %s (request %s)" % [command, request_id])
|
|
||||||
return result
|
|
||||||
|
|
||||||
result["request_id"] = request_id
|
|
||||||
if not result.has("status"):
|
|
||||||
result["status"] = "ok"
|
|
||||||
## Stamp live editor readiness onto every command-response envelope so
|
|
||||||
## the server's `Session.readiness` cache self-heals on the very next
|
|
||||||
## tool call. Without this, a single dropped `readiness_changed` event
|
|
||||||
## (or a one-frame race around `pause_processing`) leaves the cache
|
|
||||||
## stuck at "playing" / "importing" long after the editor has settled,
|
|
||||||
## and write tools fail with EDITOR_NOT_READY against a writable editor.
|
|
||||||
## See connection.gd::send_deferred_response for the deferred-response
|
|
||||||
## counterpart, which stamps the same field.
|
|
||||||
result["readiness"] = McpConnection.get_readiness()
|
|
||||||
_stamp_error_watermark(result)
|
|
||||||
|
|
||||||
if mcp_logging:
|
|
||||||
var status: String = result.get("status", "ok")
|
|
||||||
if status == "ok":
|
|
||||||
_log_buffer.log("[send] %s -> ok" % command)
|
|
||||||
else:
|
|
||||||
var err_msg: String = result.get("error", {}).get("message", "unknown")
|
|
||||||
_log_buffer.log("[send] %s -> error: %s" % [command, err_msg])
|
|
||||||
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
## Truncate JSON-stringified args at this many chars when stuffing them into
|
|
||||||
## a malformed-result error message — large dicts shouldn't bloat the
|
|
||||||
## response, but a few hundred chars usually pinpoints which param was the
|
|
||||||
## wrong shape.
|
|
||||||
const _MALFORMED_ARGS_MAX := 400
|
|
||||||
|
|
||||||
|
|
||||||
func _call_handler(command: String, params: Dictionary) -> Dictionary:
|
|
||||||
if not _handlers.has(command):
|
|
||||||
var materialize_error := _materialize_lazy_command(command)
|
|
||||||
if not materialize_error.is_empty():
|
|
||||||
return materialize_error
|
|
||||||
## #712: a handler that crashes between pause_processing = true and its
|
|
||||||
## matching false leaves the pause depth unbalanced — GDScript swallows
|
|
||||||
## the error, the dispatcher reports "malformed result", and the
|
|
||||||
## transport stays paused FOREVER (no watchdog, no disconnect reset).
|
|
||||||
## Restore balance at this boundary: the depth a handler leaves behind
|
|
||||||
## must equal the depth it started with.
|
|
||||||
var pause_depth_before: int = pause_target.pause_depth() if pause_target != null else 0
|
|
||||||
var result: Dictionary = _handlers[command].call(params)
|
|
||||||
if pause_target != null and pause_target.pause_depth() > pause_depth_before:
|
|
||||||
var leaked: int = pause_target.pause_depth() - pause_depth_before
|
|
||||||
while pause_target.pause_depth() > pause_depth_before:
|
|
||||||
pause_target.resume()
|
|
||||||
if mcp_logging and _log_buffer != null:
|
|
||||||
_log_buffer.log(
|
|
||||||
"[error] %s leaked %d pause_processing level(s) — restored (handler crash?)"
|
|
||||||
% [command, leaked]
|
|
||||||
)
|
|
||||||
## Handlers must return {"data": ...} on success or {"error": ...} on failure.
|
|
||||||
## Anything else (null, empty, missing keys) means the handler crashed
|
|
||||||
## mid-call — GDScript swallows the error and returns an empty dict.
|
|
||||||
if result == null or not (result.has("data") or result.has("error") or result.has("_deferred")):
|
|
||||||
var safe_params := params.duplicate()
|
|
||||||
safe_params.erase("_request_id")
|
|
||||||
var args_json := JSON.stringify(safe_params)
|
|
||||||
if args_json.length() > _MALFORMED_ARGS_MAX:
|
|
||||||
args_json = args_json.substr(0, _MALFORMED_ARGS_MAX) + "..."
|
|
||||||
var backtrace := _capture_compact_backtrace()
|
|
||||||
var msg := (
|
|
||||||
"Handler '%s' returned malformed result — likely a runtime error in the handler "
|
|
||||||
+ "(e.g. param type mismatch). Args received: %s"
|
|
||||||
) % [command, args_json]
|
|
||||||
if not backtrace.is_empty():
|
|
||||||
msg += "\nBacktrace:\n%s" % backtrace
|
|
||||||
if mcp_logging and _log_buffer != null:
|
|
||||||
var compact_backtrace := backtrace.replace("\n", " | ")
|
|
||||||
_log_buffer.log(
|
|
||||||
"[error] %s -> malformed result; args=%s; backtrace=%s"
|
|
||||||
% [command, args_json, compact_backtrace]
|
|
||||||
)
|
|
||||||
return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, msg)
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
## Resolve a lazily-registered command into a live Callable in `_handlers`.
|
|
||||||
## Loads + constructs the owning handler on first use (cached per handler
|
|
||||||
## key, so one load() covers every command the handler serves). Returns an
|
|
||||||
## empty dict on success or a protocol error dict on failure — a missing
|
|
||||||
## script or method is a plugin packaging bug and must surface loudly, not
|
|
||||||
## as a silent no-op.
|
|
||||||
func _materialize_lazy_command(command: String) -> Dictionary:
|
|
||||||
var command_spec: Dictionary = _lazy_commands.get(command, {})
|
|
||||||
if command_spec.is_empty():
|
|
||||||
return ErrorCodes.make(ErrorCodes.UNKNOWN_COMMAND, "Unknown command: %s" % command)
|
|
||||||
var handler_key: String = command_spec["handler"]
|
|
||||||
var instance = _lazy_handler_cache.get(handler_key)
|
|
||||||
if instance == null:
|
|
||||||
var handler_spec: Dictionary = _lazy_handler_specs.get(handler_key, {})
|
|
||||||
if handler_spec.is_empty():
|
|
||||||
return ErrorCodes.make(
|
|
||||||
ErrorCodes.INTERNAL_ERROR,
|
|
||||||
"No lazy handler '%s' declared for command '%s'" % [handler_key, command]
|
|
||||||
)
|
|
||||||
## Existence-check first so a missing script surfaces as one clean
|
|
||||||
## protocol error instead of also spraying engine load errors.
|
|
||||||
if not ResourceLoader.exists(handler_spec["path"]):
|
|
||||||
return ErrorCodes.make(
|
|
||||||
ErrorCodes.INTERNAL_ERROR,
|
|
||||||
"Missing handler script '%s' for command '%s'" % [handler_spec["path"], command]
|
|
||||||
)
|
|
||||||
var script := load(handler_spec["path"]) as GDScript
|
|
||||||
if script == null:
|
|
||||||
return ErrorCodes.make(
|
|
||||||
ErrorCodes.INTERNAL_ERROR,
|
|
||||||
"Failed to load handler script '%s' for command '%s'" % [handler_spec["path"], command]
|
|
||||||
)
|
|
||||||
instance = script.callv("new", handler_spec["args"])
|
|
||||||
if instance == null:
|
|
||||||
return ErrorCodes.make(
|
|
||||||
ErrorCodes.INTERNAL_ERROR,
|
|
||||||
"Failed to construct handler '%s' for command '%s'" % [handler_key, command]
|
|
||||||
)
|
|
||||||
_lazy_handler_cache[handler_key] = instance
|
|
||||||
var method: StringName = command_spec["method"]
|
|
||||||
if not instance.has_method(method):
|
|
||||||
return ErrorCodes.make(
|
|
||||||
ErrorCodes.INTERNAL_ERROR,
|
|
||||||
"Handler '%s' has no method '%s' for command '%s'" % [handler_key, method, command]
|
|
||||||
)
|
|
||||||
_handlers[command] = Callable(instance, method)
|
|
||||||
return {}
|
|
||||||
|
|
||||||
|
|
||||||
func _register_deferred(request_id: String, command: String, timeout_override_ms: int = 0) -> void:
|
|
||||||
if request_id.is_empty():
|
|
||||||
return
|
|
||||||
## A positive per-request override wins over the per-command table so a
|
|
||||||
## single deferred call can claim more headroom without globally widening
|
|
||||||
## the command's budget (see _dispatch: input_sequence needs ~30s, but the
|
|
||||||
## other game_command ops must keep their tight 15s).
|
|
||||||
var timeout_ms: int = (
|
|
||||||
timeout_override_ms if timeout_override_ms > 0
|
|
||||||
else _deferred_timeout_ms_for_command(command)
|
|
||||||
)
|
|
||||||
_pending_deferred[request_id] = {
|
|
||||||
"command": command,
|
|
||||||
"started_ms": Time.get_ticks_msec(),
|
|
||||||
"timeout_ms": timeout_ms,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
func _deferred_timeout_ms_for_command(command: String) -> int:
|
|
||||||
if deferred_timeout_overrides_ms.has(command):
|
|
||||||
return int(deferred_timeout_overrides_ms[command])
|
|
||||||
return int(DEFERRED_TIMEOUT_MS_BY_COMMAND.get(command, DEFAULT_DEFERRED_TIMEOUT_MS))
|
|
||||||
|
|
||||||
|
|
||||||
func _collect_deferred_timeouts() -> Array[Dictionary]:
|
|
||||||
var responses: Array[Dictionary] = []
|
|
||||||
if _pending_deferred.is_empty():
|
|
||||||
return responses
|
|
||||||
var now := Time.get_ticks_msec()
|
|
||||||
for request_id in _pending_deferred.keys():
|
|
||||||
var entry: Dictionary = _pending_deferred[request_id]
|
|
||||||
var timeout_ms: int = entry.get("timeout_ms", DEFAULT_DEFERRED_TIMEOUT_MS)
|
|
||||||
var elapsed_ms := now - int(entry.get("started_ms", now))
|
|
||||||
if elapsed_ms < timeout_ms:
|
|
||||||
continue
|
|
||||||
_pending_deferred.erase(request_id)
|
|
||||||
var command: String = entry.get("command", "")
|
|
||||||
var response := ErrorCodes.make(
|
|
||||||
ErrorCodes.DEFERRED_TIMEOUT,
|
|
||||||
"Deferred response for '%s' timed out after %dms" % [command, timeout_ms]
|
|
||||||
)
|
|
||||||
response["request_id"] = request_id
|
|
||||||
response["error"]["data"] = {
|
|
||||||
"command": command,
|
|
||||||
"elapsed_ms": elapsed_ms,
|
|
||||||
"timeout_ms": timeout_ms,
|
|
||||||
}
|
|
||||||
## Same envelope-level readiness stamp as `_dispatch` — keep the
|
|
||||||
## self-heal channel symmetric across every reply shape the
|
|
||||||
## dispatcher emits so the server cache can't drift just because
|
|
||||||
## the editor happened to time out a deferred command.
|
|
||||||
response["readiness"] = McpConnection.get_readiness()
|
|
||||||
_stamp_error_watermark(response)
|
|
||||||
responses.append(response)
|
|
||||||
if mcp_logging and _log_buffer != null:
|
|
||||||
_log_buffer.log("[defer] %s (request %s) -> timeout" % [command, request_id])
|
|
||||||
return responses
|
|
||||||
|
|
||||||
|
|
||||||
func _stamp_error_watermark(response: Dictionary) -> void:
|
|
||||||
McpSurfacedErrorTracker.stamp_watermark(response, _surfaced_error_tracker)
|
|
||||||
|
|
||||||
|
|
||||||
static func _capture_compact_backtrace(max_frames: int = 8) -> String:
|
|
||||||
var traces: Array = Engine.capture_script_backtraces(false)
|
|
||||||
for bt in traces:
|
|
||||||
if bt != null and not bt.is_empty():
|
|
||||||
return _trim_backtrace_string(bt.format(0, 2), max_frames)
|
|
||||||
return _format_stack_frames(get_stack(), max_frames)
|
|
||||||
|
|
||||||
|
|
||||||
static func _trim_backtrace_string(text: String, max_frames: int) -> String:
|
|
||||||
var lines := text.strip_edges().split("\n")
|
|
||||||
var kept: Array[String] = []
|
|
||||||
for i in range(min(lines.size(), max_frames)):
|
|
||||||
kept.append(lines[i].strip_edges())
|
|
||||||
return "\n".join(kept)
|
|
||||||
|
|
||||||
|
|
||||||
static func _format_stack_frames(frames: Array, max_frames: int) -> String:
|
|
||||||
var lines: Array[String] = []
|
|
||||||
for i in range(min(frames.size(), max_frames)):
|
|
||||||
var frame: Dictionary = frames[i]
|
|
||||||
lines.append(
|
|
||||||
"%s:%s in %s"
|
|
||||||
% [
|
|
||||||
frame.get("source", "?"),
|
|
||||||
frame.get("line", 0),
|
|
||||||
frame.get("function", "?"),
|
|
||||||
]
|
|
||||||
)
|
|
||||||
return "\n".join(lines)
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
uid://ctldk7ivsoo3i
|
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
@tool
|
|
||||||
extends VBoxContainer
|
|
||||||
|
|
||||||
## Dock subpanel — renders the MCP request/response log buffer. Owns its own
|
|
||||||
## UI subtree, the line-count cursor, and the display-visibility toggle. It
|
|
||||||
## receives copied `{sequence, reset, lines}` values; neither this view nor its
|
|
||||||
## parent Dock retains the root-owned log buffer.
|
|
||||||
##
|
|
||||||
## Extracted from mcp_dock.gd as part of audit-v2 #360 — see the comment at
|
|
||||||
## the top of mcp_dock.gd for the broader extraction story.
|
|
||||||
|
|
||||||
signal logging_enabled_changed(enabled: bool)
|
|
||||||
|
|
||||||
const Dock := preload("res://addons/godot_ai/mcp_dock.gd")
|
|
||||||
const Settings := preload("res://addons/godot_ai/utils/settings.gd")
|
|
||||||
|
|
||||||
var _log_display: RichTextLabel
|
|
||||||
var _log_toggle: CheckButton
|
|
||||||
## Last `McpLogBuffer.total_logged()` value painted into the display. Tracking
|
|
||||||
## the buffer's monotonic sequence (rather than its bounded `total_count()`)
|
|
||||||
## keeps the viewer painting once the ring fills — a size-based cursor would
|
|
||||||
## freeze at MAX_LINES on every subsequent append. See PR #392 for the bug.
|
|
||||||
var _last_log_seq := 0
|
|
||||||
|
|
||||||
|
|
||||||
## Build the UI synchronously here so callers (and detached-tree tests that
|
|
||||||
## instantiate the dock with `McpDockScript.new()` and never enter the tree)
|
|
||||||
## can interact with the panel's controls right after `setup()`. Mirrors the
|
|
||||||
## pre-extraction inline-build behavior that test_dock.gd relies on.
|
|
||||||
##
|
|
||||||
## Idempotent: `_log_display == null` covers an unlikely double-`setup()` call
|
|
||||||
## without rebuilding (which would orphan the prior controls).
|
|
||||||
func setup() -> void:
|
|
||||||
if _log_display == null:
|
|
||||||
_build_ui()
|
|
||||||
|
|
||||||
|
|
||||||
func _build_ui() -> void:
|
|
||||||
add_child(HSeparator.new())
|
|
||||||
|
|
||||||
var log_header_row := HBoxContainer.new()
|
|
||||||
var log_header := Dock._make_header("MCP Log")
|
|
||||||
log_header.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
|
||||||
log_header_row.add_child(log_header)
|
|
||||||
|
|
||||||
_log_toggle = CheckButton.new()
|
|
||||||
_log_toggle.text = "Log"
|
|
||||||
## Restore the persisted choice — a hardcoded `true` here meant the
|
|
||||||
## toggle reset to noisy on every editor restart (#626).
|
|
||||||
_log_toggle.button_pressed = Settings.mcp_logging_enabled()
|
|
||||||
_log_toggle.toggled.connect(_on_log_toggled)
|
|
||||||
log_header_row.add_child(_log_toggle)
|
|
||||||
|
|
||||||
add_child(log_header_row)
|
|
||||||
|
|
||||||
_log_display = RichTextLabel.new()
|
|
||||||
_log_display.custom_minimum_size = Vector2(0, 80)
|
|
||||||
_log_display.scroll_following = true
|
|
||||||
_log_display.bbcode_enabled = false
|
|
||||||
_log_display.selection_enabled = true
|
|
||||||
_log_display.visible = _log_toggle.button_pressed
|
|
||||||
add_child(_log_display)
|
|
||||||
|
|
||||||
|
|
||||||
func sequence() -> int:
|
|
||||||
return _last_log_seq
|
|
||||||
|
|
||||||
|
|
||||||
func present_snapshot(snapshot: Dictionary) -> void:
|
|
||||||
if _log_display == null:
|
|
||||||
return
|
|
||||||
var seq := int(snapshot.get("sequence", _last_log_seq))
|
|
||||||
if bool(snapshot.get("reset", false)) or seq < _last_log_seq:
|
|
||||||
_log_display.clear()
|
|
||||||
_last_log_seq = 0
|
|
||||||
var lines: Array[String] = []
|
|
||||||
lines.assign(snapshot.get("lines", []))
|
|
||||||
for line in lines:
|
|
||||||
_log_display.add_text(line + "\n")
|
|
||||||
_last_log_seq = seq
|
|
||||||
|
|
||||||
|
|
||||||
func _on_log_toggled(enabled: bool) -> void:
|
|
||||||
Settings.set_mcp_logging_enabled(enabled)
|
|
||||||
_log_display.visible = enabled
|
|
||||||
logging_enabled_changed.emit(enabled)
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
uid://cr5nbnd6vj3b8
|
|
||||||
@@ -1,106 +0,0 @@
|
|||||||
@tool
|
|
||||||
extends VBoxContainer
|
|
||||||
|
|
||||||
## Dock subpanel — port-change escape hatch surfaced inside the spawn-failure
|
|
||||||
## crash panel when a port is contested or an incompatible server cannot be reclaimed.
|
|
||||||
##
|
|
||||||
## It moves BOTH ports. A godot-ai server binds its HTTP and WebSocket ports
|
|
||||||
## together, so moving only the HTTP port onto a free number lands the next
|
|
||||||
## launch on a WebSocket port the previous server still holds, and the server
|
|
||||||
## dies at preflight ("WebSocket port 19630 is already in use"). Emits
|
|
||||||
## `port_apply_requested(new_http_port, new_ws_port)` after range-validation;
|
|
||||||
## the dock writes the EditorSettings and reloads the plugin.
|
|
||||||
##
|
|
||||||
## Extracted from mcp_dock.gd as part of audit-v2 #360 — see the comment at
|
|
||||||
## the top of mcp_dock.gd for the broader extraction story.
|
|
||||||
|
|
||||||
const ClientConfigurator := preload("res://addons/godot_ai/client_configurator.gd")
|
|
||||||
const PortResolver := preload("res://addons/godot_ai/utils/port_resolver.gd")
|
|
||||||
|
|
||||||
signal port_apply_requested(new_http_port: int, new_ws_port: int)
|
|
||||||
|
|
||||||
var _spinbox: SpinBox
|
|
||||||
var _ws_spinbox: SpinBox
|
|
||||||
## Bind probe used when seeding; tests substitute a deterministic one.
|
|
||||||
var port_in_use_probe: Callable = Callable(PortResolver, "is_port_in_use")
|
|
||||||
|
|
||||||
|
|
||||||
## Build the UI synchronously here so callers (and detached-tree tests that
|
|
||||||
## instantiate the dock with `McpDockScript.new()` and never enter the tree)
|
|
||||||
## can interact with the panel's controls right after `setup()`. Mirrors the
|
|
||||||
## pre-extraction inline-build behavior that test_dock.gd relies on.
|
|
||||||
##
|
|
||||||
## Idempotent: `_spinbox == null` covers an unlikely double-`setup()` call
|
|
||||||
## without rebuilding (which would orphan the prior controls).
|
|
||||||
func setup() -> void:
|
|
||||||
if _spinbox == null:
|
|
||||||
_build_ui()
|
|
||||||
|
|
||||||
|
|
||||||
func _build_ui() -> void:
|
|
||||||
add_theme_constant_override("separation", 4)
|
|
||||||
visible = false
|
|
||||||
|
|
||||||
var picker_row := HBoxContainer.new()
|
|
||||||
picker_row.add_theme_constant_override("separation", 6)
|
|
||||||
|
|
||||||
_spinbox = _port_spinbox(ClientConfigurator.http_port(), "Effective HTTP port")
|
|
||||||
picker_row.add_child(_spinbox)
|
|
||||||
_ws_spinbox = _port_spinbox(ClientConfigurator.ws_port(), "Effective WebSocket port")
|
|
||||||
picker_row.add_child(_ws_spinbox)
|
|
||||||
|
|
||||||
var apply_btn := Button.new()
|
|
||||||
apply_btn.text = "Apply + Reload"
|
|
||||||
apply_btn.tooltip_text = (
|
|
||||||
"Saves the effective HTTP and WebSocket ports to Editor Settings and reloads"
|
|
||||||
+ " the plugin so the server spawns on the new ports. Reconfigure your AI"
|
|
||||||
+ " clients afterwards so their bridges use the new ports."
|
|
||||||
)
|
|
||||||
apply_btn.pressed.connect(_on_apply_pressed)
|
|
||||||
picker_row.add_child(apply_btn)
|
|
||||||
|
|
||||||
add_child(picker_row)
|
|
||||||
|
|
||||||
|
|
||||||
static func _port_spinbox(value: int, tooltip: String) -> SpinBox:
|
|
||||||
var spinbox := SpinBox.new()
|
|
||||||
spinbox.min_value = ClientConfigurator.MIN_PORT
|
|
||||||
spinbox.max_value = ClientConfigurator.MAX_PORT
|
|
||||||
spinbox.step = 1
|
|
||||||
spinbox.value = value
|
|
||||||
spinbox.tooltip_text = tooltip
|
|
||||||
spinbox.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
|
||||||
return spinbox
|
|
||||||
|
|
||||||
|
|
||||||
## Seed both boxes every time the panel surfaces, so a stale value from a
|
|
||||||
## previous spawn-failure round cannot carry over. The diagnosed
|
|
||||||
## `conflict_port` and any port that is simply in use get a fresh free
|
|
||||||
## suggestion; a port that is free keeps its value, so clients whose entries
|
|
||||||
## carry it keep working. Note that this OVERWRITES unsaved user input — fine
|
|
||||||
## in practice because the dock only calls this on `server_status`
|
|
||||||
## transitions (`if server_status == _last_server_status: return`).
|
|
||||||
func seed_suggested_ports(conflict_port := 0) -> void:
|
|
||||||
if _spinbox == null:
|
|
||||||
return
|
|
||||||
var http := ClientConfigurator.http_port()
|
|
||||||
var ws := ClientConfigurator.ws_port()
|
|
||||||
if conflict_port == http or bool(port_in_use_probe.call(http)):
|
|
||||||
http = ClientConfigurator.suggest_free_port(http + 1)
|
|
||||||
if conflict_port == ws or ws == http or bool(port_in_use_probe.call(ws)):
|
|
||||||
ws = ClientConfigurator.suggest_free_port(ws + 1)
|
|
||||||
if ws == http:
|
|
||||||
ws = ClientConfigurator.suggest_free_port(ws + 1)
|
|
||||||
_spinbox.value = http
|
|
||||||
_ws_spinbox.value = ws
|
|
||||||
|
|
||||||
|
|
||||||
func _on_apply_pressed() -> void:
|
|
||||||
var new_http: int = int(_spinbox.value)
|
|
||||||
var new_ws: int = int(_ws_spinbox.value)
|
|
||||||
for port in [new_http, new_ws]:
|
|
||||||
if port < ClientConfigurator.MIN_PORT or port > ClientConfigurator.MAX_PORT:
|
|
||||||
return
|
|
||||||
if new_http == new_ws:
|
|
||||||
return
|
|
||||||
port_apply_requested.emit(new_http, new_ws)
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
uid://hlggbo1q65eq
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
@tool
|
|
||||||
extends EditorExportPlugin
|
|
||||||
|
|
||||||
## Strips the MCP game-helper autoload from exported builds (#740).
|
|
||||||
##
|
|
||||||
## plugin.gd writes `autoload/_mcp_game_helper` into project.godot so the
|
|
||||||
## editor-spawned game process loads the helper. Exports bake project
|
|
||||||
## settings into the pack's project.binary, so without this plugin every
|
|
||||||
## export ships the autoload — and users who exclude addons/godot_ai/**
|
|
||||||
## in their export preset get three "Failed to instantiate an autoload"
|
|
||||||
## errors at game start. Even when the files ARE shipped, the helper is
|
|
||||||
## editor-tooling: no exported build should carry it.
|
|
||||||
##
|
|
||||||
## Strip mechanics: clear the in-memory ProjectSettings entry in
|
|
||||||
## _export_begin, restore it in _export_end. The export pipeline reads
|
|
||||||
## the live ProjectSettings when it bakes project.binary, which happens
|
|
||||||
## after _export_begin — verified end-to-end by
|
|
||||||
## script/ci-export-strip-smoke, which exports a real pack and asserts
|
|
||||||
## the autoload is absent inside it. We never call ProjectSettings.save()
|
|
||||||
## while stripped, so project.godot on disk keeps the autoload
|
|
||||||
## throughout; only the export snapshot loses it.
|
|
||||||
##
|
|
||||||
## Failure containment: if an export aborts so hard that _export_end
|
|
||||||
## never fires, the damage is bounded to the editor's in-memory settings
|
|
||||||
## — the running game reads project.godot from disk, and plugin.gd's
|
|
||||||
## _ensure_game_helper_autoload() re-asserts the entry on the next
|
|
||||||
## plugin enable / editor launch.
|
|
||||||
|
|
||||||
## Must equal "autoload/" + plugin.gd's GAME_HELPER_AUTOLOAD_NAME.
|
|
||||||
## Duplicated (not preloaded from plugin.gd) to avoid a cyclic preload —
|
|
||||||
## plugin.gd preloads this script. The pairing is locked by
|
|
||||||
## test_export_strip.gd's constants-contract test.
|
|
||||||
const AUTOLOAD_KEY := "autoload/_mcp_game_helper"
|
|
||||||
|
|
||||||
var _saved_value: Variant = null
|
|
||||||
var _stripped := false
|
|
||||||
|
|
||||||
|
|
||||||
func _get_name() -> String:
|
|
||||||
return "GodotAIStripAutoload"
|
|
||||||
|
|
||||||
|
|
||||||
func _export_begin(_features: PackedStringArray, _is_debug: bool, _path: String, _flags: int) -> void:
|
|
||||||
## `_stripped` guard: if a previous export died before _export_end,
|
|
||||||
## don't overwrite the genuinely-saved value with the already-cleared
|
|
||||||
## state — restore semantics stay anchored to the original value.
|
|
||||||
if _stripped:
|
|
||||||
return
|
|
||||||
if not ProjectSettings.has_setting(AUTOLOAD_KEY):
|
|
||||||
return
|
|
||||||
_saved_value = ProjectSettings.get_setting(AUTOLOAD_KEY)
|
|
||||||
## Setting a project setting to null erases it.
|
|
||||||
ProjectSettings.set_setting(AUTOLOAD_KEY, null)
|
|
||||||
_stripped = true
|
|
||||||
print("MCP | export: stripping %s from the exported pack (restored in the editor after export)" % AUTOLOAD_KEY)
|
|
||||||
|
|
||||||
|
|
||||||
func _export_end() -> void:
|
|
||||||
if not _stripped:
|
|
||||||
return
|
|
||||||
ProjectSettings.set_setting(AUTOLOAD_KEY, _saved_value)
|
|
||||||
## Mirror _ensure_game_helper_autoload()'s registration shape so the
|
|
||||||
## restored entry is indistinguishable from the original: initial
|
|
||||||
## value "" keeps project.godot diff-clean, basic keeps it visible in
|
|
||||||
## the non-advanced settings view.
|
|
||||||
ProjectSettings.set_initial_value(AUTOLOAD_KEY, "")
|
|
||||||
ProjectSettings.set_as_basic(AUTOLOAD_KEY, true)
|
|
||||||
_saved_value = null
|
|
||||||
_stripped = false
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
uid://np0cp6fwpim7
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
@tool
|
|
||||||
class_name McpNodeValidator
|
|
||||||
extends RefCounted
|
|
||||||
|
|
||||||
## Shared resolve-or-error helper that subsumes the 38+ sites where
|
|
||||||
## handlers each rolled their own "is the editor ready, does the path
|
|
||||||
## resolve, otherwise return EDITOR_NOT_READY / NODE_NOT_FOUND" guard.
|
|
||||||
##
|
|
||||||
## audit-v2 #20 (issue #364). Uses the audit-v2 #21 (issue #365) error
|
|
||||||
## vocabulary.
|
|
||||||
|
|
||||||
## Local const names alias the preloaded scripts. The naming choice is
|
|
||||||
## stylistic, not an upgrade-safety boundary: bare `McpErrorCodes.MEMBER`
|
|
||||||
## and `ErrorCodes.MEMBER` both depend on the Script object Godot has for
|
|
||||||
## `error_codes.gd`. The transient #398 parse errors were caused by the
|
|
||||||
## old runner scanning a mixed old/new plugin snapshot and seeing stale
|
|
||||||
## Script-object content; the runner now writes one v(N+1) snapshot before
|
|
||||||
## its scan.
|
|
||||||
const ScenePath := preload("res://addons/godot_ai/utils/scene_path.gd")
|
|
||||||
const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd")
|
|
||||||
|
|
||||||
|
|
||||||
## Resolve a scene-relative path to the live Node, or return a structured
|
|
||||||
## error dict.
|
|
||||||
##
|
|
||||||
## Success shape: `{"node": Node, "scene_root": Node, "path": String}`.
|
|
||||||
## Error shape: matches `ErrorCodes.make(...)` so callers can
|
|
||||||
## `return resolved` to propagate.
|
|
||||||
##
|
|
||||||
## Errors (in order checked):
|
|
||||||
## - `MISSING_REQUIRED_PARAM`: `node_path` is empty
|
|
||||||
## - `EDITOR_NOT_READY`: no scene open
|
|
||||||
## - `EDITED_SCENE_MISMATCH`: caller pinned `scene_file` and the open
|
|
||||||
## scene's path doesn't match
|
|
||||||
## - `NODE_NOT_FOUND`: `node_path` doesn't resolve under the scene root
|
|
||||||
##
|
|
||||||
## `param_name` is the agent-facing name reported in the
|
|
||||||
## `MISSING_REQUIRED_PARAM` message — handlers pass "node_path",
|
|
||||||
## "player_path", "target_path", etc. so the error reads like the
|
|
||||||
## hand-written messages it replaces.
|
|
||||||
static func resolve_or_error(
|
|
||||||
node_path: String,
|
|
||||||
param_name: String = "path",
|
|
||||||
scene_file: String = "",
|
|
||||||
) -> Dictionary:
|
|
||||||
if node_path.is_empty():
|
|
||||||
return ErrorCodes.make(
|
|
||||||
ErrorCodes.MISSING_REQUIRED_PARAM,
|
|
||||||
"Missing required param: %s" % param_name,
|
|
||||||
)
|
|
||||||
var scene_check := ScenePath.require_edited_scene(scene_file)
|
|
||||||
if scene_check.has("error"):
|
|
||||||
return scene_check
|
|
||||||
var scene_root: Node = scene_check.node
|
|
||||||
var node := ScenePath.resolve(node_path, scene_root)
|
|
||||||
if node == null:
|
|
||||||
return ErrorCodes.make(
|
|
||||||
ErrorCodes.NODE_NOT_FOUND,
|
|
||||||
ScenePath.format_node_error(node_path, scene_root),
|
|
||||||
)
|
|
||||||
return {"node": node, "scene_root": scene_root, "path": node_path}
|
|
||||||
|
|
||||||
|
|
||||||
## When the caller needs the scene root but no specific node yet — e.g.
|
|
||||||
## handlers that walk children or filter by group. Returns either
|
|
||||||
## `{"scene_root": Node}` or an `ErrorCodes.make(...)` error dict.
|
|
||||||
static func require_scene_or_error(scene_file: String = "") -> Dictionary:
|
|
||||||
var scene_check := ScenePath.require_edited_scene(scene_file)
|
|
||||||
if scene_check.has("error"):
|
|
||||||
return scene_check
|
|
||||||
return {"scene_root": scene_check.node}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
uid://dn75jifad0ghx
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user