Files
g-world/addons/godot_ai/client_configurator.gd
T
2026-08-10 20:39:06 +03:00

1389 lines
59 KiB
GDScript

@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)