godot ai插件
This commit is contained in:
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025 Godot AI contributors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,54 @@
|
||||
# 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
@@ -0,0 +1 @@
|
||||
uid://1kiy8hqyymyj
|
||||
@@ -0,0 +1,251 @@
|
||||
@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()
|
||||
@@ -0,0 +1 @@
|
||||
uid://6fkb5uau0r4h
|
||||
@@ -0,0 +1,578 @@
|
||||
@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
|
||||
@@ -0,0 +1 @@
|
||||
uid://cyowqr1x12ilg
|
||||
@@ -0,0 +1,219 @@
|
||||
@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()
|
||||
@@ -0,0 +1 @@
|
||||
uid://dhoe3ypkhm12v
|
||||
@@ -0,0 +1,181 @@
|
||||
@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",
|
||||
]
|
||||
@@ -0,0 +1 @@
|
||||
uid://cnp5b6fcwou2y
|
||||
@@ -0,0 +1,466 @@
|
||||
@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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://bvib7d8eabbcm
|
||||
@@ -0,0 +1,763 @@
|
||||
@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}
|
||||
@@ -0,0 +1 @@
|
||||
uid://ch0brn7swb6qx
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1 @@
|
||||
uid://g8a4iijpk22w
|
||||
@@ -0,0 +1,353 @@
|
||||
@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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://ct1wmgfk408x0
|
||||
@@ -0,0 +1,242 @@
|
||||
@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
|
||||
@@ -0,0 +1 @@
|
||||
uid://5pd418va35ms
|
||||
@@ -0,0 +1,156 @@
|
||||
@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
|
||||
@@ -0,0 +1 @@
|
||||
uid://bxougoq8xwg1
|
||||
@@ -0,0 +1,754 @@
|
||||
@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
|
||||
@@ -0,0 +1 @@
|
||||
uid://cwdvxgn0aurqv
|
||||
@@ -0,0 +1,553 @@
|
||||
@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}
|
||||
@@ -0,0 +1 @@
|
||||
uid://cfnw4oe71ra1l
|
||||
@@ -0,0 +1,38 @@
|
||||
@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",
|
||||
])
|
||||
@@ -0,0 +1 @@
|
||||
uid://b4l1g0apa2hch
|
||||
@@ -0,0 +1,81 @@
|
||||
@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"
|
||||
@@ -0,0 +1 @@
|
||||
uid://cp1u1hdpa6f8d
|
||||
@@ -0,0 +1,38 @@
|
||||
@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"])
|
||||
@@ -0,0 +1 @@
|
||||
uid://bilntn5n8oqe3
|
||||
@@ -0,0 +1,42 @@
|
||||
@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"])
|
||||
@@ -0,0 +1 @@
|
||||
uid://d36nywn2nkgts
|
||||
@@ -0,0 +1,25 @@
|
||||
@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"])
|
||||
@@ -0,0 +1 @@
|
||||
uid://cutvvrwaef0rk
|
||||
@@ -0,0 +1,44 @@
|
||||
@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())
|
||||
@@ -0,0 +1 @@
|
||||
uid://hdlwcfdr8mdk
|
||||
@@ -0,0 +1,20 @@
|
||||
@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"])
|
||||
@@ -0,0 +1 @@
|
||||
uid://bvpbssfanukef
|
||||
@@ -0,0 +1,44 @@
|
||||
@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.
|
||||
@@ -0,0 +1 @@
|
||||
uid://cw3pghlevtql8
|
||||
@@ -0,0 +1,24 @@
|
||||
@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"])
|
||||
@@ -0,0 +1 @@
|
||||
uid://b8288pxninajy
|
||||
@@ -0,0 +1,34 @@
|
||||
@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())
|
||||
@@ -0,0 +1 @@
|
||||
uid://ckchsj5s3q1b0
|
||||
@@ -0,0 +1,45 @@
|
||||
@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()
|
||||
@@ -0,0 +1 @@
|
||||
uid://ewmadhrvs5d7
|
||||
@@ -0,0 +1,37 @@
|
||||
@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"])
|
||||
@@ -0,0 +1 @@
|
||||
uid://dc1x77i1cmb6w
|
||||
@@ -0,0 +1,33 @@
|
||||
@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"])
|
||||
@@ -0,0 +1 @@
|
||||
uid://d2whd6a5fofhg
|
||||
@@ -0,0 +1,22 @@
|
||||
@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"])
|
||||
@@ -0,0 +1 @@
|
||||
uid://dqdmd2jw5qen7
|
||||
@@ -0,0 +1,54 @@
|
||||
@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"])
|
||||
@@ -0,0 +1 @@
|
||||
uid://s8n0vfirf2pj
|
||||
@@ -0,0 +1,61 @@
|
||||
@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())
|
||||
@@ -0,0 +1 @@
|
||||
uid://it00ohwkm2nd
|
||||
@@ -0,0 +1,25 @@
|
||||
@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"])
|
||||
@@ -0,0 +1 @@
|
||||
uid://qwb5udkf423q
|
||||
@@ -0,0 +1,41 @@
|
||||
@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"])
|
||||
@@ -0,0 +1 @@
|
||||
uid://denjdf50qrf66
|
||||
@@ -0,0 +1,22 @@
|
||||
@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"])
|
||||
@@ -0,0 +1 @@
|
||||
uid://cwpu48772vfj1
|
||||
@@ -0,0 +1,29 @@
|
||||
@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"])
|
||||
@@ -0,0 +1 @@
|
||||
uid://dl6cm044pihub
|
||||
@@ -0,0 +1,22 @@
|
||||
@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"])
|
||||
@@ -0,0 +1 @@
|
||||
uid://cad5w4ofyg8a2
|
||||
@@ -0,0 +1,28 @@
|
||||
@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"])
|
||||
@@ -0,0 +1 @@
|
||||
uid://b6pqiok2mlsmg
|
||||
@@ -0,0 +1,35 @@
|
||||
@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"])
|
||||
@@ -0,0 +1 @@
|
||||
uid://d152l0u0r6fsc
|
||||
@@ -0,0 +1,35 @@
|
||||
@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"])
|
||||
@@ -0,0 +1 @@
|
||||
uid://fmp0nlzcm3ukl
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1 @@
|
||||
uid://bmnk8rsotiks2
|
||||
@@ -0,0 +1,80 @@
|
||||
@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
|
||||
@@ -0,0 +1 @@
|
||||
uid://b68uel6lsb7jr
|
||||
@@ -0,0 +1,44 @@
|
||||
@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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://bdgqe4gyian2o
|
||||
@@ -0,0 +1,66 @@
|
||||
@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
|
||||
@@ -0,0 +1 @@
|
||||
uid://h7k8j5tpscwy
|
||||
@@ -0,0 +1,16 @@
|
||||
@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
|
||||
@@ -0,0 +1 @@
|
||||
uid://dfvphmvo7en3i
|
||||
@@ -0,0 +1,277 @@
|
||||
@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
|
||||
@@ -0,0 +1 @@
|
||||
uid://bq2btxtm8flhb
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1 @@
|
||||
uid://bd1k63iye1bsl
|
||||
@@ -0,0 +1,529 @@
|
||||
@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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://ctldk7ivsoo3i
|
||||
@@ -0,0 +1,86 @@
|
||||
@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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://cr5nbnd6vj3b8
|
||||
@@ -0,0 +1,106 @@
|
||||
@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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://hlggbo1q65eq
|
||||
@@ -0,0 +1,69 @@
|
||||
@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
|
||||
@@ -0,0 +1 @@
|
||||
uid://np0cp6fwpim7
|
||||
@@ -0,0 +1,71 @@
|
||||
@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}
|
||||
@@ -0,0 +1 @@
|
||||
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