1389 lines
59 KiB
GDScript
Vendored
1389 lines
59 KiB
GDScript
Vendored
@tool
|
|
class_name McpClientConfigurator
|
|
extends RefCounted
|
|
|
|
## Public facade for the MCP client configuration system.
|
|
##
|
|
## Per-client logic lives in clients/*.gd (one descriptor per client) and is
|
|
## dispatched through clients/_registry.gd. This file:
|
|
## - owns server-side identifiers (SERVER_NAME, HTTP/WS port helpers)
|
|
## - registers the EditorSettings port overrides and resolves the live
|
|
## port/URL via `http_port()` / `ws_port()` / `http_url()`
|
|
## - keeps server-launch discovery (.venv → uvx → system godot-ai)
|
|
## - exposes string-id wrappers around configure / check_status / remove /
|
|
## manual_command so callers don't need to touch the registry directly
|
|
##
|
|
## To add a new client: drop a file in clients/, then preload it in
|
|
## clients/_registry.gd. No edits required here.
|
|
|
|
const Client := preload("res://addons/godot_ai/clients/_base.gd")
|
|
const ClientRegistry := preload("res://addons/godot_ai/clients/_registry.gd")
|
|
const JsonStrategy := preload("res://addons/godot_ai/clients/_json_strategy.gd")
|
|
const TomlStrategy := preload("res://addons/godot_ai/clients/_toml_strategy.gd")
|
|
const YamlStrategy := preload("res://addons/godot_ai/clients/_yaml_strategy.gd")
|
|
const CliStrategy := preload("res://addons/godot_ai/clients/_cli_strategy.gd")
|
|
const ManualCommand := preload("res://addons/godot_ai/clients/_manual_command.gd")
|
|
const CliFinder := preload("res://addons/godot_ai/clients/_cli_finder.gd")
|
|
const WindowsPortReservation := preload("res://addons/godot_ai/utils/windows_port_reservation.gd")
|
|
const PortResolver := preload("res://addons/godot_ai/utils/port_resolver.gd")
|
|
|
|
const SERVER_NAME := "godot-ai"
|
|
|
|
## Fallback ports. Live port selection goes through `http_port()` / `ws_port()`,
|
|
## which read overrides from EditorSettings first. Users on Windows whose 8000
|
|
## is grabbed by Hyper-V / WSL2 / Docker can pick a different port in
|
|
## Editor Settings > Plugins > godot_ai without touching code. See #146 for
|
|
## the Windows-reservation diagnostics this is the escape hatch for.
|
|
const DEFAULT_HTTP_PORT := 8000
|
|
const DEFAULT_WS_PORT := 9500
|
|
const STARTUP_TRACE_ENV := "GODOT_AI_STARTUP_TRACE"
|
|
const MIN_PORT := 1024
|
|
const MAX_PORT := 65535
|
|
## Cap on `can_bind_local_port` probes per `suggest_free_port` call so a
|
|
## pathological run of occupied ports can't stall the (cold-path) caller.
|
|
## 64 localhost binds are sub-millisecond; finding a free port realistically
|
|
## takes one or two probes, so this only bounds the worst case.
|
|
const SUGGEST_PORT_MAX_PROBES := 64
|
|
const SETTING_WS_PORT := "godot_ai/ws_port"
|
|
const SETTING_STARTUP_TRACE := "godot_ai/log_startup_timing"
|
|
const SETTING_KEEP_SERVER_ON_EXIT := "godot_ai/keep_server_on_exit"
|
|
const _DISCOVERY_TIMEOUT_MS := 3000
|
|
## Codex launches Windows console-subsystem MCP commands in a visible terminal.
|
|
## A GUI-subsystem Python keeps the bridge attached to Codex's redirected MCP
|
|
## pipes without allocating a console, then starts console launchers such as
|
|
## uvx with CREATE_NO_WINDOW. Keep stdin/stdout/stderr explicit: pythonw can use
|
|
## its own inherited pipes, but subprocess defaults do not reliably forward
|
|
## them to a child when no console exists.
|
|
## This string is a wire format written verbatim into user config `args`.
|
|
## Whitespace or formatting changes make every existing Windows entry report
|
|
## CONFIGURED_MISMATCH, so changing it is a deliberate migration decision, not
|
|
## a refactor. The inline `-c` script is required because the uvx and system
|
|
## tiers resolve a system interpreter where `godot_ai` is not importable.
|
|
const _WINDOWS_STDIO_BOOTSTRAP := (
|
|
"import subprocess,sys; "
|
|
+ "raise SystemExit(subprocess.call(sys.argv[1:], stdin=sys.stdin, stdout=sys.stdout, "
|
|
+ "stderr=sys.stderr, creationflags=0x08000000))"
|
|
)
|
|
|
|
|
|
## Active HTTP port: user override (if in range) or `DEFAULT_HTTP_PORT`.
|
|
static func http_port() -> int:
|
|
return _read_port_setting(McpSettings.SETTING_HTTP_PORT, DEFAULT_HTTP_PORT)
|
|
|
|
|
|
## Active WebSocket port: user override (if in range) or `DEFAULT_WS_PORT`.
|
|
static func ws_port() -> int:
|
|
return _read_port_setting(SETTING_WS_PORT, DEFAULT_WS_PORT)
|
|
|
|
|
|
static func http_url() -> String:
|
|
return "http://127.0.0.1:%d/mcp" % http_port()
|
|
|
|
|
|
## Read a URL already captured on the main thread without evaluating the
|
|
## EditorSettings-backed fallback unless the snapshot is genuinely incomplete.
|
|
static func server_url_from(launch_context: Dictionary) -> String:
|
|
if launch_context.has("server_url"):
|
|
return str(launch_context["server_url"])
|
|
return http_url()
|
|
|
|
|
|
static func _read_port_setting(key: String, default_port: int) -> int:
|
|
var es := EditorInterface.get_editor_settings()
|
|
if es == null or not es.has_setting(key):
|
|
return default_port
|
|
var value: int = int(es.get_setting(key))
|
|
if value < MIN_PORT or value > MAX_PORT:
|
|
return default_port
|
|
return value
|
|
|
|
|
|
## Register the port overrides in EditorSettings so they show up in the
|
|
## editor's Settings > Plugins section with a range hint. Called once from
|
|
## `plugin.gd._enter_tree` before `_start_server` so spawn args see the
|
|
## configured values. Safe to call repeatedly — `add_property_info` is
|
|
## idempotent and `set_initial_value` only seeds the default.
|
|
static func ensure_settings_registered() -> void:
|
|
var es := EditorInterface.get_editor_settings()
|
|
if es == null:
|
|
return
|
|
_register_port_setting(es, McpSettings.SETTING_HTTP_PORT, DEFAULT_HTTP_PORT)
|
|
_register_port_setting(es, SETTING_WS_PORT, DEFAULT_WS_PORT)
|
|
_register_bool_setting(es, SETTING_STARTUP_TRACE, false)
|
|
_register_bool_setting(es, SETTING_KEEP_SERVER_ON_EXIT, false)
|
|
|
|
|
|
static func _register_port_setting(es: EditorSettings, key: String, default_port: int) -> void:
|
|
if not es.has_setting(key):
|
|
es.set_setting(key, default_port)
|
|
es.set_initial_value(key, default_port, false)
|
|
es.add_property_info({
|
|
"name": key,
|
|
"type": TYPE_INT,
|
|
"hint": PROPERTY_HINT_RANGE,
|
|
"hint_string": "%d,%d,1" % [MIN_PORT, MAX_PORT],
|
|
})
|
|
|
|
|
|
static func _register_bool_setting(es: EditorSettings, key: String, default_value: bool) -> void:
|
|
if not es.has_setting(key):
|
|
es.set_setting(key, default_value)
|
|
es.set_initial_value(key, default_value, false)
|
|
es.add_property_info({
|
|
"name": key,
|
|
"type": TYPE_BOOL,
|
|
})
|
|
|
|
|
|
static func startup_trace_enabled() -> bool:
|
|
## env_lookup + _editor_setting_lookup for the same worker-thread
|
|
## reason as mode_override (#691).
|
|
var raw := McpPathTemplate.env_lookup(STARTUP_TRACE_ENV).strip_edges().to_lower()
|
|
if raw == "1" or raw == "true" or raw == "yes" or raw == "on":
|
|
return true
|
|
var setting: Variant = _editor_setting_lookup(SETTING_STARTUP_TRACE)
|
|
if setting != null:
|
|
return bool(setting)
|
|
return false
|
|
|
|
|
|
## keep_server_on_exit (#800): when enabled, editor teardown detaches from
|
|
## the managed server instead of killing it, and the spawn env opts the
|
|
## server out of both self-reap paths (owner-PID watchdog, session-idle
|
|
## backstop) — so MCP clients connected over HTTP stay served across editor
|
|
## sessions, and the next editor start adopts the survivor. Off by default:
|
|
## "server dies with the editor" stays the shipped behavior. Read via
|
|
## _editor_setting_lookup for the same worker-thread reason as
|
|
## startup_trace_enabled (#691).
|
|
static func keep_server_on_exit() -> bool:
|
|
var setting: Variant = _editor_setting_lookup(SETTING_KEEP_SERVER_ON_EXIT)
|
|
if setting != null:
|
|
return bool(setting)
|
|
return false
|
|
|
|
|
|
## #691: EditorSettings counterpart of McpPathTemplate.env_lookup. The #678
|
|
## startup walk's discovery worker reaches mode_override() (via
|
|
## get_server_command) and EditorInterface / EditorSettings are not
|
|
## thread-safe objects. Main thread: live read + mutex-guarded snapshot
|
|
## refresh. Worker thread: snapshot only — a never-warmed key reads as
|
|
## null (unset), never a live EditorInterface call. Warmed alongside the
|
|
## env snapshot in warm_env_snapshot(), which runs on the main thread
|
|
## before any worker dispatch.
|
|
static var _setting_snapshot := {}
|
|
static var _setting_snapshot_mutex := Mutex.new()
|
|
## The aggregate MCP status command runs on a worker thread. Keep the same
|
|
## main-thread-only LaunchContext contract used by dock workers by publishing a
|
|
## deep snapshot whenever capture_launch_context() runs on the main thread.
|
|
static var _launch_context_snapshot := {}
|
|
static var _launch_context_snapshot_mutex := Mutex.new()
|
|
|
|
|
|
static func _editor_setting_lookup(key: String) -> Variant:
|
|
if OS.get_thread_caller_id() == OS.get_main_thread_id():
|
|
var live: Variant = null
|
|
if Engine.is_editor_hint():
|
|
var es := EditorInterface.get_editor_settings()
|
|
if es != null and es.has_setting(key):
|
|
live = es.get_setting(key)
|
|
_setting_snapshot_mutex.lock()
|
|
_setting_snapshot[key] = live
|
|
_setting_snapshot_mutex.unlock()
|
|
return live
|
|
_setting_snapshot_mutex.lock()
|
|
var cached: Variant = _setting_snapshot.get(key, null)
|
|
_setting_snapshot_mutex.unlock()
|
|
return cached
|
|
|
|
|
|
## Read the `godot_ai/excluded_domains` EditorSetting as a canonicalized
|
|
## comma-separated list (sorted, deduplicated, whitespace-stripped). Returns
|
|
## "" when the setting is missing or resolves to an empty set — callers can
|
|
## skip appending the flag in that case so older servers that don't know
|
|
## `--exclude-domains` don't see an empty argument.
|
|
##
|
|
## Unknown domain names (e.g. a domain removed since the setting was last
|
|
## written) are dropped here, at the single chokepoint both the startup
|
|
## flag builder (plugin.gd) and the dock display read — the server's
|
|
## `parse_exclude_list` hard-fails on unknown names, so a stale setting
|
|
## would otherwise block server startup.
|
|
static func excluded_domains() -> String:
|
|
var es := EditorInterface.get_editor_settings()
|
|
if es == null or not es.has_setting(McpSettings.SETTING_EXCLUDED_DOMAINS):
|
|
return ""
|
|
return _canonicalize_excluded_domains(str(es.get_setting(McpSettings.SETTING_EXCLUDED_DOMAINS)))
|
|
|
|
|
|
## Pure canonicalizer shared by the main-thread LaunchContext capture and
|
|
## tests. Unknown domains are dropped for the same startup-safety reason as
|
|
## `excluded_domains()` above.
|
|
static func _canonicalize_excluded_domains(raw: String) -> String:
|
|
var parts := PackedStringArray()
|
|
for p in raw.split(","):
|
|
var t := p.strip_edges()
|
|
if t.is_empty() or parts.find(t) != -1:
|
|
continue
|
|
if not McpToolCatalog.is_excludable_domain(t):
|
|
continue
|
|
parts.append(t)
|
|
parts.sort()
|
|
return ",".join(parts)
|
|
|
|
|
|
## Snapshot every EditorSettings-backed value needed to render or verify an
|
|
## attach launch command. Main-thread calls refresh the snapshot; worker calls
|
|
## return that snapshot without touching EditorInterface (#691). Warm it on the
|
|
## main thread before dispatching a worker.
|
|
static func capture_launch_context() -> Dictionary:
|
|
if OS.get_thread_caller_id() != OS.get_main_thread_id():
|
|
_launch_context_snapshot_mutex.lock()
|
|
var cached := _launch_context_snapshot.duplicate(true)
|
|
_launch_context_snapshot_mutex.unlock()
|
|
return cached
|
|
var captured_http_port := http_port()
|
|
var context := {
|
|
"http_port": captured_http_port,
|
|
"ws_port": ws_port(),
|
|
"excluded_domains": excluded_domains(),
|
|
"plugin_version": get_plugin_version(),
|
|
"allow_dev_venv": mode_override() != "user",
|
|
"platform": OS.get_name(),
|
|
"server_url": "http://127.0.0.1:%d/mcp" % captured_http_port,
|
|
## The opt-out must ride the attach argv: the client spawns the bridge
|
|
## (and the bridge its backend) with no editor in the loop, so the
|
|
## env-injection path in server_lifecycle.gd never runs for them.
|
|
"telemetry_enabled": McpSettings.telemetry_enabled(),
|
|
}
|
|
_launch_context_snapshot_mutex.lock()
|
|
_launch_context_snapshot = context.duplicate(true)
|
|
_launch_context_snapshot_mutex.unlock()
|
|
return context
|
|
|
|
|
|
## Read the `godot_ai/allow_remote_hosts` EditorSetting as a canonicalized
|
|
## comma-separated list of CIDRs / bare IPs (#507). Returns "" when the
|
|
## setting is missing or empty — callers skip appending `--allow-host` in
|
|
## that case so spawns stay byte-for-byte identical to the loopback-only
|
|
## default (and compatible with pre-#421 servers). Mirrors
|
|
## `excluded_domains()` above.
|
|
static func allow_hosts() -> String:
|
|
var es := EditorInterface.get_editor_settings()
|
|
if es == null or not es.has_setting(McpSettings.SETTING_ALLOW_HOSTS):
|
|
return ""
|
|
return McpAllowHosts.normalize(str(es.get_setting(McpSettings.SETTING_ALLOW_HOSTS)))
|
|
|
|
|
|
## Suggest a port the caller can actually switch to. Walks
|
|
## `candidate`..`candidate+span-1` and returns the first port that is both
|
|
## (a) NOT inside a Windows winnat reservation range (Hyper-V / WSL2 / Docker
|
|
## grab these; bind fails with WinError 10013 and netstat shows nothing) and
|
|
## (b) actually bindable right now on 127.0.0.1. The bind probe is what makes
|
|
## "free" honest on macOS/Linux, where the reservation table is empty but the
|
|
## next port up may still be occupied — the same suggestion feeds the dock
|
|
## crash body, the port-picker spinbox, and the non-recoverable INCOMPATIBLE
|
|
## log line. Falls back to the clamped candidate if nothing in the window
|
|
## clears both checks (caller surfaces it as a best-effort hint; the user can
|
|
## retry or pick another). Best-effort by nature: a TOCTOU window remains
|
|
## between the probe and the caller actually binding the port. The bind probe
|
|
## is bounded to `SUGGEST_PORT_MAX_PROBES` attempts so this cold path can't
|
|
## stall on a pathological run of occupied ports.
|
|
static func suggest_free_port(start: int, span: int = 2048) -> int:
|
|
var candidate := clampi(start, MIN_PORT, MAX_PORT - span + 1)
|
|
var limit := mini(candidate + span - 1, MAX_PORT)
|
|
var p := candidate
|
|
var probes := 0
|
|
while p <= limit and probes < SUGGEST_PORT_MAX_PROBES:
|
|
## Jump past a whole Windows-reserved range in one step (no-op on
|
|
## POSIX: returns `p` unchanged), so we don't probe port-by-port
|
|
## through the large adjacent ranges those services reserve. The
|
|
## jump itself runs no bind probes, so it doesn't count against the cap.
|
|
var not_reserved := WindowsPortReservation.suggest_non_excluded_port(p, limit - p + 1, MAX_PORT)
|
|
if not_reserved < p or not_reserved > limit:
|
|
break
|
|
p = not_reserved
|
|
probes += 1
|
|
if PortResolver.can_bind_local_port(p):
|
|
return p
|
|
p += 1
|
|
return candidate
|
|
|
|
|
|
# --- Client operations (string id) ---------------------------------------
|
|
|
|
static func client_ids() -> PackedStringArray:
|
|
return ClientRegistry.ids()
|
|
|
|
|
|
static func has_client(id: String) -> bool:
|
|
return ClientRegistry.has_id(id)
|
|
|
|
|
|
static func client_display_name(id: String) -> String:
|
|
var c := ClientRegistry.get_by_id(id)
|
|
return c.display_name if c != null else id
|
|
|
|
|
|
## Pass an explicit `url` when calling from a worker thread: `http_url()`
|
|
## reads `EditorInterface.get_editor_settings()`, which is main-thread-only.
|
|
## Empty defaults to the live server URL — appropriate for MCP-tool callers
|
|
## that always run on main.
|
|
static func configure(id: String, url: String = "", launch_context: Dictionary = {}) -> Dictionary:
|
|
if ClientRegistry.stale_session_detected():
|
|
return {"status": "error", "message": ClientRegistry.RESTART_TO_FINISH_UPDATE}
|
|
var client := ClientRegistry.get_by_id(id)
|
|
if client == null:
|
|
return {"status": "error", "message": "Unknown client: %s" % id}
|
|
var path_error := _config_path_resolution_error(client)
|
|
if not path_error.is_empty():
|
|
return {"status": "error", "message": path_error}
|
|
## Capture `url` once so a port flip in EditorSettings between write and
|
|
## verify can't trigger a spurious CONFIGURED_MISMATCH against an entry
|
|
## that just landed correctly.
|
|
if url.is_empty():
|
|
url = http_url()
|
|
var context := launch_context
|
|
if client.command_shape != Client.CommandShape.NONE and context.is_empty():
|
|
if OS.get_thread_caller_id() != OS.get_main_thread_id():
|
|
return {
|
|
"status": "error",
|
|
"message": "Cannot configure %s without a main-thread launch snapshot; retry from the dock." % client.display_name,
|
|
}
|
|
context = capture_launch_context()
|
|
var launch := (
|
|
resolve_attach_launch(context)
|
|
if client.command_shape != Client.CommandShape.NONE
|
|
else {}
|
|
)
|
|
var result := _dispatch_configure(client, url, launch)
|
|
## Trust-but-verify: a strategy may report ok and have actually written the
|
|
## file, yet the entry is missing/stale on the read-back path — most often
|
|
## because the user's installed client is reading a different file than
|
|
## `path_template` resolves to (issue #201). Re-read the live state and
|
|
## surface a clear error before the dock reports a bogus green dot.
|
|
return _verify_post_state(client, result, Client.Status.CONFIGURED, url, "configure", launch)
|
|
|
|
|
|
static func check_status(id: String) -> Client.Status:
|
|
if ClientRegistry.stale_session_detected():
|
|
return Client.Status.ERROR
|
|
var client := ClientRegistry.get_by_id(id)
|
|
if client == null:
|
|
return Client.Status.NOT_CONFIGURED
|
|
var context := capture_launch_context() if client.command_shape != Client.CommandShape.NONE else {}
|
|
return _dispatch_check_status(client, http_url(), context)
|
|
|
|
|
|
static func check_status_for_url_with_cli_path(
|
|
id: String, url: String, cli_path: String, launch_context: Dictionary = {}
|
|
) -> Client.Status:
|
|
return check_status_details_for_url_with_cli_path(id, url, cli_path, launch_context).get("status", Client.Status.NOT_CONFIGURED)
|
|
|
|
|
|
## Detailed variant used by the dock refresh worker. Returns
|
|
## `{"status": Status, "error_msg": String}` so the worker can surface
|
|
## "probe timed out" on the row instead of silently flipping it to
|
|
## NOT_CONFIGURED. Callers that only need the status can use the simpler
|
|
## helper above.
|
|
static func check_status_details_for_url_with_cli_path(
|
|
id: String,
|
|
url: String,
|
|
cli_path: String,
|
|
launch_context: Dictionary = {},
|
|
resolved_launch: Dictionary = {},
|
|
) -> Dictionary:
|
|
## One comprehensible line per row beats a wall of per-field type errors —
|
|
## the dock keeps painting, every row names the same repair (restart).
|
|
if ClientRegistry.stale_session_detected():
|
|
return {"status": Client.Status.ERROR, "error_msg": ClientRegistry.RESTART_TO_FINISH_UPDATE}
|
|
var client := ClientRegistry.get_by_id(id)
|
|
if client == null:
|
|
return {"status": Client.Status.NOT_CONFIGURED, "error_msg": ""}
|
|
# A cli client with no resolved binary normally reads as NOT_CONFIGURED.
|
|
# Skip that shortcut when the client has a JSON fallback (#463): the
|
|
# dispatch below reads its config file directly so the status dot reflects
|
|
# a fallback-configured entry instead of always showing red.
|
|
if client.config_type == "cli" and cli_path.is_empty() and not client.has_json_fallback():
|
|
return {"status": Client.Status.NOT_CONFIGURED, "error_msg": ""}
|
|
var path_error := _config_path_resolution_error(client)
|
|
if not path_error.is_empty():
|
|
return {"status": Client.Status.ERROR, "error_msg": path_error}
|
|
if client.command_shape != Client.CommandShape.NONE and launch_context.is_empty():
|
|
return {
|
|
"status": Client.Status.ERROR,
|
|
"error_msg": "Missing launch-context snapshot; retry the status refresh.",
|
|
}
|
|
return _dispatch_check_status_with_cli_path_details(
|
|
client, url, cli_path, launch_context, resolved_launch
|
|
)
|
|
|
|
|
|
## #691: main-thread pre-warm of McpPathTemplate's env snapshot, covering
|
|
## the base vars plus every descriptor-declared config-file/config-home env
|
|
## (OPENCODE_CONFIG, CLAUDE_CONFIG_DIR, CODEX_HOME, …), so worker-thread config-path
|
|
## resolution never calls OS.get_environment concurrently with the spawn
|
|
## window's setenv/unsetenv. Also warms the EditorSettings snapshot for
|
|
## the mode/trace overrides so worker-thread mode_override() /
|
|
## startup_trace_enabled() never touch EditorInterface. Idempotent;
|
|
## called from plugin _enter_tree and before each dock worker dispatch.
|
|
static func warm_env_snapshot() -> void:
|
|
var extras := PackedStringArray()
|
|
for id in client_ids():
|
|
var client := ClientRegistry.get_by_id(String(id))
|
|
if client == null:
|
|
continue
|
|
## Reflected get(): after an in-session self-update these fields can
|
|
## read as Nil on stale instances, and String(Nil) is a hard error (#850). Skipping just degrades env-override
|
|
## resolution until the restart the registry is already asking for.
|
|
for env_name in [client.get("config_file_env"), client.get("config_home_env")]:
|
|
if env_name is String and not env_name.is_empty() and not extras.has(env_name):
|
|
extras.append(env_name)
|
|
McpPathTemplate.warm_env_snapshot(extras)
|
|
_editor_setting_lookup(MODE_OVERRIDE_SETTING)
|
|
_editor_setting_lookup(SETTING_STARTUP_TRACE)
|
|
_editor_setting_lookup(SETTING_KEEP_SERVER_ON_EXIT)
|
|
# Publish the complete launch context while EditorInterface access is safe;
|
|
# worker callers of capture_launch_context() read this snapshot only.
|
|
capture_launch_context()
|
|
|
|
|
|
static func client_status_probe_snapshot(id: String) -> Dictionary:
|
|
var client := ClientRegistry.get_by_id(id)
|
|
if client == null:
|
|
return {}
|
|
var cli_path := ""
|
|
var installed := false
|
|
if client.config_type == "cli":
|
|
cli_path = CliStrategy.resolve_cli_path(client)
|
|
# #463: a JSON-fallback cli client (Claude Code as a VS Code extension)
|
|
# is "installed" when its fallback config exists, even with no binary.
|
|
installed = not cli_path.is_empty() or client.is_installed()
|
|
else:
|
|
installed = client.is_installed()
|
|
return {"id": id, "cli_path": cli_path, "installed": installed}
|
|
|
|
|
|
## Force lazy GDScript bytecode swaps to complete before a client-status
|
|
## worker reaches the registry and strategies. Pure-memory only: callers can
|
|
## run this on the handler thread without performing CLI or config probes.
|
|
static func warm_status_worker_bytecode() -> void:
|
|
var ids := client_ids()
|
|
if ids.is_empty():
|
|
return
|
|
var any_client := ClientRegistry.get_by_id(String(ids[0]))
|
|
if any_client != null:
|
|
JsonStrategy.verify_entry(any_client, {}, "")
|
|
TomlStrategy.format_body(PackedStringArray(), "")
|
|
CliStrategy.format_args(PackedStringArray(), "", "")
|
|
# Compile the aggregate worker entry point on main as well. After a plugin
|
|
# reload, first-dereferencing this function from Thread can hang in Godot's
|
|
# lazy bytecode swap even when every strategy it calls was already warmed.
|
|
run_client_status_sweep({}, true)
|
|
|
|
|
|
## Worker entry point for the MCP aggregate status command. Every filesystem,
|
|
## CLI, and launch-discovery probe stays inside this function; the WebSocket
|
|
## handler only schedules it and returns the deferred sentinel.
|
|
static func run_client_status_sweep(
|
|
fallback_launch_context: Dictionary = {}, warm_only: bool = false
|
|
) -> Dictionary:
|
|
if warm_only:
|
|
return {}
|
|
var clients := []
|
|
var launch_context := capture_launch_context()
|
|
if launch_context.is_empty():
|
|
launch_context = fallback_launch_context.duplicate(true)
|
|
if launch_context.is_empty():
|
|
return {"worker_error": "Client status launch context was not warmed on the main thread."}
|
|
var server_url := server_url_from(launch_context)
|
|
var resolved_launch := resolve_attach_launch(launch_context)
|
|
for client_id in client_ids():
|
|
var probe := client_status_probe_snapshot(client_id)
|
|
var details := check_status_details_for_url_with_cli_path(
|
|
client_id,
|
|
server_url,
|
|
str(probe.get("cli_path", "")),
|
|
launch_context,
|
|
resolved_launch,
|
|
)
|
|
clients.append(_client_status_sweep_entry(
|
|
client_id, details, bool(probe.get("installed", false))
|
|
))
|
|
return {"data": {"clients": clients}}
|
|
|
|
|
|
static func _client_status_sweep_entry(
|
|
client_id: String, details: Dictionary, installed: bool
|
|
) -> Dictionary:
|
|
var status = details.get("status", Client.Status.NOT_CONFIGURED)
|
|
var entry := {
|
|
"id": client_id,
|
|
"display_name": client_display_name(client_id),
|
|
"status": Client.status_label(status),
|
|
"installed": installed,
|
|
}
|
|
var error_msg := str(details.get("error_msg", ""))
|
|
if not error_msg.is_empty():
|
|
entry["error"] = error_msg
|
|
return entry
|
|
|
|
|
|
## Pass an explicit `url` when calling from a worker thread — see
|
|
## `configure()` above for why. The url is only used to format the
|
|
## verify-after-write diagnostic message; the remove itself doesn't need it.
|
|
static func remove(id: String, url: String = "", launch_context: Dictionary = {}) -> Dictionary:
|
|
if ClientRegistry.stale_session_detected():
|
|
return {"status": "error", "message": ClientRegistry.RESTART_TO_FINISH_UPDATE}
|
|
var client := ClientRegistry.get_by_id(id)
|
|
if client == null:
|
|
return {"status": "error", "message": "Unknown client: %s" % id}
|
|
var path_error := _config_path_resolution_error(client)
|
|
if not path_error.is_empty():
|
|
return {"status": "error", "message": path_error}
|
|
if url.is_empty():
|
|
url = http_url()
|
|
var context := launch_context
|
|
if client.command_shape != Client.CommandShape.NONE and context.is_empty():
|
|
if OS.get_thread_caller_id() != OS.get_main_thread_id():
|
|
return {
|
|
"status": "error",
|
|
"message": "Cannot remove %s without a main-thread launch snapshot; retry from the dock." % client.display_name,
|
|
}
|
|
context = capture_launch_context()
|
|
var launch := (
|
|
resolve_attach_launch(context)
|
|
if client.command_shape != Client.CommandShape.NONE
|
|
else {}
|
|
)
|
|
var result := _dispatch_remove(client)
|
|
return _verify_post_state(client, result, Client.Status.NOT_CONFIGURED, url, "remove", launch)
|
|
|
|
|
|
## Resolve config-backed path errors before attach-launch discovery. This both
|
|
## gives ambiguity precedence over unrelated launcher failures and avoids
|
|
## spending the status worker's command budget on a Configure action that must
|
|
## fail closed regardless. CLI clients keep their existing CLI/fallback dispatch.
|
|
static func _config_path_resolution_error(client: Client) -> String:
|
|
if client.config_type == "cli":
|
|
return ""
|
|
return str(client.resolved_config_path_details().get("error", ""))
|
|
|
|
|
|
# --- Strategy dispatch + verify (testable seam) --------------------------
|
|
|
|
static func _dispatch_configure(client: Client, url: String, launch: Dictionary = {}) -> Dictionary:
|
|
launch = launch_for_client(client, launch)
|
|
match client.config_type:
|
|
"json":
|
|
return JsonStrategy.configure(client, SERVER_NAME, url, launch)
|
|
"toml":
|
|
return TomlStrategy.configure(client, SERVER_NAME, url, launch)
|
|
"yaml":
|
|
return YamlStrategy.configure(client, SERVER_NAME, url, launch)
|
|
"cli":
|
|
# #463: fall back to writing the config file directly when the CLI
|
|
# binary isn't on PATH (Claude Code as a VS Code/Cursor extension).
|
|
if client.has_json_fallback() and CliStrategy.resolve_cli_path(client).is_empty():
|
|
return JsonStrategy.configure(client, SERVER_NAME, url, launch)
|
|
return CliStrategy.configure(client, SERVER_NAME, url, launch)
|
|
return {"status": "error", "message": "Unknown config_type for %s: %s" % [client.id, client.config_type]}
|
|
|
|
|
|
static func _dispatch_remove(client: Client) -> Dictionary:
|
|
match client.config_type:
|
|
"json":
|
|
return JsonStrategy.remove(client, SERVER_NAME)
|
|
"toml":
|
|
return TomlStrategy.remove(client, SERVER_NAME)
|
|
"yaml":
|
|
return YamlStrategy.remove(client, SERVER_NAME)
|
|
"cli":
|
|
# #463: mirror the configure fallback so Remove also works without
|
|
# the CLI binary — otherwise a fallback-written entry is unremovable.
|
|
if client.has_json_fallback() and CliStrategy.resolve_cli_path(client).is_empty():
|
|
return JsonStrategy.remove(client, SERVER_NAME)
|
|
return CliStrategy.remove(client, SERVER_NAME)
|
|
return {"status": "error", "message": "Unknown config_type for %s: %s" % [client.id, client.config_type]}
|
|
|
|
|
|
static func _dispatch_check_status(
|
|
client: Client, url: String, launch_context: Dictionary = {}
|
|
) -> Client.Status:
|
|
return _dispatch_check_status_with_cli_path(client, url, "", launch_context)
|
|
|
|
|
|
static func _dispatch_check_status_with_cli_path(
|
|
client: Client, url: String, cli_path: String, launch_context: Dictionary = {}
|
|
) -> Client.Status:
|
|
return _dispatch_check_status_with_cli_path_details(client, url, cli_path, launch_context).get("status", Client.Status.NOT_CONFIGURED)
|
|
|
|
|
|
static func _dispatch_check_status_with_cli_path_details(
|
|
client: Client,
|
|
url: String,
|
|
cli_path: String,
|
|
launch_context: Dictionary = {},
|
|
resolved_launch: Dictionary = {},
|
|
) -> Dictionary:
|
|
match client.config_type:
|
|
"json":
|
|
var launch := {}
|
|
if client.command_shape != Client.CommandShape.NONE:
|
|
launch = _resolved_or_discovered_launch(client, resolved_launch, launch_context)
|
|
return JsonStrategy.check_status_details(client, SERVER_NAME, url, launch)
|
|
"toml":
|
|
var launch := {}
|
|
if client.command_shape != Client.CommandShape.NONE:
|
|
launch = _resolved_or_discovered_launch(client, resolved_launch, launch_context)
|
|
return TomlStrategy.check_status_details(client, SERVER_NAME, url, launch)
|
|
"yaml":
|
|
var yaml_launch := {}
|
|
if client.command_shape != Client.CommandShape.NONE:
|
|
yaml_launch = _resolved_or_discovered_launch(client, resolved_launch, launch_context)
|
|
return YamlStrategy.check_status_details(client, SERVER_NAME, url, yaml_launch)
|
|
"cli":
|
|
# Command-shape CLI clients register through their CLI, but the entry
|
|
# lands in the same file the JSON fallback reads (`claude mcp add
|
|
# --scope user` writes mcpServers in ~/.claude.json). Reading that
|
|
# file gives exact launch-drift detection — a changed port, version
|
|
# pin, or exclusion list — which scanning `mcp list` stdout cannot,
|
|
# so it is preferred even when the CLI binary resolves.
|
|
if client.command_shape != Client.CommandShape.NONE and client.has_json_fallback():
|
|
var command_launch := _resolved_or_discovered_launch(client, resolved_launch, launch_context)
|
|
return JsonStrategy.check_status_details(client, SERVER_NAME, url, command_launch)
|
|
var resolved_cli := cli_path if not cli_path.is_empty() else CliStrategy.resolve_cli_path(client)
|
|
# #463: with no CLI binary, read the JSON fallback config so a
|
|
# fallback-configured entry reports CONFIGURED instead of red.
|
|
if resolved_cli.is_empty() and client.has_json_fallback():
|
|
var fallback_launch := {}
|
|
if client.command_shape != Client.CommandShape.NONE:
|
|
fallback_launch = _resolved_or_discovered_launch(client, resolved_launch, launch_context)
|
|
return JsonStrategy.check_status_details(client, SERVER_NAME, url, fallback_launch)
|
|
var cli_launch := {}
|
|
if client.command_shape != Client.CommandShape.NONE:
|
|
cli_launch = _resolved_or_discovered_launch(client, resolved_launch, launch_context)
|
|
return CliStrategy.check_status_details(client, SERVER_NAME, url, resolved_cli, cli_launch)
|
|
return {"status": Client.Status.NOT_CONFIGURED, "error_msg": ""}
|
|
|
|
|
|
static func _resolved_or_discovered_launch(
|
|
client: Client, resolved_launch: Dictionary, launch_context: Dictionary
|
|
) -> Dictionary:
|
|
var launch := (
|
|
resolved_launch
|
|
if not resolved_launch.is_empty()
|
|
else resolve_attach_launch(launch_context)
|
|
)
|
|
return launch_for_client(client, launch)
|
|
|
|
|
|
## After a configure/remove returns ok, re-read the live status. If it doesn't
|
|
## match `expected`, replace the result with an error that names the actual
|
|
## status and the resolved config path so the user can self-diagnose. The
|
|
## strategy's own error path is left untouched — already actionable.
|
|
static func _verify_post_state(
|
|
client: Client,
|
|
result: Dictionary,
|
|
expected: Client.Status,
|
|
url: String,
|
|
action: String,
|
|
resolved_launch: Dictionary = {},
|
|
) -> Dictionary:
|
|
if result.get("status") != "ok":
|
|
return result
|
|
var actual := _dispatch_check_status_with_cli_path_details(
|
|
client, url, "", {}, resolved_launch
|
|
).get("status", Client.Status.NOT_CONFIGURED)
|
|
if actual == expected:
|
|
return result
|
|
var path := client.resolved_config_path()
|
|
var path_hint := "" if path.is_empty() else " Inspect %s and remove the godot-ai entry by hand if needed." % path
|
|
return {
|
|
"status": "error",
|
|
"message": "%s reported %s ok but verification still reads %s (expected %s).%s" % [
|
|
client.display_name, action,
|
|
Client.status_label(actual), Client.status_label(expected),
|
|
path_hint,
|
|
],
|
|
}
|
|
|
|
|
|
static func manual_command(id: String) -> String:
|
|
var client := ClientRegistry.get_by_id(id)
|
|
if client == null:
|
|
return ""
|
|
var path_resolution := client.resolved_config_path_details()
|
|
var path_error := str(path_resolution.get("error", ""))
|
|
if not path_error.is_empty():
|
|
return "Config path unavailable: %s" % path_error
|
|
var context := capture_launch_context() if client.command_shape != Client.CommandShape.NONE else {}
|
|
var launch := (
|
|
launch_for_client(client, resolve_attach_launch(context))
|
|
if client.command_shape != Client.CommandShape.NONE
|
|
else {}
|
|
)
|
|
var cmd := ManualCommand.build(
|
|
client,
|
|
SERVER_NAME,
|
|
server_url_from(context),
|
|
str(path_resolution.get("path", "")),
|
|
launch,
|
|
)
|
|
if cmd.is_empty():
|
|
return cmd
|
|
## #507: when the allow-host opt-in names a non-loopback range, also
|
|
## surface the LAN URL so the user can copy-paste the right address into
|
|
## a remote agent. Informational only — configure/remove still WRITE the
|
|
## loopback URL above; nothing about the config-file contract changes.
|
|
var note := McpAllowHosts.lan_url_note(allow_hosts(), IP.get_local_addresses(), http_port())
|
|
if not note.is_empty():
|
|
cmd += "\n\n" + note
|
|
return cmd
|
|
|
|
|
|
static func config_path(id: String) -> String:
|
|
var client := ClientRegistry.get_by_id(id)
|
|
return client.resolved_config_path() if client != null else ""
|
|
|
|
|
|
static func is_installed(id: String) -> bool:
|
|
var client := ClientRegistry.get_by_id(id)
|
|
return client != null and client.is_installed()
|
|
|
|
|
|
# --- Server command discovery --------------------------------------------
|
|
#
|
|
# Three-tier resolution:
|
|
# 1. .venv python — dev checkout, source code
|
|
# 2. uvx — user install, published package from PyPI
|
|
# 3. godot-ai CLI — system-wide pip/pipx/uv install
|
|
|
|
static func get_plugin_version() -> String:
|
|
var cfg := ConfigFile.new()
|
|
if cfg.load("res://addons/godot_ai/plugin.cfg") == OK:
|
|
return cfg.get_value("plugin", "version", "0.0.1")
|
|
return "0.0.1"
|
|
|
|
|
|
## Strip PEP 440 local build metadata for PyPI pins: `3.0.2+local.1` → `3.0.2`.
|
|
## Pre-release segments (`3.1.0-rc1`) are preserved — only `+…` is removed.
|
|
static func _pypi_pin_version(version: String) -> String:
|
|
var v := version.strip_edges()
|
|
var plus := v.find("+")
|
|
if plus >= 0:
|
|
v = v.substr(0, plus)
|
|
return v
|
|
|
|
|
|
## Resolve the client-owned `godot-ai attach` command from a main-thread
|
|
## LaunchContext. Discovery itself is worker-safe: path/environment lookup is
|
|
## snapshot-backed and subprocess probes are wall-clock bounded.
|
|
##
|
|
## `discovery_override` is a data-only test seam. Supplying a key (including
|
|
## an empty value) bypasses that tier's live lookup; `system_version_result`
|
|
## bypasses the real `godot-ai --version` subprocess.
|
|
static func resolve_attach_launch(
|
|
launch_context: Dictionary, discovery_override: Dictionary = {}
|
|
) -> Dictionary:
|
|
## Test overrides always bypass the session cache so fixture-controlled
|
|
## discovery remains deterministic. Production results are keyed by every
|
|
## setting that affects the rendered command; a port/domain/version change
|
|
## therefore cannot reuse stale arguments.
|
|
if not discovery_override.is_empty():
|
|
return _resolve_attach_launch_uncached(launch_context, discovery_override)
|
|
var cache_key := _attach_launch_cache_key(launch_context)
|
|
_attach_launch_cache_mutex.lock()
|
|
if _attach_launch_cache.has(cache_key):
|
|
var cached: Dictionary = _attach_launch_cache[cache_key].duplicate(true)
|
|
_attach_launch_cache_mutex.unlock()
|
|
return cached
|
|
## Keep the cache lock through the bounded discovery probes. This cold path
|
|
## runs at most once per distinct context and prevents simultaneous status
|
|
## workers from repeating the same subprocess probes. Invalidation waits for
|
|
## the in-flight result, then clears it, so stale work cannot repopulate a
|
|
## freshly invalidated cache.
|
|
var resolved := _resolve_attach_launch_uncached(launch_context)
|
|
_attach_launch_cache[cache_key] = resolved.duplicate(true)
|
|
_attach_launch_cache_mutex.unlock()
|
|
return resolved
|
|
|
|
|
|
static func _resolve_attach_launch_uncached(
|
|
launch_context: Dictionary, discovery_override: Dictionary = {}
|
|
) -> Dictionary:
|
|
for key in ["http_port", "ws_port", "excluded_domains", "plugin_version", "allow_dev_venv", "platform"]:
|
|
if not launch_context.has(key):
|
|
return _attach_discovery_error("Launch context is missing `%s`; retry Configure." % key)
|
|
|
|
var plugin_version := str(launch_context.get("plugin_version", "")).strip_edges()
|
|
if plugin_version.is_empty():
|
|
return _attach_discovery_error("The bundled godot-ai version is unavailable; reinstall the plugin and retry Configure.")
|
|
|
|
var common_args: Array[String] = [
|
|
"attach",
|
|
"--port", str(int(launch_context.get("http_port", DEFAULT_HTTP_PORT))),
|
|
"--ws-port", str(int(launch_context.get("ws_port", DEFAULT_WS_PORT))),
|
|
]
|
|
var exclusions := str(launch_context.get("excluded_domains", "")).strip_edges()
|
|
if not exclusions.is_empty():
|
|
common_args.append_array(["--exclude-domains", exclusions])
|
|
## Default true when the key is absent (hand-built contexts in tests, stale
|
|
## pre-upgrade snapshots) — matching the server's send-by-default posture.
|
|
## Toggling the setting changes the rendered argv, so existing entries read
|
|
## CONFIGURED_MISMATCH and the dock offers Reconfigure, like any other
|
|
## launch-affecting value.
|
|
if not bool(launch_context.get("telemetry_enabled", true)):
|
|
common_args.append("--disable-telemetry")
|
|
|
|
var venv_python := ""
|
|
if discovery_override.has("venv_python"):
|
|
venv_python = str(discovery_override["venv_python"])
|
|
elif bool(launch_context.get("allow_dev_venv", true)):
|
|
venv_python = _cached_venv_python()
|
|
if bool(launch_context.get("allow_dev_venv", true)) and not venv_python.is_empty():
|
|
var venv_args: Array[String] = ["-m", "godot_ai"]
|
|
venv_args.append_array(common_args)
|
|
return _finalize_attach_launch(
|
|
"dev_venv", venv_python, venv_args, launch_context, discovery_override
|
|
)
|
|
|
|
var uvx := ""
|
|
if discovery_override.has("uvx_path"):
|
|
uvx = str(discovery_override["uvx_path"])
|
|
else:
|
|
## Strict lookup for attach entries: never write a bare `uvx` command
|
|
## that a GUI-launched client may be unable to resolve from its PATH.
|
|
uvx = find_uvx()
|
|
if not uvx.is_empty():
|
|
var uvx_args: Array[String] = [
|
|
"--link-mode", "copy",
|
|
"--from", "godot-ai==%s" % _pypi_pin_version(plugin_version),
|
|
"godot-ai",
|
|
]
|
|
uvx_args.append_array(common_args)
|
|
return _finalize_attach_launch(
|
|
"uvx", uvx, uvx_args, launch_context, discovery_override
|
|
)
|
|
|
|
var system_cmd := ""
|
|
if discovery_override.has("system_path"):
|
|
system_cmd = str(discovery_override["system_path"])
|
|
else:
|
|
system_cmd = _find_system_install()
|
|
if not system_cmd.is_empty():
|
|
var probe: Dictionary
|
|
if discovery_override.has("system_version_result"):
|
|
probe = discovery_override["system_version_result"] as Dictionary
|
|
else:
|
|
probe = McpCliExec.run(system_cmd, ["--version"], _DISCOVERY_TIMEOUT_MS, false)
|
|
var version_check := _system_version_from_probe(probe)
|
|
if bool(version_check.get("ok", false)):
|
|
var found_version := str(version_check.get("version", ""))
|
|
if found_version == plugin_version:
|
|
return _finalize_attach_launch(
|
|
"system", system_cmd, common_args, launch_context, discovery_override
|
|
)
|
|
return _attach_discovery_error(
|
|
"System godot-ai is version %s, but this plugin requires %s. Install uv or update the system package, then retry Configure."
|
|
% [found_version, plugin_version]
|
|
)
|
|
if bool(probe.get("timed_out", false)):
|
|
return _attach_discovery_error(
|
|
"Timed out checking the system godot-ai version. Install uv or repair the system command, then retry Configure."
|
|
)
|
|
return _attach_discovery_error(
|
|
"Could not verify the system godot-ai version. Install uv or repair the system command, then retry Configure."
|
|
)
|
|
|
|
return _attach_discovery_error(
|
|
"No compatible godot-ai launcher was found. Install uv (provides uvx), then retry Configure."
|
|
)
|
|
|
|
|
|
## Return a launch shape that cannot allocate a visible console on Windows.
|
|
## The development tier can execute its sibling pythonw directly. uvx and the
|
|
## system entry point still need their own environments, so pythonw acts only
|
|
## as a stdio-preserving, CREATE_NO_WINDOW process bootstrap for those tiers.
|
|
static func _finalize_attach_launch(
|
|
tier: String,
|
|
command: String,
|
|
args: Array[String],
|
|
launch_context: Dictionary,
|
|
discovery_override: Dictionary,
|
|
) -> Dictionary:
|
|
if str(launch_context.get("platform", "")) != "Windows":
|
|
return {"ok": true, "tier": tier, "command": command, "args": args}
|
|
|
|
var pythonw := _resolve_consoleless_python(command, tier, discovery_override)
|
|
if pythonw.is_empty():
|
|
return _attach_discovery_error(
|
|
"Windows requires pythonw.exe to launch the MCP bridge without opening a terminal window. Repair this Python or uv installation, then retry Configure."
|
|
)
|
|
|
|
## `console_command`/`console_args` carry the unwrapped console-subsystem
|
|
## launch for clients that opt out of pythonw via
|
|
## `needs_consoleless_launcher = false` (#863). Strategies only consume
|
|
## `command`/`args`/`ok`; `launch_for_client` swaps the shapes per client.
|
|
if tier == "dev_venv":
|
|
return {
|
|
"ok": true, "tier": tier, "command": pythonw, "args": args,
|
|
"console_command": command, "console_args": args,
|
|
}
|
|
|
|
var wrapped_args: Array[String] = ["-c", _WINDOWS_STDIO_BOOTSTRAP, command]
|
|
wrapped_args.append_array(args)
|
|
return {
|
|
"ok": true, "tier": tier, "command": pythonw, "args": wrapped_args,
|
|
"console_command": command, "console_args": args,
|
|
}
|
|
|
|
|
|
## Select the launch shape a specific client should see. Clients with
|
|
## `needs_consoleless_launcher = false` (Antigravity, #863) get the plain
|
|
## console command captured by `_finalize_attach_launch`; everyone else keeps
|
|
## the pythonw shape unchanged. Idempotent: the returned dict carries no
|
|
## console keys, so a second application is a no-op.
|
|
static func launch_for_client(client: Client, launch: Dictionary) -> Dictionary:
|
|
if client == null or client.needs_consoleless_launcher:
|
|
return launch
|
|
if not launch.has("console_command"):
|
|
return launch
|
|
var selected := launch.duplicate(true)
|
|
selected["command"] = selected["console_command"]
|
|
selected["args"] = selected["console_args"]
|
|
selected.erase("console_command")
|
|
selected.erase("console_args")
|
|
return selected
|
|
|
|
|
|
static func _resolve_consoleless_python(
|
|
command: String, tier: String, discovery_override: Dictionary
|
|
) -> String:
|
|
## Data-only override keeps resolver tests independent of the host's Python.
|
|
if discovery_override.has("consoleless_python"):
|
|
return str(discovery_override["consoleless_python"])
|
|
|
|
## Venv/system console-script launchers normally keep pythonw beside their
|
|
## python.exe. The dev tier must use that exact interpreter so godot_ai is
|
|
## imported from the selected checkout rather than some unrelated install.
|
|
var sibling := command.get_base_dir().path_join("pythonw.exe")
|
|
if FileAccess.file_exists(sibling):
|
|
return sibling
|
|
if tier == "dev_venv":
|
|
return ""
|
|
|
|
## uvx may be installed without a PATH-visible CPython. Ask its sibling uv
|
|
## for the already-managed system interpreter; Godot AI's existing uvx
|
|
## server launch ensures one normally exists before client configuration.
|
|
if tier == "uvx":
|
|
var uv := command.get_base_dir().path_join("uv.exe")
|
|
if not FileAccess.file_exists(uv):
|
|
uv = CliFinder.find(["uv.exe"])
|
|
if not uv.is_empty():
|
|
var probe := McpCliExec.run(
|
|
uv, ["python", "find", "--system"], _DISCOVERY_TIMEOUT_MS, false
|
|
)
|
|
if int(probe.get("exit_code", -1)) == 0:
|
|
var python := str(probe.get("stdout", "")).strip_edges()
|
|
if not python.is_empty():
|
|
var managed_pythonw := python.get_base_dir().path_join("pythonw.exe")
|
|
if FileAccess.file_exists(managed_pythonw):
|
|
return managed_pythonw
|
|
|
|
## A system Python GUI launcher is sufficient for the non-dev bootstrap;
|
|
## it does not import godot_ai itself.
|
|
return CliFinder.find(["pythonw.exe"])
|
|
|
|
|
|
static func _system_version_from_probe(probe: Dictionary) -> Dictionary:
|
|
if int(probe.get("exit_code", -1)) != 0:
|
|
return {"ok": false}
|
|
var output := str(probe.get("stdout", "")).strip_edges()
|
|
var pattern := RegEx.new()
|
|
if pattern.compile("^godot-ai\\s+([^\\s]+)(?:\\s|$)") != OK:
|
|
return {"ok": false}
|
|
var matched := pattern.search(output)
|
|
if matched == null:
|
|
return {"ok": false}
|
|
return {"ok": true, "version": matched.get_string(1)}
|
|
|
|
|
|
static func _attach_discovery_error(message: String) -> Dictionary:
|
|
return {"ok": false, "error": message}
|
|
|
|
|
|
## Override for the dev-vs-user heuristic. Accepted values:
|
|
## "dev" — force dev-checkout mode (skip update check + self-install)
|
|
## "user" — force user-install mode (run update check, allow self-install)
|
|
## as long as the data-safety guard (addons_dir_is_symlink) passes
|
|
## other / unset — "auto": fall back to the .venv-proximity heuristic
|
|
##
|
|
## Use `user` to test the AssetLib self-update flow from inside a dev
|
|
## checkout (there's a .venv nearby but `addons/godot_ai` is a plain copy —
|
|
## e.g. after unpacking a release zip into `test_project/`).
|
|
##
|
|
## Two ways to set it, resolved in priority order:
|
|
## 1. EditorSettings → `godot_ai/mode_override` — set manually via
|
|
## Editor Settings (no dock UI writes it today); persists
|
|
## per-editor-install and wins over the env var so an editor-side
|
|
## choice always takes effect without relaunching.
|
|
## 2. Env var `GODOT_AI_MODE` — useful for CLI launches and CI.
|
|
const MODE_OVERRIDE_ENV := "GODOT_AI_MODE"
|
|
const MODE_OVERRIDE_SETTING := "godot_ai/mode_override"
|
|
|
|
|
|
static func mode_override() -> String:
|
|
# 1. EditorSetting wins — the user explicitly set it via Editor Settings.
|
|
# _editor_setting_lookup handles the `Engine.is_editor_hint()` gate
|
|
# (no-op in the game subprocess; see CLAUDE.md "Game-side code") and
|
|
# serves worker threads from a main-thread-warmed snapshot — this
|
|
# runs on the #678 startup walk's discovery worker, and
|
|
# EditorInterface/EditorSettings are not thread-safe (#691).
|
|
var setting: Variant = _editor_setting_lookup(MODE_OVERRIDE_SETTING)
|
|
if setting != null:
|
|
var setting_val := str(setting).strip_edges().to_lower()
|
|
if setting_val == "dev" or setting_val == "user":
|
|
return setting_val
|
|
# 2. Env var fallback. env_lookup, not OS.get_environment: same
|
|
# worker-thread reason (#691).
|
|
var raw := McpPathTemplate.env_lookup(MODE_OVERRIDE_ENV).strip_edges().to_lower()
|
|
if raw == "dev" or raw == "user":
|
|
return raw
|
|
return ""
|
|
|
|
|
|
static func is_dev_checkout() -> bool:
|
|
match mode_override():
|
|
"dev":
|
|
return true
|
|
"user":
|
|
return false
|
|
return not _find_venv_python().is_empty()
|
|
|
|
|
|
## Data-safety check for self-install: is `res://addons/godot_ai` a symbolic
|
|
## link? In a dev checkout this points at the canonical `plugin/` source
|
|
## tree, and writing files into it would clobber tracked source. This check
|
|
## is independent of `is_dev_checkout()` so a forced-user mode override
|
|
## still cannot extract a release zip over the symlink.
|
|
static func addons_dir_is_symlink() -> bool:
|
|
return _is_symlink(ProjectSettings.globalize_path("res://addons/godot_ai"))
|
|
|
|
|
|
## Mirrors the idiom used in `mcp_dock.gd::_resolve_plugin_symlink_target` —
|
|
## open the parent dir and ask Godot via `DirAccess.is_link()`, which
|
|
## handles symlinks on POSIX and reparse points on Windows natively.
|
|
static func _is_symlink(path: String) -> bool:
|
|
if path.is_empty():
|
|
return false
|
|
var dir := DirAccess.open(path.get_base_dir())
|
|
if dir == null:
|
|
## This is a data-safety guard (a symlinked addons dir is a dev
|
|
## checkout self-update must never write through). When the path
|
|
## exists but its parent can't be opened, we can't PROVE it isn't
|
|
## a link — fail closed and treat it as one (#711).
|
|
return DirAccess.dir_exists_absolute(path) or FileAccess.file_exists(path)
|
|
return dir.is_link(path)
|
|
|
|
|
|
## `refresh` forces uvx to re-fetch PyPI index metadata on spawn — used by
|
|
## `_start_server`'s one-shot retry when the first attempt exited fast with
|
|
## no pid-file on the uvx tier (stale-index-cache failure mode). No-op on
|
|
## other tiers: dev_venv and system resolve locally, so the flag has nowhere
|
|
## to go. See plugin.gd::_should_retry_with_refresh.
|
|
static func get_server_command(refresh: bool = false) -> Array[String]:
|
|
## `mode_override() == "user"` skips the dev_venv tier even when a nearby
|
|
## .venv exists — the override then becomes an actual workaround for
|
|
## the "user venv misidentified as dev checkout" bug, not just a
|
|
## cosmetic relabel.
|
|
if mode_override() != "user":
|
|
var venv_python := _cached_venv_python()
|
|
if not venv_python.is_empty():
|
|
print("MCP | using dev venv: %s" % venv_python)
|
|
return [venv_python, "-m", "godot_ai"]
|
|
|
|
var uvx := find_uvx()
|
|
if not uvx.is_empty():
|
|
var version := get_plugin_version()
|
|
## PEP 440 local build tags (e.g. 3.0.2+local.1) are not on PyPI.
|
|
## Pin uvx to the public base version so the server still boots;
|
|
## checkout-local extras need the dev_venv tier above
|
|
## (symlink/junction → repo .venv).
|
|
var pypi_version := _pypi_pin_version(version)
|
|
## Pin to the EXACT plugin version rather than `~=<minor>`. Under the
|
|
## tilde form, uvx was happy to reuse a cached tool env that matched
|
|
## the minor constraint — so an install that first spawned 1.2.0 kept
|
|
## using 1.2.0 even after 1.2.1/1.2.2 landed. Exact pinning makes the
|
|
## cache key version-specific: if the cached env matches, fast hit;
|
|
## otherwise uvx installs the exact version fresh. Keeps plugin and
|
|
## server version in lockstep without needing `--refresh-package` on
|
|
## every spawn. See issue #133.
|
|
if pypi_version != version:
|
|
print(
|
|
"MCP | using uvx (godot-ai==%s; local plugin %s not on PyPI)%s"
|
|
% [pypi_version, version, " [refresh]" if refresh else ""]
|
|
)
|
|
else:
|
|
print("MCP | using uvx (godot-ai==%s)%s" % [pypi_version, " [refresh]" if refresh else ""])
|
|
var cmd: Array[String] = [uvx]
|
|
if refresh:
|
|
cmd.append("--refresh")
|
|
cmd.append_array(["--from", "godot-ai==%s" % pypi_version, "godot-ai"])
|
|
return cmd
|
|
|
|
var system_cmd := _find_system_install()
|
|
if not system_cmd.is_empty():
|
|
print("MCP | using system install: %s" % system_cmd)
|
|
return [system_cmd]
|
|
|
|
push_warning("MCP | no server found — install uv or run: pip install godot-ai")
|
|
return []
|
|
|
|
|
|
## Which tier `get_server_command` would resolve to, without side-effects.
|
|
## Returned as a stable string so handshakes and session_list can expose it
|
|
## to MCP callers. Values track the `Literal` on the Python side.
|
|
static func get_server_launch_mode() -> String:
|
|
if mode_override() != "user" and not _cached_venv_python().is_empty():
|
|
return "dev_venv"
|
|
if not find_uvx().is_empty():
|
|
return "uvx"
|
|
if not _find_system_install().is_empty():
|
|
return "system"
|
|
return "unknown"
|
|
|
|
|
|
static func find_uvx() -> String:
|
|
return CliFinder.find(_uvx_cli_names())
|
|
|
|
|
|
static func _uvx_cli_names() -> Array[String]:
|
|
var names: Array[String] = []
|
|
names.append("uvx.exe" if OS.get_name() == "Windows" else "uvx")
|
|
return names
|
|
|
|
|
|
## Drop the `CliFinder` cache for the platform-specific uvx binary
|
|
## name. Pairs with `invalidate_uv_version_cache()` so the dock's
|
|
## `_on_install_uv` can refresh both caches with one call each. The
|
|
## OS-specific name matters: Windows caches under `uvx.exe`, every
|
|
## other platform under `uvx`; hard-coding `"uvx"` here would leave
|
|
## the CLI-path cache stale on Windows after a fresh install and the
|
|
## dock would keep showing "uv: not found" for the rest of the session.
|
|
static func invalidate_uvx_cli_cache() -> void:
|
|
for name in _uvx_cli_names():
|
|
CliFinder.invalidate(name)
|
|
|
|
|
|
## Drop the entire `CliFinder` cache. Called from any explicit-user-action
|
|
## refresh path (`force=true` in `_request_client_status_refresh` — manual
|
|
## Refresh button, popup-open, compat wrapper, future external API) so a
|
|
## freshly-installed CLI (claude, codex, gemini, …) gets detected without
|
|
## an editor restart. Per-CLI invalidation (`invalidate_uvx_cli_cache`) is
|
|
## preferred when the dock knows which binary changed; this catch-all
|
|
## handles the "any CLI may have been installed since the last sweep" case.
|
|
##
|
|
## Thread safety: `CliFinder.invalidate()` guards `_cache` / `_searched`
|
|
## with a mutex so it can race safely against worker threads calling
|
|
## `find()` from `_run_client_action_worker`. The mutex is held only
|
|
## across the dictionary clear, never across the bounded subprocess lookup,
|
|
## so this call can never block the main thread on a subprocess.
|
|
static func invalidate_cli_cache() -> void:
|
|
CliFinder.invalidate()
|
|
|
|
|
|
static var _uv_version_cache: String = ""
|
|
static var _uv_version_searched: bool = false
|
|
|
|
|
|
## Cached for the editor session. The dock's `_refresh_setup_status`
|
|
## (called via `call_deferred` from `_build_ui`) calls this on the
|
|
## main thread in user mode, so the cold `uvx --version` probe is
|
|
## wall-clock bounded and cached. Subsequent calls (focus-in refresh,
|
|
## manual Refresh clicks) reuse the cached string.
|
|
##
|
|
## Invalidate via `invalidate_uv_version_cache()` when the user
|
|
## installs / reinstalls uv via the dock so the next refresh reflects
|
|
## the new install. The dock's `_on_install_uv` calls this alongside
|
|
## `CliFinder.invalidate("uvx")` to clear both the path cache and
|
|
## the version cache in one place.
|
|
static func check_uv_version() -> String:
|
|
if _uv_version_searched:
|
|
return _uv_version_cache
|
|
var uvx := find_uvx()
|
|
if uvx.is_empty():
|
|
_uv_version_searched = true
|
|
_uv_version_cache = ""
|
|
return ""
|
|
var result := McpCliExec.run(uvx, ["--version"], _DISCOVERY_TIMEOUT_MS, false)
|
|
if int(result.get("exit_code", -1)) == 0:
|
|
var lines := PackedStringArray(str(result.get("stdout", "")).split("\n"))
|
|
_uv_version_cache = lines[0].strip_edges() if lines.size() > 0 else ""
|
|
else:
|
|
_uv_version_cache = ""
|
|
_uv_version_searched = true
|
|
return _uv_version_cache
|
|
|
|
|
|
static func invalidate_uv_version_cache() -> void:
|
|
_uv_version_searched = false
|
|
_uv_version_cache = ""
|
|
|
|
|
|
## True when a probe has run this session and came back empty — i.e. the
|
|
## dock is currently rendering "uv: not found". Lets callers decide when
|
|
## a re-probe is worth paying for (server-connect transition, manual
|
|
## Refresh) without ever re-probing once uv has been found.
|
|
static func uv_probe_negative() -> bool:
|
|
return _uv_version_searched and _uv_version_cache.is_empty()
|
|
|
|
|
|
## Drop both uv caches — the resolved uvx path AND the cached
|
|
## `uvx --version` output — so the next check_uv_version() re-runs the
|
|
## full detection. #739: a probe that fails once at editor startup
|
|
## (contended spawn, cold Defender scan, stale PATH under a
|
|
## Steam-launched editor) used to pin "uv: not found" for the whole
|
|
## session; the Install-uv click was the only invalidation path. Callers
|
|
## invoke this on events that suggest the failure was transient.
|
|
static func invalidate_uv_detection() -> void:
|
|
invalidate_uvx_cli_cache()
|
|
invalidate_uv_version_cache()
|
|
_attach_launch_cache_mutex.lock()
|
|
_attach_launch_cache.clear()
|
|
_attach_launch_cache_mutex.unlock()
|
|
|
|
|
|
static var _venv_python_cache: String = ""
|
|
static var _venv_python_searched: bool = false
|
|
## #678 worker threads write this cache while main-thread callers read
|
|
## it; same lock discipline as McpCliFinder (clients/_cli_finder.gd).
|
|
static var _venv_mutex: Mutex = Mutex.new()
|
|
static var _attach_launch_cache := {}
|
|
static var _attach_launch_cache_mutex := Mutex.new()
|
|
|
|
|
|
static func _attach_launch_cache_key(launch_context: Dictionary) -> String:
|
|
return JSON.stringify([
|
|
launch_context.get("http_port", null),
|
|
launch_context.get("ws_port", null),
|
|
launch_context.get("excluded_domains", null),
|
|
launch_context.get("plugin_version", null),
|
|
launch_context.get("allow_dev_venv", null),
|
|
launch_context.get("platform", null),
|
|
launch_context.get("telemetry_enabled", null),
|
|
])
|
|
|
|
|
|
static func _cached_venv_python() -> String:
|
|
_venv_mutex.lock()
|
|
if not _venv_python_searched:
|
|
_venv_python_cache = _find_venv_python()
|
|
_venv_python_searched = true
|
|
var cached := _venv_python_cache
|
|
_venv_mutex.unlock()
|
|
return cached
|
|
|
|
|
|
## Absolute path to `res://addons/godot_ai`, resolving Windows junctions /
|
|
## POSIX symlinks via `DirAccess.read_link`. Unresolved globalize_path only
|
|
## walks the *logical* project path (e.g. MyGame/addons/godot_ai → MyGame)
|
|
## and never reaches a fork checkout's `.venv` (…/godot-ai/.venv).
|
|
static func resolve_addons_realpath() -> String:
|
|
var addons_path := ProjectSettings.globalize_path("res://addons/godot_ai").rstrip("/").rstrip("\\")
|
|
if addons_path.is_empty():
|
|
return ""
|
|
var parent := addons_path.get_base_dir()
|
|
var dir := DirAccess.open(parent)
|
|
if dir != null and dir.is_link(addons_path):
|
|
var target := dir.read_link(addons_path)
|
|
if not target.is_empty():
|
|
if target.is_relative_path():
|
|
target = parent.path_join(target).simplify_path()
|
|
return target.rstrip("/").rstrip("\\")
|
|
return addons_path
|
|
|
|
|
|
static func _find_venv_python() -> String:
|
|
## Optional hard override (junction edge cases / CI).
|
|
var env_py := McpPathTemplate.env_lookup("GODOT_AI_VENV_PYTHON").strip_edges()
|
|
if not env_py.is_empty():
|
|
if FileAccess.file_exists(env_py):
|
|
return env_py
|
|
## An explicit override pointing nowhere is a misconfiguration the
|
|
## user needs to see — falling through silently would make the dev
|
|
## venv appear randomly ignored.
|
|
push_warning(
|
|
"godot-ai: GODOT_AI_VENV_PYTHON is set but no file exists at '%s'; ignoring override."
|
|
% env_py
|
|
)
|
|
## 1) Walk up from the open project (classic monorepo / test_project layout).
|
|
var from_project := _find_venv_python_in(
|
|
ProjectSettings.globalize_path("res://").rstrip("/").rstrip("\\")
|
|
)
|
|
if not from_project.is_empty():
|
|
return from_project
|
|
## 2) Junctioned plugin: resolve reparse target, then walk up to fork root.
|
|
var addons_real := resolve_addons_realpath()
|
|
if not addons_real.is_empty():
|
|
var from_addons := _find_venv_python_in(addons_real)
|
|
if not from_addons.is_empty():
|
|
return from_addons
|
|
return ""
|
|
|
|
|
|
## Pure path-based lookup so tests can drive it with a scratch dir instead of
|
|
## monkey-patching `res://`. Only treats a `.venv/bin/python` as a godot-ai dev
|
|
## venv if a sibling `src/godot_ai/` exists in the same parent dir — otherwise
|
|
## an unrelated user venv (e.g. `~/.venv` from a data-science side project)
|
|
## gets picked up and `python -m godot_ai` fails with ModuleNotFoundError about
|
|
## 5s into startup, cascading into an infinite reconnect loop. The retry-with-
|
|
## refresh recovery in `plugin.gd::_should_retry_with_refresh` only fires on
|
|
## the uvx tier, so the dev_venv misidentification has no escape hatch — the
|
|
## detection has to be right the first time.
|
|
static func _find_venv_python_in(start_dir: String) -> String:
|
|
var dir := start_dir.rstrip("/").rstrip("\\")
|
|
var python_name := "python" if OS.get_name() != "Windows" else "python.exe"
|
|
var venv_dir := ".venv/bin/" if OS.get_name() != "Windows" else ".venv/Scripts/"
|
|
## 8 hops: game project roots are shallow; junctioned plugins sit at
|
|
## <repo>/plugin/addons/godot_ai (4) and nested worktrees may be deeper.
|
|
for i in 8:
|
|
var venv_path := dir.path_join(venv_dir + python_name)
|
|
if FileAccess.file_exists(venv_path) and DirAccess.dir_exists_absolute(dir.path_join("src/godot_ai")):
|
|
return venv_path
|
|
var parent := dir.get_base_dir()
|
|
if parent == dir or parent.is_empty():
|
|
break
|
|
dir = parent
|
|
return ""
|
|
|
|
|
|
## Walk up from `start_dir` looking for a sibling `src/godot_ai/` — returns
|
|
## the absolute path of the enclosing `src/` dir, or "". Used by the dev
|
|
## server launcher to prepend the caller's own source to PYTHONPATH so a
|
|
## worktree-launched editor serves the worktree's Python, not the root
|
|
## repo's editable install. See #84.
|
|
static func find_worktree_src_dir(start_dir: String) -> String:
|
|
var dir := start_dir.rstrip("/")
|
|
for i in 5:
|
|
var candidate := dir.path_join("src/godot_ai")
|
|
if DirAccess.dir_exists_absolute(candidate):
|
|
return dir.path_join("src")
|
|
var parent := dir.get_base_dir()
|
|
if parent == dir:
|
|
break
|
|
dir = parent
|
|
return ""
|
|
|
|
|
|
## Delegates to McpCliFinder rather than shelling out to which/where
|
|
## directly: the finder adds the well-known-install-dirs and login-shell
|
|
## PATH tiers plus its per-exe cache, and this drops the private
|
|
## `_pick_best_path` cross-class reach (#711).
|
|
static func _find_system_install() -> String:
|
|
## Built with append, not a ternary of untyped literals — assigning a
|
|
## ternary's Array to Array[String] is a runtime error on newer Godot
|
|
## builds (same idiom as _uvx_cli_names above).
|
|
var names: Array[String] = ["godot-ai"]
|
|
if OS.get_name() == "Windows":
|
|
names.push_front("godot-ai.exe")
|
|
return CliFinder.find(names)
|