Files
agame/addons/godot_ai/mcp_dock.gd
T
2026-09-22 16:07:50 +08:00

2884 lines
118 KiB
GDScript

@tool
class_name McpDock
extends VBoxContainer
## Editor dock panel showing MCP connection status, client config, and command log.
##
## Audit-v2 #360 partial extraction. Two cohesive subpanels live in
## res://addons/godot_ai/dock_panels/:
## - log_viewer.gd: MCP request/response log (dev-mode only).
## - port_picker_panel.gd: spawn-failure escape hatch nested in the crash panel.
##
## The audit also called for ServerStatusPanel and ClientRowController
## extractions; those were *deliberately deferred*. Their UI scatters across
## the dock layout (status icon at top, crash panel mid, setup section lower;
## client rows + drift banner + scroll grid spread similarly), so a clean
## extract-by-panel needs either visible UI reorganization or a coordinator-
## Node pattern with property-accessor façades on McpDock that re-tangle the
## very state they claim to move.
##
## Client workers and update orchestration have plugin lifetime and are owned
## by plugin.gd. This replaceable view emits intents and paints copied
## snapshots/outcomes; detaching it cannot destroy domain work.
const ServerStateScript := preload("res://addons/godot_ai/utils/mcp_server_state.gd")
const ClientRefreshStateScript := preload("res://addons/godot_ai/utils/mcp_client_refresh_state.gd")
const Client := preload("res://addons/godot_ai/clients/_base.gd")
const PortResolver := preload("res://addons/godot_ai/utils/port_resolver.gd")
const ClientConfigurator := preload("res://addons/godot_ai/client_configurator.gd")
const ClientRegistry := preload("res://addons/godot_ai/clients/_registry.gd")
const ToolCatalog := preload("res://addons/godot_ai/tool_catalog.gd")
const LogViewerScript := preload("res://addons/godot_ai/dock_panels/log_viewer.gd")
const PortPickerPanelScript := preload("res://addons/godot_ai/dock_panels/port_picker_panel.gd")
const VisionRoutingScript := preload("res://addons/godot_ai/vision_routing.gd")
const DEV_MODE_SETTING := "godot_ai/dev_mode"
## "Change the port + reconfigure your clients" guide. Surfaced from the crash
## panel when a foreign process holds the HTTP port — the one piece of recovery
## (per-client config rewrite) that doesn't fit in the inline crash body.
## Resolved against the installed plugin version at click time (see
## `_port_conflict_docs_url`) so a shipped build opens the guide as it shipped,
## not tip-of-main, which may have drifted from that build's UI.
const PORT_CONFLICT_DOCS_PATH := "docs/port-conflicts.md"
const REPO_BLOB_BASE := "https://github.com/hi-godot/godot-ai/blob"
const RELEASES_PAGE := "https://github.com/hi-godot/godot-ai/releases/latest"
## Opened by the "How to install uv" button. See _on_install_uv for why the
## dock links here instead of running an installer itself.
const UV_INSTALL_DOCS_URL := "https://docs.astral.sh/uv/getting-started/installation/"
static var COLOR_MUTED := Color(0.7, 0.7, 0.7)
static var COLOR_HEADER := Color(0.95, 0.95, 0.95)
## Used for "in-progress" / "stale, action needed" UI: the startup-grace
## status icon, the spawn-failure suggested-port hint, the drift banner,
## and the per-row mismatch dot. One constant so a future palette tweak
## doesn't have to find every literal.
static var COLOR_AMBER := Color(1.0, 0.75, 0.25)
signal update_requested
signal client_action_requested(client_id: String, action: String)
signal client_action_cancel_requested(client_id: String)
signal client_status_refresh_requested(client_ids: Array[String], force: bool)
signal status_snapshot_requested
signal live_server_probe_requested(port: int)
signal lifecycle_action_requested(action: int)
signal dev_server_action_requested(action: int)
signal mcp_logging_changed(enabled: bool)
signal log_snapshot_requested(after_sequence: int)
signal plugin_reload_requested(reason: String)
signal settings_apply_requested(changes: Dictionary, reload: bool)
signal post_update_action_requested(action: String)
enum LifecycleAction { RECOVER_INCOMPATIBLE, RESTART_SERVER }
enum DevServerAction { START_OR_RESTART, STOP }
## Copied root-owned values only. The Dock never retains Connection, lifecycle,
## plugin, log-buffer, or process owners.
var _transport_snapshot: Dictionary = {
"connected": false,
"server_version": "",
"status": {},
}
var _lifecycle_snapshot: Dictionary = {
"state": ServerStateScript.UNINITIALIZED,
"server_pid": -1,
"resolved_ws_port": 0,
"can_restart_managed": false,
"can_recover_incompatible": false,
"normal_start_released": false,
}
var _live_server_probe_result: Dictionary = {}
# Always visible
var _redock_btn: Button
var _status_icon: ColorRect
var _status_label: Label
var _body_scroll: ScrollContainer
var _body: VBoxContainer
var _client_grid: VBoxContainer
var _client_configure_all_btn: Button
var _client_empty_cta_btn: Button
var _clients_summary_label: Label
var _clients_window: Window
var _dev_mode_toggle: CheckButton
var _install_label: Label
# Tools tab (secondary window, Tab 2) — domain-exclusion UI for clients
# that cap total tool count (Antigravity: 100). Pending set is mutated by
# checkbox clicks; saved set reflects what the spawned server actually
# sees. `Apply and Restart Server` writes pending → setting and triggers a
# plugin reload so the new server comes up with the trimmed list.
var _tools_pending_excluded: PackedStringArray = PackedStringArray()
var _tools_saved_excluded: PackedStringArray = PackedStringArray()
## Custom (addon-registered) tools list — rebuilt live on registry
## tools_changed; per-tool checkboxes apply immediately (no restart).
var _custom_tools_list: VBoxContainer
var _custom_tools_count_label: Label
var _tools_domain_checkboxes: Dictionary = {}
var _tools_count_label: Label
var _tools_apply_btn: Button
var _tools_reset_btn: Button
var _tools_dirty_warning: Label
var _tools_close_confirm: ConfirmationDialog
## Update saves the project and relaunches the editor; the user says so first.
var _update_confirm: ConfirmationDialog
var _update_candidate_version := ""
var _telemetry_toggle: CheckButton
var _telemetry_pending_enabled: bool = true
var _telemetry_saved_enabled: bool = true
# Settings tab (secondary window, Tab 3) — Vision Routing section plus the
# LAN opt-in (#507): "Allow remote hosts (CIDR)" behind a collapsed
# "Remote access (advanced)" disclosure (auto-expands when a non-empty
# allowlist is configured). The value feeds `--allow-host` at server spawn
# (see plugin.gd::_build_server_flags). The LineEdit's live text is the
# pending state; `_allow_hosts_saved` mirrors the persisted EditorSetting,
# same pending/saved shape as the Tools tab above.
var _allow_hosts_section: VBoxContainer
var _allow_hosts_fold: FoldableContainer
var _allow_hosts_edit: LineEdit
var _allow_hosts_hint: Label
var _allow_hosts_apply_btn: Button
var _allow_hosts_saved: String = ""
## Per-client UI handles, keyed by client id. Each entry holds the row's
## status dot, configure/remove buttons, config-file buttons, and manual panel.
var _client_rows: Dictionary = {}
# Drift banner — surfaced near the Clients section when one or more clients
# have a stored entry whose URL no longer matches `http_url()` (typical after
# the user changes `godot_ai/http_port`). Refreshes are stale-while-refreshing:
# cached row dots/banner remain visible while a background worker performs the
# potentially blocking config/CLI probes, then the main thread applies results.
# Automatic focus-in refreshes use a short cooldown to avoid repeated sweeps
# during tab-away/tab-back churn. See #166 and #226.
var _drift_banner: VBoxContainer
var _drift_label: Label
## Set when the user clicks "How to install uv"; consumed by the next
## application focus-in so the uv row is re-probed after the user has had a
## chance to install, not immediately. See _on_install_uv and _notification.
## (Deliberately spelled without the focus-in constant name: the guard in
## tests/unit/test_editor_focus_refocus.py locates the notification handler
## by first occurrence of that token.)
var _uv_recheck_pending := false
## Handles for the Setup section's "Server" row. `_update_status_label` keeps
## the label text/color in sync with `McpConnection.server_version` so the
## dock reports the TRUE running server version, not the plugin's
## expected version. See #174 follow-up — a plugin upgrade via self-
## update can leave the plugin connected to an older adopted server
## (foreign-port branch never sets `_server_pid`, so `_stop_server`
## can't kill it); the line has to show the mismatch honestly.
var _setup_server_label: Label
## Last rendered server-version string. `_update_status_label` runs every
## frame; early-outs text repaint when nothing changed. Empty means
## "no line rendered yet" (dev-checkout branch doesn't render a
## user-mode Server line).
var _last_rendered_server_text: String = ""
## Restart-server button shown next to the Setup container when
## `McpConnection.server_version` drifts from the plugin version. Hidden
## in the match case so the UI stays calm.
var _version_restart_btn: Button
var _server_restart_in_progress := false
## Sorted snapshot of the most recent mismatched-client set. Powers two things:
## (a) the Reconfigure button reuses this list instead of re-running
## `check_status` per row (saves ~18 filesystem reads per click), and
## (b) `_refresh_drift_banner` early-returns when the set is unchanged so
## repeated explicit refreshes don't repaint identical text. Mirrors the
## `_last_server_status` pattern used by the crash panel.
var _last_mismatched_ids: Array[String] = []
## Copied plugin-lifetime client-job state. This contains no Thread, mutex,
## cancellation map, generation, or mutable owner record.
var _client_work_snapshot: Dictionary = {
"accepting_work": false,
"refresh_state": ClientRefreshStateScript.IDLE,
"refresh_completed": false,
"busy_actions": [],
"action_names": {},
"action_phases": {},
}
var _update_install_in_flight := false
# Dev-mode only
var _dev_section: VBoxContainer
var _server_label: Label
var _reload_btn: Button
var _setup_section: VBoxContainer
var _setup_container: VBoxContainer
## Developer controls are a view of the ordinary lifecycle owner. They never
## start a second process topology or kill an externally launched server.
var _dev_primary_btn: Button
## Small "✕" affordance next to the primary — enabled only for the exact
## plugin-owned process grant.
var _dev_stop_btn: Button
var _log_viewer: LogViewerScript
## Vision Routing (optional) - set by plugin.gd; builds the "Vision Routing"
## tab in Clients & Tools and the quick toggle under Developer mode.
var vision_routing: VisionRoutingScript = null
var _last_connected := false
var _last_status_text := ""
var _last_status_tooltip := ""
var _startup_grace_until_msec: int = 0
# Spawn-failure panel — rendered when `get_server_status` reports a
# non-OK `state`. One panel, one body paragraph per state, no cascading
# booleans. See `_crash_body_for_state`.
var _crash_panel: VBoxContainer
var _crash_output: RichTextLabel
var _crash_restart_btn: Button
var _crash_reload_btn: Button
## Help link — visible only for the genuinely-foreign-occupant INCOMPATIBLE
## case (no `can_recover_incompatible` proof). The inline body names a free
## port; this button carries the per-client reconfigure steps that don't fit
## inline. See `PORT_CONFLICT_DOCS` and `_update_crash_panel`.
var _crash_docs_btn: Button
## Port-picker escape hatch — visible inside the crash panel when the root
## cause is port contention (PORT_EXCLUDED or FOREIGN_PORT). The dock writes
## the EditorSetting and reloads the plugin in response to the panel's
## `port_apply_requested` signal.
var _port_picker_panel: PortPickerPanelScript
## Last status Dict rendered into the panel — used to skip re-population
## when nothing changed, which would otherwise reset the user's scroll
## position on every frame. GDScript Dicts compare by value with `==`.
var _last_server_status: Dictionary = {}
# First-run grace: uvx installs 60+ Python packages on first run (can take
# 10-30s on a slow connection). Don't scare users with "Disconnected" during
# that window — show "Starting server…" instead. After this expires, fall
# back to the normal disconnect UI.
const STARTUP_GRACE_MSEC := 60 * 1000
# Update banner — visible UI only. Release polling, ZIP download, and
# installation remain root-owned and arrive here as copied presentation state.
var _update_banner: VBoxContainer
var _update_label: Label
var _update_status_label: Label
var _update_btn: Button
## The button is an action, never a status line: progress and failure text
## goes to `_update_status_label`, and the button only enables or disables.
const _UPDATE_ACTION_TEXT := "Update"
const _UPDATE_LABEL_COLOR := Color(1.0, 0.85, 0.3)
var _post_update_action := ""
## True from the moment an update starts its client migration until the
## server it then starts is connected. The transport reads "blocked" for
## that whole window (the migration barrier, then the launch), which is
## not a fault: name the phase instead of alarming the user (#999).
var _post_update_server_pending := false
func _ready() -> void:
_startup_grace_until_msec = Time.get_ticks_msec() + STARTUP_GRACE_MSEC
_build_ui()
func _process(_delta: float) -> void:
_update_status()
if _log_viewer != null and _log_viewer.visible:
log_snapshot_requested.emit(_log_viewer.sequence())
func present_transport_snapshot(snapshot: Dictionary) -> void:
_transport_snapshot = snapshot.duplicate(true)
func present_lifecycle_snapshot(snapshot: Dictionary) -> void:
_lifecycle_snapshot = snapshot.duplicate(true)
func present_live_server_probe_result(result: Dictionary) -> void:
_live_server_probe_result = result.duplicate(true)
func present_log_snapshot(snapshot: Dictionary) -> void:
if _log_viewer != null:
_log_viewer.present_snapshot(snapshot)
func _notification(what: int) -> void:
# Detect dock/undock by watching for reparenting events.
if what == NOTIFICATION_PARENTED or what == NOTIFICATION_UNPARENTED:
_update_redock_visibility.call_deferred()
elif what == NOTIFICATION_APPLICATION_FOCUS_IN:
if _should_refresh_client_statuses_on_focus_in():
_request_client_status_refresh(false)
## Re-probe uv only when the user actually went off to install it
## (see _on_install_uv). `check_uv_version()` is cached, so an
## ungated refresh here would usually be free — but after the
## button invalidated that cache it costs one blocking
## `uvx --version`, and this notification must not grow a probe on
## the common focus-in path. One-shot: clear before refreshing.
if _uv_recheck_pending:
_uv_recheck_pending = false
_refresh_setup_status.call_deferred()
## Godot can leave its shared progress dialog under one of our modal windows.
## Return it before removing the dock: freeing it leaves the editor's pointer dangling.
func release_editor_progress_dialog() -> void:
if not is_inside_tree():
return
for dialog in find_children("*", "ProgressDialog", true, false):
dialog.reparent(get_tree().root)
func _should_refresh_client_statuses_on_focus_in() -> bool:
## Focus-in is part of Godot/editor window activation. Keep automatic refresh,
## but only through the async/cooldown-protected path; never run a blocking
## client-status sweep directly from this notification.
return true
func _is_floating() -> bool:
var p := get_parent()
while p != null:
if p is Window:
return p != get_tree().root
p = p.get_parent()
return false
func _update_redock_visibility() -> void:
if _redock_btn == null:
return
var floating := _is_floating()
if _redock_btn.visible != floating:
_redock_btn.visible = floating
func _on_redock() -> void:
# When floating, our Window is NOT the editor root. Closing it triggers
# Godot's internal dock-return logic (same as clicking the window's X).
var win := get_window()
if win != null and win != get_tree().root:
win.close_requested.emit()
func _build_margin_container(margin: int = 12) -> MarginContainer:
var margin_container := MarginContainer.new()
margin_container.add_theme_constant_override("margin_left", margin)
margin_container.add_theme_constant_override("margin_right", margin)
margin_container.add_theme_constant_override("margin_top", margin)
margin_container.add_theme_constant_override("margin_bottom", margin)
return margin_container
func _build_ui() -> void:
add_theme_constant_override("separation", 8)
# --- Top row: status indicator + redock button (when floating) ---
var status_row := HBoxContainer.new()
status_row.add_theme_constant_override("separation", 8)
_status_icon = ColorRect.new()
_status_icon.custom_minimum_size = Vector2(14, 14)
# Amber on first paint — matches the "Starting server…" label text and
# distinguishes from a real disconnect (red).
_status_icon.color = COLOR_AMBER
var icon_center := CenterContainer.new()
icon_center.add_child(_status_icon)
status_row.add_child(icon_center)
_status_label = Label.new()
# Start in grace state — _update_status_label will take over on the next frame
# once the connection is available. Never show bare "Disconnected" on
# first paint because that's misleading while the server is still
# spinning up.
_status_label.text = "Starting server…"
_status_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
status_row.add_child(_status_label)
_redock_btn = Button.new()
_redock_btn.text = "Dock"
_redock_btn.tooltip_text = "Return this panel to the editor dock"
_redock_btn.visible = false
_redock_btn.pressed.connect(_on_redock)
status_row.add_child(_redock_btn)
add_child(status_row)
# Install-mode line — so a git-clone user doesn't press the yellow Update
# banner below and silently downgrade from main to the last release tag.
# See #144.
_install_label = Label.new()
_install_label.add_theme_color_override("font_color", COLOR_MUTED)
_install_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
_install_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_install_label.text = _install_mode_text()
_install_label.tooltip_text = _install_mode_tooltip()
_install_label.mouse_filter = Control.MOUSE_FILTER_STOP
add_child(_install_label)
_body_scroll = ScrollContainer.new()
_body_scroll.name = "DockBodyScroll"
_body_scroll.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_body_scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL
_body_scroll.custom_minimum_size = Vector2(0, 48)
_body_scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED
add_child(_body_scroll)
_body = VBoxContainer.new()
_body.name = "DockBody"
_body.add_theme_constant_override("separation", 8)
_body.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_body_scroll.add_child(_body)
# --- Spawn-failure panel (shown when `_start_server` reports a non-OK
# state via `get_server_status`). One body paragraph + the matching
# action; the top status label already carries the state headline.
_crash_panel = VBoxContainer.new()
_crash_panel.add_theme_constant_override("separation", 6)
_crash_panel.visible = false
_crash_output = RichTextLabel.new()
_crash_output.custom_minimum_size = Vector2(0, 60)
_crash_output.bbcode_enabled = false
_crash_output.selection_enabled = true
_crash_output.scroll_following = false
_crash_output.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
_crash_output.fit_content = true
_crash_panel.add_child(_crash_output)
_port_picker_panel = PortPickerPanelScript.new()
_port_picker_panel.setup()
_port_picker_panel.port_apply_requested.connect(_on_port_apply_requested)
_crash_panel.add_child(_port_picker_panel)
_crash_restart_btn = Button.new()
_crash_restart_btn.text = "Restart Server"
_crash_restart_btn.tooltip_text = "Stop the old server on this port and start the bundled godot-ai server"
_crash_restart_btn.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_crash_restart_btn.add_theme_color_override("font_color", Color.WHITE)
_crash_restart_btn.add_theme_color_override("font_hover_color", Color.WHITE)
_crash_restart_btn.add_theme_color_override("font_pressed_color", Color.WHITE)
_crash_restart_btn.pressed.connect(_on_restart_stale_server)
_crash_restart_btn.visible = false
_crash_panel.add_child(_crash_restart_btn)
_crash_reload_btn = Button.new()
_crash_reload_btn.text = "Reload Plugin"
_crash_reload_btn.tooltip_text = "Re-run the spawn after fixing the underlying issue"
_crash_reload_btn.pressed.connect(_on_reload_plugin)
_crash_panel.add_child(_crash_reload_btn)
_crash_docs_btn = Button.new()
_crash_docs_btn.text = "How to change the port"
_crash_docs_btn.tooltip_text = "Open the guide: change ports in Godot AI settings and reconfigure your MCP clients"
_crash_docs_btn.visible = false
_crash_docs_btn.pressed.connect(func(): OS.shell_open(_port_conflict_docs_url()))
_crash_panel.add_child(_crash_docs_btn)
_crash_panel.add_child(HSeparator.new())
_body.add_child(_crash_panel)
# --- Update banner (top of dock, hidden until check finds a newer version) ---
_update_banner = VBoxContainer.new()
_update_banner.add_theme_constant_override("separation", 4)
_update_banner.visible = false
_update_label = Label.new()
_update_label.add_theme_font_size_override("font_size", 15)
_update_label.add_theme_color_override("font_color", _UPDATE_LABEL_COLOR)
## Wrap long banner text (e.g. the < 4.5 support-floor guidance) instead
## of letting a single line stretch the whole dock wide. The dock is a
## fixed-width side panel, so constrain horizontally and wrap.
_update_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
_update_label.size_flags_horizontal = Control.SIZE_FILL
_update_label.custom_minimum_size = Vector2(0, 0)
_update_banner.add_child(_update_label)
_update_status_label = Label.new()
_update_status_label.add_theme_font_size_override("font_size", 13)
_update_status_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
_update_status_label.size_flags_horizontal = Control.SIZE_FILL
_update_status_label.custom_minimum_size = Vector2(0, 0)
_update_status_label.visible = false
_update_banner.add_child(_update_status_label)
var update_btn_row := HBoxContainer.new()
update_btn_row.add_theme_constant_override("separation", 6)
_update_btn = Button.new()
_update_btn.text = _UPDATE_ACTION_TEXT
_update_btn.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_update_btn.pressed.connect(_on_update_pressed)
update_btn_row.add_child(_update_btn)
var release_link := Button.new()
release_link.text = "Release notes"
release_link.pressed.connect(func(): OS.shell_open(RELEASES_PAGE))
update_btn_row.add_child(release_link)
_update_banner.add_child(update_btn_row)
_update_banner.add_child(HSeparator.new())
_body.add_child(_update_banner)
# --- Dev-only connection extras (server label + reload button) ---
_dev_section = VBoxContainer.new()
_dev_section.add_theme_constant_override("separation", 6)
_body.add_child(_dev_section)
_server_label = Label.new()
_server_label.add_theme_color_override("font_color", COLOR_MUTED)
_dev_section.add_child(_server_label)
_refresh_server_label()
var btn_row := HBoxContainer.new()
btn_row.add_theme_constant_override("separation", 6)
_reload_btn = Button.new()
_reload_btn.text = "Dev: Reload Plugin"
_reload_btn.tooltip_text = "Developer utility: reload the GDScript plugin. This does not restart or replace the server."
_reload_btn.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_reload_btn.pressed.connect(_on_reload_plugin)
btn_row.add_child(_reload_btn)
_dev_section.add_child(btn_row)
# --- Setup section (dev-only or when uv missing) ---
_setup_section = VBoxContainer.new()
_setup_section.add_theme_constant_override("separation", 6)
_body.add_child(_setup_section)
_setup_section.add_child(HSeparator.new())
_setup_section.add_child(_make_header("Setup"))
_setup_container = VBoxContainer.new()
_setup_container.add_theme_constant_override("separation", 6)
_setup_section.add_child(_setup_container)
_body.add_child(HSeparator.new())
# --- Clients ---
var clients_header_row := HBoxContainer.new()
clients_header_row.add_theme_constant_override("separation", 8)
var clients_header := _make_header("Clients")
clients_header_row.add_child(clients_header)
_clients_summary_label = Label.new()
_clients_summary_label.add_theme_color_override("font_color", COLOR_MUTED)
_clients_summary_label.clip_text = true
_clients_summary_label.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS
_clients_summary_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
clients_header_row.add_child(_clients_summary_label)
var clients_actions := HFlowContainer.new()
clients_actions.add_theme_constant_override("h_separation", 8)
clients_actions.add_theme_constant_override("v_separation", 4)
var clients_refresh_btn := Button.new()
clients_refresh_btn.text = "Refresh"
clients_refresh_btn.tooltip_text = "Refresh client status in the background. Cached status stays visible while checks run."
clients_refresh_btn.pressed.connect(_on_refresh_clients_pressed)
clients_actions.add_child(clients_refresh_btn)
var clients_open_btn := Button.new()
clients_open_btn.text = "Clients & Tools"
clients_open_btn.tooltip_text = "Open the Clients & Tools window — configure AI clients, choose telemetry preferences, or disable tool domains to fit under a client's hard tool-count cap (e.g. Antigravity's 100)."
clients_open_btn.pressed.connect(_on_open_clients_window)
clients_actions.add_child(clients_open_btn)
_body.add_child(clients_header_row)
_body.add_child(clients_actions)
_client_empty_cta_btn = Button.new()
_client_empty_cta_btn.text = "Configure an AI client ->"
_client_empty_cta_btn.tooltip_text = "Open the Clients tab to configure an AI coding client for this Godot AI server."
_client_empty_cta_btn.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_client_empty_cta_btn.visible = false
_client_empty_cta_btn.pressed.connect(_on_open_clients_window)
_body.add_child(_client_empty_cta_btn)
# Drift banner — hidden until a sweep finds at least one mismatched client.
_drift_banner = VBoxContainer.new()
_drift_banner.add_theme_constant_override("separation", 4)
_drift_banner.visible = false
_drift_label = Label.new()
_drift_label.add_theme_color_override("font_color", COLOR_AMBER)
_drift_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
_drift_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_drift_banner.add_child(_drift_label)
var drift_btn := Button.new()
drift_btn.text = "Reconfigure mismatched"
drift_btn.tooltip_text = "Re-run Configure on every client whose stored URL doesn't match the current server URL."
drift_btn.pressed.connect(_on_reconfigure_mismatched)
_drift_banner.add_child(drift_btn)
_body.add_child(_drift_banner)
_clients_window = Window.new()
_clients_window.title = "Godot AI Settings"
## `Vector2i * float` yields Vector2; wrap the result back to Vector2i.
_clients_window.min_size = Vector2i(Vector2(560, 460) * EditorInterface.get_editor_scale())
_clients_window.visible = false
_clients_window.close_requested.connect(_on_clients_window_close_requested)
add_child(_clients_window)
## Tabbed secondary window: Clients (per-client rows), Tools (domain-
## exclusion checkboxes for clients that cap total tool count, like
## Antigravity at 100), and Settings (allow-host LAN opt-in, #507).
## Adding another tab is one more _build_*_tab call — no surgery on the
## rest of the window.
var tabs := TabContainer.new()
tabs.anchor_right = 1.0
tabs.anchor_bottom = 1.0
_clients_window.add_child(tabs)
var clients_tab := VBoxContainer.new()
clients_tab.add_theme_constant_override("separation", 8)
var clients_margin := _build_margin_container()
clients_margin.name = "Clients"
clients_margin.add_child(clients_tab)
tabs.add_child(clients_margin)
_client_configure_all_btn = Button.new()
_client_configure_all_btn.text = "Configure all"
_client_configure_all_btn.tooltip_text = "Configure every client that isn't already pointing at this server"
_client_configure_all_btn.size_flags_horizontal = Control.SIZE_SHRINK_END
_client_configure_all_btn.pressed.connect(_on_configure_all_clients)
clients_tab.add_child(_client_configure_all_btn)
var clients_scroll := ScrollContainer.new()
clients_scroll.size_flags_horizontal = Control.SIZE_EXPAND_FILL
clients_scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL
clients_scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED
clients_tab.add_child(clients_scroll)
_client_grid = VBoxContainer.new()
_client_grid.add_theme_constant_override("separation", 4)
_client_grid.size_flags_horizontal = Control.SIZE_EXPAND_FILL
clients_scroll.add_child(_client_grid)
for client_id in ClientConfigurator.client_ids():
_build_client_row(client_id)
_build_tools_tab(tabs)
_build_settings_tab(tabs)
_body.add_child(HSeparator.new())
# --- Dev mode toggle (always visible) ---
var dev_toggle_row := HBoxContainer.new()
var dev_toggle_label := Label.new()
dev_toggle_label.text = "Developer mode"
dev_toggle_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
dev_toggle_row.add_child(dev_toggle_label)
_dev_mode_toggle = CheckButton.new()
_dev_mode_toggle.button_pressed = _load_dev_mode()
_dev_mode_toggle.toggled.connect(_on_dev_mode_toggled)
dev_toggle_row.add_child(_dev_mode_toggle)
_body.add_child(dev_toggle_row)
# --- Log section (dev-only) ---
_log_viewer = LogViewerScript.new()
_log_viewer.setup()
_log_viewer.logging_enabled_changed.connect(_on_log_logging_enabled_changed)
_body.add_child(_log_viewer)
# Apply initial dev-mode visibility
_apply_dev_mode_visibility()
_refresh_setup_status.call_deferred()
_perform_initial_client_status_refresh()
## Static so `dock_panels/*.gd` subpanels can call it via `McpDock._make_header(...)`
## without re-declaring identical helpers + COLOR_HEADER constants.
static func _make_header(text: String) -> Label:
var label := Label.new()
label.text = text
label.add_theme_font_size_override("font_size", 18)
label.add_theme_color_override("font_color", COLOR_HEADER)
return label
func _build_client_row(client_id: String) -> void:
var row := HBoxContainer.new()
row.add_theme_constant_override("separation", 6)
row.size_flags_horizontal = Control.SIZE_EXPAND_FILL
var dot := ColorRect.new()
dot.custom_minimum_size = Vector2(10, 10)
dot.color = COLOR_MUTED
var dot_center := CenterContainer.new()
dot_center.add_child(dot)
row.add_child(dot_center)
var name_label := Label.new()
name_label.text = ClientConfigurator.client_display_name(client_id)
name_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
## Every advertised client uses the authenticated local attach bridge.
var transport_tag := Label.new()
transport_tag.text = _client_transport_tag(client_id)
transport_tag.add_theme_color_override("font_color", COLOR_MUTED)
transport_tag.tooltip_text = "Configure writes a local `godot-ai attach` launch command for this client."
## Long error messages from `_verify_post_state` (e.g. "reported remove ok
## but verification still reads configured…") used to push the Retry /
## Configure button off-screen — the row's Label wanted its full text
## width as minimum size, so the buttons got squeezed out. Wrap onto
## multiple lines instead so the row keeps its right edge stable and
## the buttons remain visible; the user can also read the whole message
## without resizing the window.
name_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
name_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
row.add_child(name_label)
row.add_child(transport_tag)
var configure_btn := Button.new()
configure_btn.text = "Configure"
configure_btn.pressed.connect(_on_client_action_button_pressed.bind(client_id, "configure"))
row.add_child(configure_btn)
var remove_btn := Button.new()
remove_btn.text = "Remove"
remove_btn.visible = false
remove_btn.pressed.connect(_on_client_action_button_pressed.bind(client_id, "remove"))
row.add_child(remove_btn)
# F-3-4: use the authoritative facade so Open/Reveal land on the same
# file `_check_status_merged` drives (last-wins across project tiers,
# matching the F2 status fix).
var config_path := ClientConfigurator.effective_authoritative_path(client_id)
var open_config_btn := Button.new()
_apply_editor_icon(open_config_btn, "ExternalLink", "Open")
open_config_btn.custom_minimum_size = Vector2(28, 28)
open_config_btn.visible = not config_path.is_empty()
open_config_btn.pressed.connect(_on_open_config_file.bind(client_id))
row.add_child(open_config_btn)
var reveal_btn := Button.new()
_apply_editor_icon(reveal_btn, "Folder", "Reveal")
reveal_btn.custom_minimum_size = Vector2(28, 28)
reveal_btn.visible = not config_path.is_empty()
reveal_btn.pressed.connect(_on_reveal_config_folder.bind(client_id))
row.add_child(reveal_btn)
_client_grid.add_child(row)
var manual_panel := VBoxContainer.new()
manual_panel.add_theme_constant_override("separation", 4)
manual_panel.visible = false
var manual_hint := Label.new()
manual_hint.text = "Run this manually:"
manual_hint.add_theme_color_override("font_color", COLOR_MUTED)
manual_panel.add_child(manual_hint)
var manual_text := TextEdit.new()
manual_text.editable = false
manual_text.custom_minimum_size = Vector2(0, 60)
manual_text.wrap_mode = TextEdit.LINE_WRAPPING_BOUNDARY
manual_panel.add_child(manual_text)
var copy_btn := Button.new()
copy_btn.text = "Copy"
copy_btn.pressed.connect(_on_copy_manual_command.bind(client_id))
manual_panel.add_child(copy_btn)
_client_grid.add_child(manual_panel)
_client_rows[client_id] = {
"dot": dot,
"status": Client.Status.NOT_CONFIGURED,
"name_label": name_label,
"configure_btn": configure_btn,
"remove_btn": remove_btn,
"open_config_btn": open_config_btn,
"reveal_btn": reveal_btn,
"config_path": config_path,
"manual_panel": manual_panel,
"manual_text": manual_text,
}
_refresh_client_config_file_buttons(client_id)
func _apply_editor_icon(button: Button, icon_name: String, fallback_text: String) -> void:
if has_theme_icon(icon_name, "EditorIcons"):
button.icon = get_theme_icon(icon_name, "EditorIcons")
else:
button.text = fallback_text
# --- Status updates ---
func _update_status() -> void:
## Signal delivery is synchronous: the composition root copies current
## transport/lifecycle values into the presentation methods below before
## this frame renders. If no root is attached, the last snapshot remains.
status_snapshot_requested.emit()
var connected := bool(_transport_snapshot.get("connected", false))
var transport_status: Dictionary = _transport_snapshot.get("status", {})
var server_status := _lifecycle_snapshot
var state: int = int(server_status.get("state", ServerStateScript.UNINITIALIZED))
if ServerStateScript.blocks_client_health(state):
connected = false
if connected:
_post_update_server_pending = false
## One `match`/`elif` chain, one source of truth. Adding a new
## spawn outcome = one `ServerStateScript` constant + one arm here +
## one body string in `_crash_body_for_state`.
## Default covers both a missing/old Connection instance and an unknown
## future transport phase. Every recognized state below overrides it, so
## startup grace and settled disconnect have one rendering path.
var inside_startup_grace := Time.get_ticks_msec() < _startup_grace_until_msec
var status_text := "Starting server…" if inside_startup_grace else "Disconnected"
var status_color := COLOR_AMBER if inside_startup_grace else Color.RED
if _server_restart_in_progress:
status_text = "Restarting server..."
status_color = COLOR_AMBER
elif connected:
status_text = _connected_status_text()
status_color = Color.GREEN
elif bool(server_status.get("handoff_retry_pending", false)):
status_text = "Recovering after update…"
status_color = COLOR_AMBER
elif state == ServerStateScript.UNSUPPORTED_CONFIG:
status_text = "Unsupported remote access configuration"
status_color = Color.RED
elif state == ServerStateScript.CRASHED and str(server_status.get("reason", "")) == "endpoint_lost":
var recovery_pending := bool(server_status.get("recovery_pending", false))
status_text = "Connection lost; reconnecting..." if recovery_pending else "Connection lost"
status_color = COLOR_AMBER if recovery_pending else Color.RED
elif state == ServerStateScript.CRASHED:
var exit_ms: int = server_status.get("exit_ms", 0)
status_text = "Server exited after %.1fs" % (exit_ms / 1000.0)
status_color = Color.RED
elif state == ServerStateScript.PORT_EXCLUDED:
status_text = "Port %d reserved by Windows" % ClientConfigurator.http_port()
status_color = Color.RED
elif state == ServerStateScript.INCOMPATIBLE:
status_text = "Incompatible server on port %d" % ClientConfigurator.http_port()
status_color = Color.RED
elif state == ServerStateScript.FOREIGN_PORT:
## #647: the post-crash probe names the actual conflicting port
## (HTTP or WS) — don't blame port 8000 when 9500 is the occupant.
var conflict_port: int = int(server_status.get("conflict_port", 0))
if conflict_port <= 0:
conflict_port = ClientConfigurator.http_port()
status_text = (
"Windows port discovery unavailable" if str(server_status.get("episode_reason", "")) == "port_occupancy_unknown"
else "Port %d held by another process" % conflict_port
)
status_color = Color.RED
elif state == ServerStateScript.NO_COMMAND:
status_text = "No server command found"
status_color = Color.RED
elif _post_update_server_pending:
## Every terminal spawn failure matched above; what is left is the
## post-update window where the server is being brought back.
status_text = "Finishing update — starting server…"
status_color = COLOR_AMBER
elif state == ServerStateScript.SPAWNING:
status_text = "Starting server…"
status_color = COLOR_AMBER
elif not transport_status.is_empty():
var transport_phase := str(transport_status.get("phase", ""))
if transport_phase == "connecting":
status_text = _transport_status_text(transport_status)
status_color = COLOR_AMBER
elif transport_phase == "retrying":
status_text = _transport_status_text(transport_status)
status_color = COLOR_AMBER
elif transport_phase == "closing":
status_text = _transport_status_text(transport_status)
status_color = COLOR_AMBER
elif transport_phase == "blocked":
## Exact terminal labels come from lifecycle state above. This is a
## generic fallback for a blocked connection without a diagnosis.
status_text = _transport_status_text(transport_status)
status_color = Color.RED
## keep_server_on_exit (#800): the reaper env opt-outs are staged at
## spawn, so a mid-session toggle only lands on the next server start —
## say so while the running server still carries the old behavior.
if connected and ClientConfigurator.keep_server_on_exit() != bool(server_status.get("keep_alive", false)):
status_text += " — keep-server-on-exit applies after Restart"
_update_crash_panel(server_status)
_refresh_server_version_label(server_status)
_refresh_server_label(server_status)
## A transient disconnect reason remains in the transport snapshot until
## handshake_ack. Once the dock renders the connection as OPEN, do not pair
## its green label with the previous peer's recovery diagnostic.
var status_tooltip := "" if connected else str(transport_status.get("reason", ""))
var changed: bool = (
connected != _last_connected
or status_text != _last_status_text
or status_tooltip != _last_status_tooltip
)
if not changed:
return
var just_connected: bool = connected and not _last_connected
_last_connected = connected
_last_status_text = status_text
_last_status_tooltip = status_tooltip
_status_icon.color = status_color
_status_label.text = status_text
_status_label.tooltip_text = status_tooltip
if just_connected:
## #739: the server just came up. If the startup uv probe failed
## (the reporter's screenshot: green "Server connected" beside a
## red "uv: not found" row), the failure was transient — re-probe
## instead of pinning the red row for the whole session. Runs
## AFTER the label writes above and via the deferred queue, so the
## status-machine state is committed before the probe can block.
_schedule_uv_reprobe()
## Status transitions are exactly when "is the launch still settling?"
## can change (Starting server… -> connected / Disconnected / terminal
## diagnosis), so re-evaluate the Setup section's visibility here (#744).
## Cheap: runs only on `changed`, and the uv probe result is cached.
_apply_dev_mode_visibility()
_update_dev_section_buttons()
## Render the diagnostic panel body for a given spawn state. The top
## status label already names the problem; this answers "what do I do?".
## Panel shows for any non-OK state; picker shows only when moving the HTTP
## port alone is a valid recovery. Incompatible godot-ai servers commonly
## hold both HTTP and WS ports, so their message points to Editor Settings
## instead of offering the HTTP-only quick picker.
func _update_crash_panel(server_status: Dictionary) -> void:
var state: int = int(server_status.get("state", ServerStateScript.UNINITIALIZED))
if not ServerStateScript.is_terminal_diagnosis(state):
if _crash_panel.visible:
_crash_panel.visible = false
_last_server_status = {}
return
if server_status == _last_server_status:
return
_last_server_status = server_status.duplicate()
_crash_panel.visible = true
_crash_output.clear()
_crash_output.add_text(_crash_body_for_state(state, server_status))
var show_recovery_restart := (
state == ServerStateScript.INCOMPATIBLE
and bool(server_status.get("can_recover_incompatible", false))
)
if _crash_restart_btn != null:
_crash_restart_btn.visible = show_recovery_restart
_crash_restart_btn.disabled = _server_restart_in_progress
_crash_restart_btn.text = "Restarting..." if _server_restart_in_progress else "Restart Server"
if _crash_reload_btn != null:
_crash_reload_btn.visible = (
not show_recovery_restart
and state != ServerStateScript.INCOMPATIBLE
)
## Docs link only for the genuinely-foreign occupant: a recoverable
## (older godot-ai) server gets Restart Server instead, and the inline
## body already names a free port — the link carries the per-client
## reconfigure steps that don't fit inline.
if _crash_docs_btn != null:
_crash_docs_btn.visible = (
state == ServerStateScript.INCOMPATIBLE
and not bool(server_status.get("can_recover_incompatible", false))
)
## The picker moves both ports (#647 hid it for a WebSocket-side
## conflict when it could only move the HTTP port), seeded with the
## diagnosed conflict so only the contested port changes.
var conflict_port := int(server_status.get("conflict_port", 0))
var port_picker_visible := (
state == ServerStateScript.PORT_EXCLUDED or state == ServerStateScript.FOREIGN_PORT
or (state == ServerStateScript.INCOMPATIBLE
and not bool(server_status.get("can_recover_incompatible", false)))
)
_port_picker_panel.visible = port_picker_visible
if port_picker_visible:
_port_picker_panel.seed_suggested_ports(conflict_port)
static func _crash_body_for_state(state: int, server_status: Dictionary = {}) -> String:
## Single sentence per state. The top status label already names the
## problem; don't repeat it here. This copy answers "what do I do?".
var port := ClientConfigurator.http_port()
match state:
ServerStateScript.UNSUPPORTED_CONFIG:
return str(server_status.get("message", "Use an IPv4 allowlist or clear Allow remote hosts, then reload the plugin."))
ServerStateScript.PORT_EXCLUDED:
return "Windows (Hyper-V / WSL2 / Docker) reserved port %d. Pick a free port or try `net stop winnat; net start winnat` in an admin shell." % port
ServerStateScript.INCOMPATIBLE:
var message := str(server_status.get("message", ""))
if bool(server_status.get("can_recover_incompatible", false)):
var expected := str(server_status.get("expected_version", ""))
if expected.is_empty():
expected = ClientConfigurator.get_plugin_version()
if not message.is_empty():
return "%s Click Restart Server below to replace it with godot-ai v%s." % [message, expected]
return "Port %d is occupied by an older godot-ai server. Click Restart Server below to replace it with godot-ai v%s." % [port, expected]
## Genuinely foreign occupant (no recovery proof). Name a concrete
## free port so the user doesn't have to hunt for one, and let the
## crash panel's "How to change the port" link carry the per-client
## reconfigure steps. `suggest_free_port` already routes through the
## Windows reservation table, so the named port won't itself fail
## with WinError 10013.
var hint := _free_port_hint(port)
if not message.is_empty():
return "%s %s" % [message, hint]
return "Port %d is occupied by an incompatible server. %s" % [port, hint]
ServerStateScript.FOREIGN_PORT:
## #647: prefer the lifecycle's diagnosis (it names the right
## port — HTTP vs WS — and the Editor Setting to change) over
## the generic HTTP-port fallback.
var foreign_message := str(server_status.get("message", ""))
if not foreign_message.is_empty():
return foreign_message
return "Another process is already bound to port %d. Pick a free port or stop the other process." % port
ServerStateScript.CRASHED:
## #805: a specific crash diagnosis from the lifecycle (e.g. the
## flapping-occupant latch) beats the generic launch-mode copy.
## Generic crash paths clear the message, so stale text from an
## earlier state can't leak in here.
var crash_message := str(server_status.get("message", ""))
if not crash_message.is_empty():
return crash_message
## Both spawn attempts failed on the uvx tier — stock releases:
## PyPI lag. Local builds (version with +metadata): almost always the
## dev venv was not found (unresolved junction/symlink) so uvx tried
## a pin that may lack checkout-local extras.
if ClientConfigurator.get_server_launch_mode() == "uvx":
var version := ClientConfigurator.get_plugin_version()
var pin := ClientConfigurator._pypi_pin_version(version)
if pin != version:
## `%` binds tighter than `+` in GDScript — format the fully
## concatenated string, never the last fragment alone.
return (
"The server exited before the WebSocket handshake. "
+ "Local plugin version is %s (PEP 440 local build metadata) — uvx pins PyPI godot-ai==%s. "
+ "If you need checkout-local server code, ensure addons/godot_ai resolves to your "
+ "dev tree (symlink/junction) with a `.venv`, or set GODOT_AI_VENV_PYTHON to that "
+ "venv's python binary, then Reload Plugin. Log should show 'MCP | using dev venv: ...'."
) % [version, pin]
return (
"The server exited before the WebSocket handshake, even after a `uvx --refresh` retry. "
+ "If this is a brand-new release, PyPI's index may still be propagating (~10 min). "
+ "Wait a moment and click Reload Plugin to retry, or check Godot's output log for Python's traceback. "
+ "Target: godot-ai==%s."
) % pin
return "The server exited before the WebSocket handshake. Check Godot's output log (bottom panel) for Python's traceback."
ServerStateScript.NO_COMMAND:
return "No godot-ai server found. Install `uv` via the Setup panel above, or run `pip install godot-ai`."
_:
return ""
## One sentence naming concrete free ports for the user to switch to. Names
## BOTH http and ws: this branch also fires for an incompatible godot-ai
## server we can't prove we own, which commonly holds both ports — moving only
## http would then leave the new server unable to bind ws. Both suggestions are
## routed through `suggest_free_port` so they clear Windows' winnat reservation
## table (no point suggesting a port that 10013s on bind). Both ports reach
## the client's attach command, so clients must be reconfigured afterwards.
## The per-client reconfigure steps live behind the crash panel's docs link.
static func _free_port_hint(port: int) -> String:
var occupancy := PortResolver.windows_listener_snapshot() if OS.get_name() == "Windows" else {}
var free_http := ClientConfigurator.suggest_free_port(port + 1, 2048, occupancy)
var free_ws := ClientConfigurator.suggest_free_port(ClientConfigurator.ws_port() + 1, 2048, occupancy)
if free_http == 0 or free_ws == 0:
return "Automatic port selection is unavailable. Choose HTTP and WS ports manually below, or retry."
return "Suggested ports: %d (HTTP) and %d (WS). Choose both ports below, click Apply + Reload, then Configure your AI clients to use the new pair." % [free_http, free_ws]
## URL for the port-conflict guide, pinned to the release tag that matches the
## installed plugin version (releases are tagged `v<version>`). The crash-panel
## button only exists in builds that ship `docs/port-conflicts.md`, so the
## versioned ref always resolves — and a shipped build never points users at a
## tip-of-main guide that has drifted from its own UI.
static func _port_conflict_docs_url() -> String:
var version := ClientConfigurator.get_plugin_version()
var git_ref := ("v%s" % version) if not version.is_empty() else "main"
return "%s/%s/%s" % [REPO_BLOB_BASE, git_ref, PORT_CONFLICT_DOCS_PATH]
## Signal handler for the extracted LogViewer — the panel owns its own
## display visibility, the dock owns logging routing. Routes to BOTH the
## dispatcher (gates [recv]/[send] recording) and the log buffer's console
## echo — the connection logs [event]/[defer] lines directly to the buffer,
## bypassing the dispatcher, so gating only `mcp_logging` left the console
## spamming with the toggle off (#626). Ring recording is unaffected, so
## the dock's log panel keeps working while the console stays quiet.
func _on_log_logging_enabled_changed(enabled: bool) -> void:
mcp_logging_changed.emit(enabled)
## Signal handler for the extracted PortPickerPanel. The replaceable Dock emits
## a copied value intent; the composition root owns persistence and reload.
func _on_port_apply_requested(new_http_port: int, new_ws_port: int) -> void:
settings_apply_requested.emit({"http_port": new_http_port, "ws_port": new_ws_port}, true)
func _refresh_server_label(server_status: Dictionary = {}) -> void:
if _server_label == null:
return
var ws_port := int(_lifecycle_snapshot.get("resolved_ws_port", 0))
if ws_port <= 0:
ws_port = ClientConfigurator.ws_port()
var text := "WS: %d HTTP: %d" % [ws_port, ClientConfigurator.http_port()]
if server_status.is_empty():
server_status = _lifecycle_snapshot
var ownership := _server_ownership_tag(
int(server_status.get("state", ServerStateScript.UNINITIALIZED)),
int(server_status.get("server_pid", -1)),
)
if not ownership.is_empty():
text += " · %s" % ownership
_server_label.text = text
## #838/#816 step 11: name which backend flavor the editor is riding.
## Diagnostic display only — never kill proof (external adoption clears PID
## authority, see server_lifecycle.gd::adopt_compatible_server / #669).
static func _server_ownership_tag(state: int, server_pid: int) -> String:
if state != ServerStateScript.READY:
return ""
return "plugin-managed backend" if server_pid > 0 else "externally adopted backend"
static func _client_transport_tag(client_id: String) -> String:
var client := ClientRegistry.get_by_id(client_id)
if client == null:
return ""
return "attach"
# --- Telemetry setting persistence ---
## Returns true if GODOT_AI_DISABLE_TELEMETRY or DISABLE_TELEMETRY is set
## to a truthy value, false if either is set and non-truthy, null if neither
## env var is present at all.
func _is_telemetry_disabled_via_env() -> Variant:
if not (OS.has_environment("GODOT_AI_DISABLE_TELEMETRY") or OS.has_environment("DISABLE_TELEMETRY")):
return null
return McpSettings.env_truthy("GODOT_AI_DISABLE_TELEMETRY") or McpSettings.env_truthy("DISABLE_TELEMETRY")
## Reads the telemetry preference, applying env-var override when present.
## Initialises _telemetry_pending_enabled / _telemetry_saved_enabled and
## sets the checkbox state + locked tooltip. Call after _telemetry_toggle
## has been created.
func _load_telemetry_setting() -> void:
var env_disabled = _is_telemetry_disabled_via_env()
var enabled: bool
if env_disabled != null:
## Environment policy controls this activation but does not rewrite the
## user's persisted preference.
enabled = not bool(env_disabled)
else:
## Defaults are registered by the composition root before Dock creation.
var es := EditorInterface.get_editor_settings()
if es != null and es.has_setting(McpSettings.SETTING_TELEMETRY_ENABLED):
enabled = bool(es.get_setting(McpSettings.SETTING_TELEMETRY_ENABLED))
else:
enabled = true
_telemetry_pending_enabled = enabled
_telemetry_saved_enabled = enabled
if _telemetry_toggle == null:
return
_telemetry_toggle.set_pressed_no_signal(enabled)
if env_disabled != null:
_telemetry_toggle.disabled = true
_telemetry_toggle.tooltip_text = (
"Telemetry is controlled by an environment variable "
+ "(GODOT_AI_DISABLE_TELEMETRY / DISABLE_TELEMETRY)."
)
else:
_telemetry_toggle.disabled = false
_telemetry_toggle.tooltip_text = _live_telemetry_tooltip(enabled)
func _on_telemetry_toggled(pressed: bool) -> void:
_telemetry_pending_enabled = pressed
if _telemetry_toggle != null:
_telemetry_toggle.tooltip_text = _live_telemetry_tooltip(pressed)
_refresh_tools_ui_state()
## Report the running server's telemetry state, not just this editor's
## checkbox, so the two can never silently disagree (#913). Applying an
## opt-out reaches the live server over the authenticated WebSocket and
## latches there, so both directions of a disagreement are real states worth
## explaining — not just the unreachable one.
func _live_telemetry_tooltip(local_enabled: bool) -> String:
_live_server_probe_result = {}
live_server_probe_requested.emit(ClientConfigurator.http_port())
var live := _live_server_probe_result
if not (live.get("telemetry_enabled") is bool):
## Absent means "too old to publish it", not false. Say nothing.
return ""
var server_enabled: bool = live.get("telemetry_enabled")
if server_enabled == local_enabled:
return "Running server telemetry is %s." % ("on" if server_enabled else "off")
if local_enabled:
return (
"The running server has telemetry off and will stay off until it is "
+ "replaced. An opt-out — this editor's earlier one, another editor "
+ "sharing this server, or GODOT_AI_DISABLE_TELEMETRY in its "
+ "environment — latched it. Restart the server to apply this."
)
return (
"The running server still has telemetry on. Applying sends it an "
+ "opt-out over the authenticated connection, which it honors for the "
+ "rest of its life."
)
# --- Dev mode persistence ---
func _load_dev_mode() -> bool:
# Default OFF for every install (including dev checkouts). Contributors
# who want the extra diagnostic UI (Reload Plugin, MCP log
# panel, Start/Stop Dev Server) can flip the toggle once — editor
# settings persist across sessions.
var es := EditorInterface.get_editor_settings()
if es == null:
return false
if not es.has_setting(DEV_MODE_SETTING):
es.set_setting(DEV_MODE_SETTING, false)
return false
return bool(es.get_setting(DEV_MODE_SETTING))
func _on_dev_mode_toggled(enabled: bool) -> void:
var es := EditorInterface.get_editor_settings()
if es != null:
es.set_setting(DEV_MODE_SETTING, enabled)
_apply_dev_mode_visibility()
_refresh_setup_status()
func _apply_dev_mode_visibility() -> void:
if _dev_mode_toggle == null:
return ## dock UI not built yet (unit tests, teardown window)
var dev := _dev_mode_toggle.button_pressed
_dev_section.visible = dev
if _log_viewer != null:
_log_viewer.visible = dev
# Setup section: visible in dev mode, OR in user mode when uv is missing
# (so users can install uv from the dock) — but not while the server
# launch is still settling (#744): mid-launch a red "uv: not found" row
# is usually a transient probe failure (#739) or irrelevant because the
# launch is succeeding via the .venv or system tiers. `_update_status_label`
# re-applies visibility on every status transition, so the section
# appears the moment the launch outcome makes it relevant.
var is_dev := ClientConfigurator.is_dev_checkout()
var uv_missing := not is_dev and ClientConfigurator.check_uv_version().is_empty()
_setup_section.visible = _setup_section_should_show(dev, uv_missing, _server_launch_pending())
## Pure visibility decision for the Setup section (#744). Split out so the
## truth table is unit-testable without faking the uv probe or a dev
## checkout: dev toggle always shows the section; a missing uv only shows
## it once the server launch has settled.
static func _setup_section_should_show(
dev_toggle: bool, uv_missing: bool, launch_pending: bool
) -> bool:
return dev_toggle or (uv_missing and not launch_pending)
## True while the server launch outcome is still unknown: not connected,
## no terminal diagnosis yet, and the startup grace window ("Starting
## server…" in the status row) is still running. Mirrors the status-label
## logic in `_update_status_label` so the Setup section and the amber status
## text agree on what "still launching" means.
func _server_launch_pending() -> bool:
if _last_connected:
return false
var state := int(_lifecycle_snapshot.get("state", ServerStateScript.UNINITIALIZED))
if ServerStateScript.is_terminal_diagnosis(state):
return false
return Time.get_ticks_msec() < _startup_grace_until_msec
# --- Button handlers ---
func _on_reload_plugin() -> void:
plugin_reload_requested.emit("dock_button")
## Setup-section "Server" row: always report the TRUE running server
## version (from the handshake_ack) rather than the plugin's expected
## version, and highlight the mismatch so self-update drift is visible
## at a glance instead of silently masked by a green label.
##
## Render states, keyed off live version metadata:
## - empty (pre-ack): show the expected version only as an unverified target
## - matches plugin: show it green, no Restart button
## - dev mismatch: show amber with an explicit dev marker
## - release mismatch: show actual vs expected; only surface Restart when the
## plugin has ownership proof for the process
func _refresh_server_version_label(server_status: Dictionary = {}) -> void:
if _setup_server_label == null:
return
var plugin_ver := ClientConfigurator.get_plugin_version()
if server_status.is_empty():
server_status = _lifecycle_snapshot
var server_ver := str(_transport_snapshot.get("server_version", ""))
if server_ver.is_empty():
server_ver = str(server_status.get("actual_version", ""))
var expected_ver := str(server_status.get("expected_version", ""))
if expected_ver.is_empty():
expected_ver = plugin_ver
var state: int = int(server_status.get("state", ServerStateScript.UNINITIALIZED))
if _server_restart_in_progress and (
server_ver == expected_ver
or (
ServerStateScript.is_terminal_diagnosis(state)
and state != ServerStateScript.INCOMPATIBLE
)
):
_server_restart_in_progress = false
var text: String
var color: Color
var show_restart := false
if _server_restart_in_progress:
text = "restarting server..."
color = COLOR_AMBER
show_restart = true
elif server_ver.is_empty():
text = "checking live version (expected godot-ai == %s)" % expected_ver
color = COLOR_MUTED
elif server_ver == expected_ver:
text = "godot-ai == %s" % server_ver
color = Color.GREEN
else:
text = "godot-ai == %s (expected %s)" % [server_ver, expected_ver]
var is_incompatible: bool = state == ServerStateScript.INCOMPATIBLE
color = Color.RED if is_incompatible else COLOR_AMBER
var has_managed_proof := bool(server_status.get("can_restart_managed", false))
var can_recover: bool = bool(server_status.get("can_recover_incompatible", false))
show_restart = (
(not is_incompatible and has_managed_proof)
## Recoverable incompatible servers get the primary action in
## the top error panel. Duplicating it in Setup made the UI
## look like it had multiple restart paths.
or (is_incompatible and can_recover and _crash_restart_btn == null)
)
if text == _last_rendered_server_text:
_setup_server_label.add_theme_color_override("font_color", color)
_update_restart_button(show_restart)
return
_last_rendered_server_text = text
_setup_server_label.text = text
_setup_server_label.add_theme_color_override("font_color", color)
_update_restart_button(show_restart)
func _update_restart_button(visible: bool) -> void:
if _version_restart_btn != null:
_version_restart_btn.visible = visible
_version_restart_btn.disabled = _server_restart_in_progress
_version_restart_btn.text = "Restarting..." if _server_restart_in_progress else "Restart"
if _crash_restart_btn != null:
_crash_restart_btn.disabled = _server_restart_in_progress
_crash_restart_btn.text = "Restarting..." if _server_restart_in_progress else "Restart Server"
func _on_restart_stale_server() -> void:
if _server_restart_in_progress:
return
_server_restart_in_progress = true
_last_rendered_server_text = ""
_refresh_server_version_label()
if not is_inside_tree():
_dispatch_stale_server_restart()
_server_restart_in_progress = false
_last_rendered_server_text = ""
_refresh_server_version_label()
return
call_deferred("_restart_stale_server_after_feedback")
func _restart_stale_server_after_feedback() -> void:
await get_tree().create_timer(0.15).timeout
if not _dispatch_stale_server_restart():
_server_restart_in_progress = false
_last_rendered_server_text = ""
_refresh_server_version_label()
func _dispatch_stale_server_restart() -> bool:
if int(_lifecycle_snapshot.get("state", ServerStateScript.UNINITIALIZED)) == ServerStateScript.INCOMPATIBLE:
lifecycle_action_requested.emit(LifecycleAction.RECOVER_INCOMPATIBLE)
return true
if not bool(_lifecycle_snapshot.get("can_restart_managed", false)):
return false
lifecycle_action_requested.emit(LifecycleAction.RESTART_SERVER)
return true
## Root acknowledgement for an asynchronous lifecycle intent. Successful
## restarts stay busy until the next lifecycle/transport snapshot settles;
## rejection restores the controls immediately.
func present_lifecycle_action_result(accepted: bool) -> void:
if accepted:
return
_server_restart_in_progress = false
_last_rendered_server_text = ""
_refresh_server_version_label()
# --- Setup section ---
## #739: a `uvx --version` probe that failed once at editor startup used
## to pin "uv: not found" for the whole session — the Install-uv click
## was the only invalidation path, so the fix users discovered was
## re-clicking Install on every launch. Re-probe on events that suggest
## the failure was transient (server-connect transition, manual Refresh).
## No-op once uv has been found, so this costs nothing in the healthy
## steady state; when uv is genuinely absent, the re-probe is a fast
## negative (CliFinder's well-known-dir walk plus one bounded `where`).
## Runs on the main thread like the initial probe — same wall-clock
## bound, and the triggering events are rare (once per connect / click).
##
## Callers go through _schedule_uv_reprobe() rather than calling this
## inline: the cache-miss probe shells out (bounded at 3s) on the
## calling thread, and both call sites sit mid-flow in UI handlers —
## the connect transition wants its status-label writes committed
## first, and the Refresh click wants the client sweep dispatched
## without waiting on the probe. Same deferred convention as
## _on_install_uv. (The deferred queue still flushes on the main
## thread, so a worst-case 3s probe delays that frame — acceptable for
## a rare, bounded event; a worker thread would be the heavier cure.)
func _schedule_uv_reprobe() -> void:
_reprobe_uv_if_negative.call_deferred()
func _reprobe_uv_if_negative() -> void:
if not ClientConfigurator.uv_probe_negative():
return
ClientConfigurator.invalidate_uv_detection()
_refresh_setup_status()
_apply_dev_mode_visibility()
func _refresh_setup_status() -> void:
if _setup_container == null:
return
for child in _setup_container.get_children():
child.queue_free()
_dev_primary_btn = null
_dev_stop_btn = null
var is_dev := ClientConfigurator.is_dev_checkout()
if is_dev:
_setup_container.add_child(_make_status_row("Mode", "Dev (venv)", Color.CYAN))
var btn_row := HBoxContainer.new()
btn_row.add_theme_constant_override("separation", 4)
btn_row.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_dev_primary_btn = Button.new()
_dev_primary_btn.text = "Restart Managed Server"
_dev_primary_btn.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_dev_primary_btn.pressed.connect(_on_dev_primary_pressed)
btn_row.add_child(_dev_primary_btn)
_dev_stop_btn = Button.new()
_dev_stop_btn.text = "✕"
_dev_stop_btn.tooltip_text = "Stop the exact plugin-owned server."
_dev_stop_btn.pressed.connect(_on_dev_stop_pressed)
btn_row.add_child(_dev_stop_btn)
_setup_container.add_child(btn_row)
_update_dev_section_buttons()
return
# User mode — check for uv
var uv_version := ClientConfigurator.check_uv_version()
if not uv_version.is_empty():
var compact_uv_version := _compact_uv_version_text(uv_version)
var uv_tooltip := uv_version if compact_uv_version != uv_version else ""
_setup_container.add_child(_make_status_row("uv", compact_uv_version, Color.GREEN, uv_tooltip))
## Build the Server row with a placeholder label we can update every
## frame. `_refresh_server_version_label` replaces the text + color
## once `McpConnection.server_version` lands via `handshake_ack`, and
## flips to amber + "(plugin X)" on drift. Pre-ack we show the
## plugin's expected version so the row isn't blank.
var server_row := HBoxContainer.new()
server_row.add_theme_constant_override("separation", 8)
var key_label := Label.new()
key_label.text = "Server"
key_label.add_theme_color_override("font_color", COLOR_MUTED)
key_label.custom_minimum_size = Vector2(60, 0)
server_row.add_child(key_label)
_setup_server_label = Label.new()
_setup_server_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
server_row.add_child(_setup_server_label)
_version_restart_btn = Button.new()
_version_restart_btn.text = "Restart"
_version_restart_btn.tooltip_text = "Kill the server on port %d and respawn with the plugin's bundled version" % ClientConfigurator.http_port()
_version_restart_btn.pressed.connect(_on_restart_stale_server)
_version_restart_btn.visible = false
server_row.add_child(_version_restart_btn)
_setup_container.add_child(server_row)
_last_rendered_server_text = ""
_refresh_server_version_label()
else:
_setup_container.add_child(_make_status_row("uv", "not found", Color.RED))
var install_btn := Button.new()
install_btn.text = "How to install uv"
install_btn.tooltip_text = (
"Opens the official uv installation docs. Godot AI deliberately does "
+ "not run the installer for you — see _on_install_uv."
)
install_btn.pressed.connect(_on_install_uv)
_setup_container.add_child(install_btn)
func _install_mode_text() -> String:
if ClientConfigurator.is_dev_checkout():
return "Install: dev checkout — update via git pull"
return "Install: v%s" % ClientConfigurator.get_plugin_version()
func _install_mode_tooltip() -> String:
if not ClientConfigurator.is_dev_checkout():
return "Plugin installed from a release ZIP, Asset Library, or source copy. Update button in this dock downloads the latest GitHub release."
var target := _resolve_plugin_symlink_target()
if target.is_empty():
return "Plugin source tree resolved via local .venv — press Reload Plugin after editing."
return "Plugin source: %s\nPress Reload Plugin after editing." % target
func _resolve_plugin_symlink_target() -> String:
var logical := ProjectSettings.globalize_path("res://addons/godot_ai").rstrip("/").rstrip("\\")
var resolved := ClientConfigurator.resolve_addons_realpath()
if resolved.is_empty() or resolved == logical:
return ""
return resolved
static func _compact_uv_version_text(uv_version: String) -> String:
var text := uv_version.strip_edges()
if text.ends_with(")"):
var metadata_start := text.rfind(" (")
if metadata_start >= 0:
return text.substr(0, metadata_start).strip_edges()
return text
func _make_status_row(
label_text: String,
value_text: String,
value_color: Color,
tooltip_text: String = ""
) -> HBoxContainer:
var row := HBoxContainer.new()
row.add_theme_constant_override("separation", 6)
if not tooltip_text.is_empty():
row.tooltip_text = tooltip_text
var label := Label.new()
label.text = label_text
label.add_theme_color_override("font_color", COLOR_MUTED)
label.custom_minimum_size.x = 60
if not tooltip_text.is_empty():
label.tooltip_text = tooltip_text
row.add_child(label)
var value := Label.new()
value.text = value_text
value.add_theme_color_override("font_color", value_color)
if not tooltip_text.is_empty():
value.tooltip_text = tooltip_text
row.add_child(value)
return row
## Pure view of the lifecycle's process authority. "External" means transport
## may be adopted, but this plugin has no right to stop its launcher.
static func _dev_primary_btn_state(managed: bool, external: bool) -> Dictionary:
var port := ClientConfigurator.http_port()
if managed:
return {
"text": "Restart Managed Server",
"enabled": true,
"tooltip": "Restart the exact plugin-owned server on port %d." % port,
}
if external:
return {
"text": "External Server Running",
"enabled": false,
"tooltip": "Stop the server on port %d from the process that launched it." % port,
}
return {
"text": "Start Managed Server",
"enabled": true,
"tooltip": "Start a plugin-owned server on port %d from the current checkout." % port,
}
static func _dev_stop_btn_state(managed: bool) -> Dictionary:
if managed:
return {"enabled": true, "tooltip": "Stop the exact plugin-owned server."}
return {"enabled": false, "tooltip": "No plugin-owned server to stop."}
func _on_dev_primary_pressed() -> void:
if _server_restart_in_progress:
return
_server_restart_in_progress = true
_update_dev_section_buttons()
if not is_inside_tree():
## Test path — no scene tree means no timer; run synchronously
## so suite assertions see the dispatch without `await`.
dev_server_action_requested.emit(DevServerAction.START_OR_RESTART)
_server_restart_in_progress = false
return
call_deferred("_perform_dev_restart_after_feedback")
func _on_dev_stop_pressed() -> void:
dev_server_action_requested.emit(DevServerAction.STOP)
_update_dev_section_buttons.call_deferred()
func _perform_dev_restart_after_feedback() -> void:
## Brief paint cycle so the user sees "Restarting..." before dispatch.
await get_tree().create_timer(0.15).timeout
dev_server_action_requested.emit(DevServerAction.START_OR_RESTART)
## Lifecycle snapshots normally repaint first; this is a bounded UI fallback.
await get_tree().create_timer(2.0).timeout
_server_restart_in_progress = false
_update_dev_section_buttons()
## Derive both buttons from the already-copied lifecycle value. No second port
## scan or dev-only ownership state exists.
func _update_dev_section_buttons() -> void:
var managed := int(_lifecycle_snapshot.get("server_pid", -1)) > 1
var normal_start_released := bool(
_lifecycle_snapshot.get("normal_start_released", false)
)
var state := int(_lifecycle_snapshot.get("state", ServerStateScript.UNINITIALIZED))
var external := not managed and (
str(_lifecycle_snapshot.get("ready_kind", "")) == "adopted"
or state in [ServerStateScript.INCOMPATIBLE, ServerStateScript.FOREIGN_PORT]
)
if _dev_primary_btn != null:
if _server_restart_in_progress:
_dev_primary_btn.disabled = true
_dev_primary_btn.text = "Restarting..."
_dev_primary_btn.tooltip_text = "Restarting the plugin-owned server..."
elif not normal_start_released:
_dev_primary_btn.disabled = true
_dev_primary_btn.text = "Server Start Blocked"
_dev_primary_btn.tooltip_text = (
"Complete post-update client migration before starting the server."
)
else:
var primary_state := _dev_primary_btn_state(managed, external)
_dev_primary_btn.disabled = not bool(primary_state["enabled"])
_dev_primary_btn.text = primary_state["text"]
_dev_primary_btn.tooltip_text = primary_state["tooltip"]
if _dev_stop_btn != null:
var stop_state := _dev_stop_btn_state(managed)
_dev_stop_btn.disabled = (not stop_state["enabled"]) or _server_restart_in_progress
_dev_stop_btn.tooltip_text = stop_state["tooltip"]
func _client_status_refresh_has_completed() -> bool:
return bool(_client_work_snapshot.get("refresh_completed", false))
func _client_refresh_state() -> int:
return int(_client_work_snapshot.get("refresh_state", ClientRefreshStateScript.IDLE))
func _connected_status_text() -> String:
return "Server connected"
static func _transport_status_text(snapshot: Dictionary) -> String:
## Total over the transport enum for isolated consumers/tests. The dock's
## connected fast path renders `_connected_status_text()` before calling it.
var phase := str(snapshot.get("phase", ""))
var attempt := maxi(1, int(snapshot.get("attempt", 0)))
match phase:
"connected":
return "Server connected"
"connecting":
return "Connecting — attempt %d" % attempt
"retrying":
var retry_in_sec := ceili(maxf(0.0, float(snapshot.get("retry_in_sec", 0.0))))
return "Retrying in %ds — attempt %d" % [retry_in_sec, attempt]
"closing":
return "Disconnecting…"
"blocked":
return "Connection blocked"
return "Disconnected"
## Open uv's official install documentation rather than executing an
## installer on the user's behalf.
##
## This used to shell out to `curl -LsSf https://astral.sh/uv/install.sh | sh`
## (and the PowerShell `irm … | iex` equivalent). That is arbitrary remote
## code execution as the editor user, one dock click deep, with no version
## pin, no checksum, and no signature — while this same plugin verifies its
## OWN updates with an RSA-4096 signature over a SHA-256 sidecar, pinned to a
## GitHub host and this repo's release-asset path. Holding a third-party
## installer to a weaker standard than our own payload is the wrong trade,
## and pinning a digest here would only cover the bootstrap script, not the
## uv binary it goes on to fetch.
##
## Opening the docs keeps the discovery value of the button (the user still
## learns uv is missing and how to get it) while leaving the decision to
## install — and the choice of install method — with the user. Mirrors the
## dock's existing "Run this manually" fallback for client CLIs.
func _on_install_uv() -> void:
OS.shell_open(UV_INSTALL_DOCS_URL)
## Drop the cached uvx path AND the cached `uvx --version` so that once
## the user has installed uv (in a terminal, from the docs we just
## opened), the dock finds the new binary instead of replaying the
## cached "not found" result for the rest of the session.
## Routing through the configurator matters on Windows, where the
## CLI-finder cache key is `uvx.exe` — invalidating just `"uvx"`
## would leave the cache stale and the dock would keep showing
## "uv: not found" for the rest of the session.
ClientConfigurator.invalidate_uv_detection()
## Deliberately do NOT refresh here. `OS.shell_open` returns as soon as
## the browser is handed the URL, so an immediate refresh would run long
## before the user could install anything and would simply re-cache
## "not found" — undoing the invalidation above. (The old shell-out was
## a blocking `OS.execute`, so refreshing straight after it was correct
## then; it stopped being correct when the installer call went away.)
## Re-probe when the editor regains focus instead — see _notification.
_uv_recheck_pending = true
# --- Client section ---
func _on_configure_client(client_id: String) -> void:
## Configure writes an explicit url + live plugin version; it does not
## need a healthy occupant. INCOMPATIBLE only suppresses status
## interpretation (#916).
_dispatch_client_action(client_id, "configure")
func _on_client_action_button_pressed(client_id: String, action: String) -> void:
if _is_self_update_in_progress():
return
if _client_work_snapshot.get("action_phases", {}).get(client_id, "") == "queued":
client_action_cancel_requested.emit(client_id)
return
_dispatch_client_action(client_id, action)
## Emit a value intent; plugin.gd routes it to the plugin-lifetime job owner.
## The Dock updates only view-local button state and never holds a Thread.
func _dispatch_client_action(client_id: String, action: String) -> void:
if _is_self_update_in_progress():
return
if _busy_client_actions().has(client_id):
return
var row: Dictionary = _client_rows.get(client_id, {})
if row.is_empty():
return
_set_row_action_in_flight(client_id, action)
client_action_requested.emit(client_id, action)
func present_client_action_result(
client_id: String,
action: String,
result: Dictionary,
prewarm: Dictionary = {},
) -> void:
_report_prewarm_outcome(client_id, prewarm)
_finalize_action_buttons(client_id)
if result.get("status") == "cancelled":
var row: Dictionary = _client_rows.get(client_id, {})
if not row.is_empty():
_apply_row_status(client_id, row.get("status", Client.Status.NOT_CONFIGURED))
_refresh_clients_summary()
return
var success_status := Client.Status.NOT_CONFIGURED if action == "remove" else Client.Status.CONFIGURED
if result.get("status") == "ok":
## #877: Remove targets only the selected scope, so a configure is the
## only action with an all-scope sweep to disclose. The manual panel
## that lists those removes is shown on the failure path below, which
## left the success path — where the sweep actually ran — silent.
var sweep_note := (
ClientConfigurator.configure_sweep_note(client_id) if action == "configure" else ""
)
_apply_row_status(client_id, success_status, sweep_note)
var row: Dictionary = _client_rows.get(client_id, {})
if not row.is_empty():
(row["manual_panel"] as VBoxContainer).visible = false
else:
_apply_row_status(client_id, Client.Status.ERROR, str(result.get("message", "failed")))
if action == "configure":
_show_manual_command_for(client_id)
_refresh_clients_summary()
func present_client_action_timeout(client_id: String, _action: String, detail: String) -> void:
_apply_row_status(client_id, Client.Status.ERROR, detail)
_refresh_clients_summary()
func present_client_work_snapshot(snapshot: Dictionary) -> void:
_client_work_snapshot = snapshot.duplicate(true)
var busy := _busy_client_actions()
var names: Dictionary = _client_work_snapshot.get("action_names", {})
var phases: Dictionary = _client_work_snapshot.get("action_phases", {})
for client_id in _client_rows:
var id := String(client_id)
if busy.has(id):
_set_row_action_in_flight(id, String(names.get(id, "configure")))
if String(phases.get(id, "")) == "queued":
var button := "remove_btn" if String(names.get(id, "configure")) == "remove" else "configure_btn"
(_client_rows[id][button] as Button).text = "Cancel queued"
(_client_rows[id][button] as Button).disabled = false
elif String(phases.get(id, "")) == "prewarm":
(_client_rows[id]["configure_btn"] as Button).text = "Installing…"
else:
_finalize_action_buttons(id)
_refresh_clients_summary()
func _busy_client_actions() -> Array[String]:
var result: Array[String] = []
result.assign(_client_work_snapshot.get("busy_actions", []))
return result
## One line per Configure so a cold build is attributable after the fact —
## the dock label is transient, and #851's symptom (a terminal window on
## Windows) is easiest to correlate against a timestamped log entry. Silent
## on the skip paths: a dev-venv/system tier having no env to warm is the
## normal case, not news.
func _report_prewarm_outcome(client_id: String, prewarm: Variant) -> void:
if not (prewarm is Dictionary):
return
var data := prewarm as Dictionary
if data.is_empty() or bool(data.get("skipped", false)):
return
if bool(data.get("timed_out", false)):
print(
"MCP | %s: package pre-warm timed out; the first client launch will build the environment"
% client_id
)
return
if int(data.get("exit_code", -1)) != 0:
print(
"MCP | %s: package pre-warm failed (exit %d); the first client launch will build the environment"
% [client_id, int(data.get("exit_code", -1))]
)
return
print("MCP | %s: package environment pre-warmed" % client_id)
## In-flight visual: rewrite the verb onto the button the user just
## clicked ("Configuring…" / "Removing…") so the feedback lands where
## their attention already is. Don't pollute the row label — that'd
## clobber any drift hint ("URL out of date") still relevant to the row.
## The dot turns amber so the row reads as "busy" at a glance, not as
## green (premature success) or red (premature failure). Both buttons
## go disabled so a double-click or second action can't queue stale
## work behind the in-flight worker.
func _set_row_action_in_flight(client_id: String, action: String) -> void:
var row: Dictionary = _client_rows.get(client_id, {})
if row.is_empty():
return
var configure_btn: Button = row["configure_btn"]
var remove_btn: Button = row["remove_btn"]
configure_btn.disabled = true
remove_btn.disabled = true
if action == "remove":
remove_btn.text = "Removing…"
else:
configure_btn.text = "Configuring…"
(row["dot"] as ColorRect).color = COLOR_AMBER
## Re-enable both buttons and reset their text back to canonical labels.
## Restore labels from cached row status as well as enabling the controls.
## This matters when a timed-out orphan finishes: the owner publishes a new
## snapshot but has no action result to repaint the row for us.
func _finalize_action_buttons(client_id: String) -> void:
var row: Dictionary = _client_rows.get(client_id, {})
if row.is_empty():
return
var configure_btn := row["configure_btn"] as Button
configure_btn.disabled = false
match row.get("status", Client.Status.NOT_CONFIGURED):
Client.Status.CONFIGURED, Client.Status.CONFIGURED_MISMATCH:
configure_btn.text = "Reconfigure"
Client.Status.NOT_CONFIGURED:
configure_btn.text = "Configure"
_:
configure_btn.text = "Retry"
var remove_btn: Button = row["remove_btn"]
remove_btn.disabled = false
remove_btn.text = "Remove"
func _on_refresh_clients_pressed() -> void:
## Explicit user action — also give a failed uv probe another chance
## (#739), mirroring how the same click already re-sweeps client CLIs.
_schedule_uv_reprobe()
_request_client_status_refresh(true)
func _on_configure_all_clients() -> void:
## Per-row Configure already bypasses the RUNNING gate. INCOMPATIBLE
## skips health interpretation (#916), so Configure all must do the same
## even if a status sweep is still in flight — `_set_incompatible_server`
## does not reset `_refresh_state`.
if (
ClientRefreshStateScript.should_disable_client_actions(_client_refresh_state())
and not _server_blocks_client_health()
):
return
for client_id in _client_rows:
var status: Client.Status = _client_rows[client_id].get("status", Client.Status.NOT_CONFIGURED)
if status == Client.Status.CONFIGURED:
continue
_on_configure_client(String(client_id))
_refresh_clients_summary()
func _on_open_clients_window() -> void:
if _clients_window == null:
return
## Re-sweep before the user has time to act on stale dot colors. The request
## is async/stale-while-refreshing so the popup paints immediately with
## last-known state; the fresh colors land when the background worker returns.
## This is an explicit user action, so it bypasses the focus-in cooldown.
_request_client_status_refresh(true)
## Also re-sync the Tools tab from the persisted setting — another
## editor instance (or a hand-edit of editor_settings-4.tres) may have
## changed the excluded list while the window was closed.
_reset_tools_pending_from_setting()
_refresh_tools_ui_state()
if vision_routing != null:
vision_routing.refresh_ui()
# popup_centered() with a minsize forces the window to that size and
# centers on the parent viewport. Setting .size on a hidden Window
# doesn't always take effect, so we force it at popup time here.
_clients_window.popup_centered(Vector2i(640, 600))
func _settings_are_dirty() -> bool:
return (
_tools_pending_excluded != _tools_saved_excluded
or _telemetry_pending_enabled != _telemetry_saved_enabled
or _allow_hosts_is_dirty()
)
func _on_clients_window_close_requested() -> void:
if _clients_window == null:
return
## If the user has unapplied settings, a close would silently throw the
## pending state away. Prompt before discarding current options and if
## they confirm, reset pending → saved so the window shows the persisted
## state the next time they open it.
if _settings_are_dirty():
_show_tools_close_confirm()
return
_clients_window.hide()
# --- Tools tab (domain exclusion) ---
func _build_tools_tab(tabs: TabContainer) -> void:
## Tab 2 — domain-exclusion checkboxes. Rendered once, on dock construction.
## `_reset_tools_pending_from_setting()` re-syncs checkbox state from the
## saved setting each time the window opens.
var tools_tab := VBoxContainer.new()
tools_tab.add_theme_constant_override("separation", 8)
var tools_margin := _build_margin_container()
tools_margin.name = "Tools"
tools_margin.add_child(tools_tab)
tabs.add_child(tools_margin)
var intro := Label.new()
intro.text = (
"Some MCP clients cap tools per connection (Antigravity: 100). "
+ "Uncheck a domain to drop its non-core tools from this server. "
+ "Core tools stay on. Changes require a server restart."
)
intro.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
intro.add_theme_color_override("font_color", COLOR_MUTED)
intro.size_flags_horizontal = Control.SIZE_EXPAND_FILL
tools_tab.add_child(intro)
var count_row := HBoxContainer.new()
count_row.add_theme_constant_override("separation", 8)
var count_header := Label.new()
count_header.text = "Tools Enabled:"
count_header.add_theme_color_override("font_color", COLOR_MUTED)
count_row.add_child(count_header)
_tools_count_label = Label.new()
_tools_count_label.add_theme_font_size_override("font_size", 15)
count_row.add_child(_tools_count_label)
_tools_dirty_warning = Label.new()
_tools_dirty_warning.add_theme_color_override("font_color", COLOR_AMBER)
_tools_dirty_warning.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_tools_dirty_warning.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT
_tools_dirty_warning.visible = false
_tools_dirty_warning.text = "Unapplied changes"
count_row.add_child(_tools_dirty_warning)
tools_tab.add_child(count_row)
tools_tab.add_child(HSeparator.new())
var scroll := ScrollContainer.new()
scroll.size_flags_horizontal = Control.SIZE_EXPAND_FILL
scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL
scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED
tools_tab.add_child(scroll)
var grid := VBoxContainer.new()
grid.add_theme_constant_override("separation", 4)
grid.size_flags_horizontal = Control.SIZE_EXPAND_FILL
scroll.add_child(grid)
## Core pseudo-row — disabled checkbox, always checked. Shows the 5
## always-loaded tools as a single line item so the user can see where
## their baseline tool budget goes without listing individual core names
## inline (tooltip has them).
var core_row := HBoxContainer.new()
core_row.add_theme_constant_override("separation", 8)
var core_chk := CheckBox.new()
core_chk.button_pressed = true
core_chk.disabled = true
core_chk.focus_mode = Control.FOCUS_NONE
core_row.add_child(core_chk)
var core_label := Label.new()
core_label.text = "Core (always on)"
core_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
core_row.add_child(core_label)
var core_count := Label.new()
core_count.text = "%d tools" % (ToolCatalog.CORE_TOOLS.size() + ToolCatalog.ALWAYS_ON_TOOLS.size())
core_count.add_theme_color_override("font_color", COLOR_MUTED)
core_row.add_child(core_count)
core_row.tooltip_text = "%s · always on: %s" % [
", ".join(ToolCatalog.CORE_TOOLS),
", ".join(ToolCatalog.ALWAYS_ON_TOOLS),
]
grid.add_child(core_row)
grid.add_child(HSeparator.new())
_tools_domain_checkboxes.clear()
for entry in ToolCatalog.DOMAINS:
_build_tools_domain_row(grid, entry)
## Custom (addon-registered) tools — unlike the domain rows above,
## toggles apply LIVE: the registry re-pushes the filtered catalog to
## the server on every change, no restart needed.
grid.add_child(HSeparator.new())
var custom_header_row := HBoxContainer.new()
custom_header_row.add_theme_constant_override("separation", 8)
var custom_header := Label.new()
custom_header.text = "Custom tools (addon-registered)"
custom_header.size_flags_horizontal = Control.SIZE_EXPAND_FILL
custom_header_row.add_child(custom_header)
_custom_tools_count_label = Label.new()
_custom_tools_count_label.add_theme_color_override("font_color", COLOR_MUTED)
custom_header_row.add_child(_custom_tools_count_label)
grid.add_child(custom_header_row)
var custom_hint := Label.new()
custom_hint.text = "Applies immediately — disabled tools are hidden from agents and rejected if called."
custom_hint.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
custom_hint.add_theme_color_override("font_color", COLOR_MUTED)
custom_hint.size_flags_horizontal = Control.SIZE_EXPAND_FILL
grid.add_child(custom_hint)
_custom_tools_list = VBoxContainer.new()
_custom_tools_list.add_theme_constant_override("separation", 4)
_custom_tools_list.size_flags_horizontal = Control.SIZE_EXPAND_FILL
grid.add_child(_custom_tools_list)
_connect_custom_tool_registry()
_refresh_custom_tools_rows()
tools_tab.add_child(HSeparator.new())
var telemetry_row := HBoxContainer.new()
telemetry_row.add_theme_constant_override("separation", 8)
var telemetry_label := Label.new()
telemetry_label.text = "Telemetry"
telemetry_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
telemetry_row.add_child(telemetry_label)
_telemetry_toggle = CheckButton.new()
_telemetry_toggle.toggled.connect(_on_telemetry_toggled)
telemetry_row.add_child(_telemetry_toggle)
tools_tab.add_child(telemetry_row)
tools_tab.add_child(HSeparator.new())
var footer := HBoxContainer.new()
footer.add_theme_constant_override("separation", 8)
_tools_apply_btn = Button.new()
_tools_apply_btn.text = "Apply and Restart Server"
_tools_apply_btn.tooltip_text = "Save the excluded list to Editor Settings and reload the plugin so the server respawns with --exclude-domains."
_tools_apply_btn.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_tools_apply_btn.pressed.connect(_on_tools_apply)
footer.add_child(_tools_apply_btn)
_tools_reset_btn = Button.new()
_tools_reset_btn.text = "Reset to defaults"
_tools_reset_btn.tooltip_text = "Re-enable every domain (no --exclude-domains flag). Still needs Apply."
_tools_reset_btn.pressed.connect(_on_tools_reset)
footer.add_child(_tools_reset_btn)
tools_tab.add_child(footer)
_tools_close_confirm = ConfirmationDialog.new()
_tools_close_confirm.title = "Discard unapplied changes?"
## Generic wording: _settings_are_dirty() covers domain toggles, the
## telemetry switch, AND the Settings tab's allow-host field (#507) —
## the old "checked/unchecked domains" text misled non-domain edits.
_tools_close_confirm.dialog_text = (
"You have unapplied changes in this window.\n"
+ "Close it and discard those changes?"
)
_tools_close_confirm.ok_button_text = "Discard"
_tools_close_confirm.confirmed.connect(_on_tools_discard_confirmed)
add_child(_tools_close_confirm)
_update_confirm = ConfirmationDialog.new()
_update_confirm.title = "Update Godot AI?"
_update_confirm.ok_button_text = "Update plugin"
_update_confirm.cancel_button_text = "Later"
_update_confirm.confirmed.connect(_on_update_confirmed)
add_child(_update_confirm)
_reset_tools_pending_from_setting()
_refresh_tools_ui_state()
## --- Custom (addon-registered) tools section ---
func _connect_custom_tool_registry() -> void:
var registry := McpToolRegistry.get_instance()
if registry == null:
return
if not registry.tools_changed.is_connected(_on_custom_tool_registry_changed):
registry.tools_changed.connect(_on_custom_tool_registry_changed)
func _on_custom_tool_registry_changed() -> void:
## Deferred: tools_changed can fire from inside a checkbox toggle in
## this very list — rebuilding synchronously would free the emitting
## control mid-signal.
_refresh_custom_tools_rows.call_deferred()
func _refresh_custom_tools_rows() -> void:
if _custom_tools_list == null or not is_instance_valid(_custom_tools_list):
return
for child in _custom_tools_list.get_children():
child.queue_free()
var registry := McpToolRegistry.get_instance()
var specs: Array = [] if registry == null else registry.all()
specs.sort_custom(func(a, b): return String(a.name) < String(b.name))
var enabled_count := 0
for spec in specs:
if registry.is_tool_enabled(spec.name):
enabled_count += 1
_custom_tools_list.add_child(_build_custom_tool_row(registry, spec))
if _custom_tools_count_label != null and is_instance_valid(_custom_tools_count_label):
_custom_tools_count_label.text = "%d/%d enabled" % [enabled_count, specs.size()]
if specs.is_empty():
var empty := Label.new()
empty.text = "None registered. Addons add tools via McpToolRegistry — see docs/plugin-architecture.md."
empty.add_theme_color_override("font_color", COLOR_MUTED)
empty.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
empty.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_custom_tools_list.add_child(empty)
func _build_custom_tool_row(registry: McpToolRegistry, spec: McpCustomToolSpec) -> HBoxContainer:
var row := HBoxContainer.new()
row.add_theme_constant_override("separation", 8)
var chk := CheckBox.new()
chk.button_pressed = registry.is_tool_enabled(spec.name)
chk.toggled.connect(func(pressed: bool): registry.set_tool_enabled(spec.name, pressed))
row.add_child(chk)
var name_label := Label.new()
name_label.text = spec.name + (" · promoted" if spec.promoted else "")
name_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
row.add_child(name_label)
var source_label := Label.new()
source_label.text = spec.source if not spec.source.is_empty() else spec.source_path.get_base_dir().get_file()
source_label.add_theme_color_override("font_color", COLOR_MUTED)
row.add_child(source_label)
## Tooltip = the agent-facing description plus provenance, so the user
## can judge what they're enabling without leaving the dock.
row.tooltip_text = "%s\n\nsource: %s%s" % [
spec.description,
spec.source_path,
"\npromoted: registers as first-class MCP tool custom_%s" % spec.name if spec.promoted else "",
]
name_label.tooltip_text = row.tooltip_text
return row
func _build_tools_domain_row(parent: VBoxContainer, entry: Dictionary) -> void:
var row := HBoxContainer.new()
row.add_theme_constant_override("separation", 8)
var chk := CheckBox.new()
chk.button_pressed = true # default; `_reset_tools_pending_from_setting` corrects
chk.toggled.connect(_on_tools_domain_toggled.bind(String(entry["id"])))
row.add_child(chk)
var name_label := Label.new()
name_label.text = String(entry["label"])
name_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
row.add_child(name_label)
var count_label := Label.new()
count_label.text = "%d tools" % int(entry["count"])
count_label.add_theme_color_override("font_color", COLOR_MUTED)
row.add_child(count_label)
## Hover tooltip = flat list of tool names in this domain. Lets the
## user decide without leaving the dock (e.g. "I just want to drop
## `animation_preset_*` — do I lose anything else?").
var tools_list: Array = entry.get("tools", [])
row.tooltip_text = ", ".join(tools_list)
name_label.tooltip_text = row.tooltip_text
count_label.tooltip_text = row.tooltip_text
parent.add_child(row)
_tools_domain_checkboxes[String(entry["id"])] = chk
func _reset_tools_pending_from_setting() -> void:
## Read the saved setting → pending/saved arrays, then sync checkbox state.
## Unknown domain names in the setting (e.g. from an older plugin
## version) are dropped from the display here (only ids with a checkbox
## survive). The startup path is protected separately:
## `ClientConfigurator.excluded_domains()` filters unknown names before
## they reach `--exclude-domains`, whose `parse_exclude_list` hard-fails
## on them.
var saved_raw := ClientConfigurator.excluded_domains()
var saved := PackedStringArray()
if not saved_raw.is_empty():
for part in saved_raw.split(","):
var t := part.strip_edges()
if t.is_empty():
continue
if _tools_domain_checkboxes.has(t) and saved.find(t) == -1:
saved.append(t)
saved.sort()
_tools_saved_excluded = saved
_tools_pending_excluded = saved.duplicate()
for id in _tools_domain_checkboxes:
var chk: CheckBox = _tools_domain_checkboxes[id]
## `set_pressed_no_signal` — mutating programmatically should not
## fire the toggled handler, which would mutate pending back.
chk.set_pressed_no_signal(_tools_pending_excluded.find(id) == -1)
## Also reset telemetry pending state from the persisted setting.
if _telemetry_toggle != null:
_load_telemetry_setting()
## And the Settings tab's allow-host field (#507) — same window-open /
## discard-confirm re-sync contract as the tools checkboxes.
_reset_allow_hosts_from_setting()
func _on_tools_domain_toggled(pressed: bool, domain_id: String) -> void:
var idx := _tools_pending_excluded.find(domain_id)
if pressed and idx != -1:
_tools_pending_excluded.remove_at(idx)
elif not pressed and idx == -1:
_tools_pending_excluded.append(domain_id)
_tools_pending_excluded.sort()
_refresh_tools_ui_state()
func _refresh_tools_ui_state() -> void:
if _tools_count_label == null:
return
var enabled := ToolCatalog.enabled_tool_count(_tools_pending_excluded)
var total := ToolCatalog.total_tool_count()
_tools_count_label.text = "%d / %d" % [enabled, total]
var dirty := _settings_are_dirty()
_tools_dirty_warning.visible = dirty
_tools_apply_btn.disabled = not dirty
## Color the count when the user is over Antigravity's cap — a soft
## signal that their selection still won't fit. 100 is the Antigravity
## limit; other clients may cap higher, so this is advisory only.
if enabled > 100:
_tools_count_label.add_theme_color_override("font_color", COLOR_AMBER)
else:
_tools_count_label.remove_theme_color_override("font_color")
func _on_tools_apply() -> void:
var canonical_excluded := ToolCatalog.canonical(_tools_pending_excluded)
_tools_saved_excluded = _tools_pending_excluded.duplicate()
_telemetry_saved_enabled = _telemetry_pending_enabled
_refresh_tools_ui_state()
settings_apply_requested.emit({
"excluded_domains": canonical_excluded,
"telemetry_enabled": _telemetry_pending_enabled,
}.duplicate(true), true)
func _on_tools_reset() -> void:
## Resets only the tool-domain exclusions, not the telemetry toggle.
## Telemetry is a privacy preference users typically want to set once
## and have honored — flipping it back to "on" via a generic Reset
## button would be a surprising privacy regression. The button label
## is scoped to tools accordingly.
_tools_pending_excluded = PackedStringArray()
for id in _tools_domain_checkboxes:
var chk: CheckBox = _tools_domain_checkboxes[id]
chk.set_pressed_no_signal(true)
_refresh_tools_ui_state()
func _show_tools_close_confirm() -> void:
if _tools_close_confirm == null:
return
_tools_close_confirm.popup_centered()
func _on_tools_discard_confirmed() -> void:
_reset_tools_pending_from_setting()
_refresh_tools_ui_state()
if _clients_window != null:
_clients_window.hide()
# --- Settings tab (allow-host LAN opt-in, #507) ---
func _build_settings_tab(tabs: TabContainer) -> void:
## Tab 3 — settings-style controls that don't fit Clients or Tools: the
## Vision Routing section plus the `--allow-host` LAN opt-in behind a
## collapsed "Remote access (advanced)" disclosure, so its security
## warning renders exactly at the point of configuration. Rendered once
## on dock construction, mirroring `_build_tools_tab`;
## `_reset_allow_hosts_from_setting()` and `vision_routing.refresh_ui()`
## re-sync each time the window opens (via
## `_reset_tools_pending_from_setting` / `_on_open_clients_window`).
var settings_tab := VBoxContainer.new()
settings_tab.add_theme_constant_override("separation", 8)
settings_tab.size_flags_horizontal = Control.SIZE_EXPAND_FILL
## The section stack below (Vision Routing + Remote access) can outgrow
## the window's minimum height, especially at larger editor scales —
## scroll instead of growing the window, same idiom as the Clients and
## Tools tabs (#1090).
var settings_scroll := ScrollContainer.new()
settings_scroll.size_flags_horizontal = Control.SIZE_EXPAND_FILL
settings_scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL
settings_scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED
settings_scroll.add_child(settings_tab)
var settings_margin := _build_margin_container()
settings_margin.name = "Settings"
settings_margin.add_child(settings_scroll)
tabs.add_child(settings_margin)
## Vision Routing is configuration, not status — it lives here rather
## than in the dock. Not dev-gated: it is the Settings tab's primary
## content and must work for every user.
if vision_routing != null:
vision_routing.build_section(settings_tab)
## Remote access (advanced): collapsed by default; auto-expands when a
## non-empty CIDR allowlist is already configured so an active
## off-loopback bind is never hidden behind a collapsed header. The
## disclosure replaces the former developer-mode gate for this block.
_allow_hosts_fold = FoldableContainer.new()
_allow_hosts_fold.title = "Remote access (advanced)"
_allow_hosts_fold.folded = true
settings_tab.add_child(_allow_hosts_fold)
_allow_hosts_section = VBoxContainer.new()
_allow_hosts_section.add_theme_constant_override("separation", 6)
_allow_hosts_fold.add_child(_allow_hosts_section)
_allow_hosts_section.add_child(_make_header("Allow remote hosts (CIDR)"))
var intro := Label.new()
intro.text = (
"Comma-separated CIDRs or bare IPs (e.g. 192.168.1.0/24, 10.0.0.5). "
+ "When non-empty, the server binds off loopback and accepts MCP "
+ "connections from these ranges (--allow-host)."
)
intro.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
intro.add_theme_color_override("font_color", COLOR_MUTED)
intro.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_allow_hosts_section.add_child(intro)
## Warning banner — the DNS-rebinding guard is widened to every machine
## in the named ranges, so make the user name a network they trust
## instead of offering a blanket "expose everything" toggle (#507).
var warning := Label.new()
warning.text = (
"Warning: every machine in these ranges can drive this Godot editor, "
+ "and the DNS-rebinding guard's Host allowlist is widened to match. "
+ "Only name networks you trust. On untrusted or shared networks, "
+ "prefer an SSH tunnel or Tailscale instead of exposing the port."
)
warning.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
warning.add_theme_color_override("font_color", COLOR_AMBER)
warning.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_allow_hosts_section.add_child(warning)
_allow_hosts_edit = LineEdit.new()
_allow_hosts_edit.placeholder_text = "e.g. 192.168.1.0/24, 10.0.0.5 — empty = loopback only"
_allow_hosts_edit.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_allow_hosts_edit.text_changed.connect(_on_allow_hosts_text_changed)
_allow_hosts_section.add_child(_allow_hosts_edit)
_allow_hosts_hint = Label.new()
_allow_hosts_hint.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
_allow_hosts_hint.add_theme_color_override("font_color", Color.RED)
_allow_hosts_hint.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_allow_hosts_hint.visible = false
_allow_hosts_section.add_child(_allow_hosts_hint)
_allow_hosts_apply_btn = Button.new()
_allow_hosts_apply_btn.text = "Apply and Restart Server"
_allow_hosts_apply_btn.tooltip_text = (
"Save the allowlist to Editor Settings and reload the plugin so the "
+ "server respawns with --allow-host. Clear the field and Apply to "
+ "return to loopback-only."
)
_allow_hosts_apply_btn.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_allow_hosts_apply_btn.pressed.connect(_on_allow_hosts_apply)
_allow_hosts_section.add_child(_allow_hosts_apply_btn)
_reset_allow_hosts_from_setting()
func _reset_allow_hosts_from_setting() -> void:
_allow_hosts_saved = ClientConfigurator.allow_hosts()
_refresh_allow_hosts_fold_state()
if _allow_hosts_edit == null:
return
_allow_hosts_edit.text = _allow_hosts_saved
_refresh_allow_hosts_ui_state()
## Auto-expands the "Remote access (advanced)" disclosure whenever a
## non-empty allowlist is configured, so an active off-loopback bind is
## never hidden behind a collapsed header.
func _refresh_allow_hosts_fold_state() -> void:
if _allow_hosts_fold == null:
return
if ClientConfigurator.allow_hosts().is_empty():
_allow_hosts_fold.fold()
else:
_allow_hosts_fold.expand()
func _allow_hosts_is_dirty() -> bool:
if _allow_hosts_edit == null:
return false
return McpAllowHosts.normalize(_allow_hosts_edit.text) != _allow_hosts_saved
func _on_allow_hosts_text_changed(_new_text: String) -> void:
_refresh_allow_hosts_ui_state()
func _refresh_allow_hosts_ui_state() -> void:
if _allow_hosts_edit == null or _allow_hosts_apply_btn == null:
return
var error := McpAllowHosts.configuration_error(_allow_hosts_edit.text)
_allow_hosts_hint.text = error
_allow_hosts_hint.visible = not error.is_empty()
_allow_hosts_apply_btn.disabled = not _allow_hosts_is_dirty() or not error.is_empty()
func _on_allow_hosts_apply() -> void:
if _allow_hosts_edit == null:
return
var normalized := McpAllowHosts.normalize(_allow_hosts_edit.text)
if not McpAllowHosts.configuration_error(normalized).is_empty():
return
_allow_hosts_saved = normalized
_allow_hosts_edit.text = normalized
_refresh_allow_hosts_ui_state()
settings_apply_requested.emit({"allow_hosts": normalized}, true)
func _refresh_clients_summary() -> void:
# Count from cached row status values — `_apply_row_status` is the single
# source of truth, and reading cached status avoids re-running
# filesystem/CLI-hitting checks on every refresh. The same cache re-derives
# the drift banner so per-row mutations (Configure/Reconfigure/Remove on a
# row in the Clients & Tools window) keep the dock-level banner in sync
# without an extra sweep. See #166 and #226.
if _clients_summary_label == null:
return
var configured := 0
var mismatched_ids: Array[String] = []
for client_id in _client_rows:
var status: Client.Status = _client_rows[client_id].get("status", Client.Status.NOT_CONFIGURED)
if status == Client.Status.CONFIGURED:
configured += 1
elif status == Client.Status.CONFIGURED_MISMATCH:
mismatched_ids.append(client_id)
var text := "%d / %d configured" % [configured, _client_rows.size()]
if mismatched_ids.size() > 0:
text += " (%d stale)" % mismatched_ids.size()
var refresh_state := _client_refresh_state()
if ClientRefreshStateScript.should_show_checking_badge(refresh_state):
text += (
" (checking...)"
if refresh_state != ClientRefreshStateScript.RUNNING_TIMED_OUT
else " (client probe still running)"
)
_clients_summary_label.text = text
if _client_configure_all_btn != null:
_client_configure_all_btn.disabled = (
ClientRefreshStateScript.should_disable_client_actions(refresh_state)
and not _server_blocks_client_health()
)
if _client_empty_cta_btn != null:
_client_empty_cta_btn.visible = configured == 0 and _client_status_refresh_has_completed()
_refresh_drift_banner(mismatched_ids)
_update_status()
func _show_manual_command_for(client_id: String) -> void:
var row: Dictionary = _client_rows.get(client_id, {})
if row.is_empty():
return
var cmd := ClientConfigurator.manual_command(client_id)
if cmd.is_empty():
row["manual_panel"].visible = false
return
row["manual_text"].text = cmd
row["manual_panel"].visible = true
## #680: for rows low in the list the panel materializes below the
## visible scroll area and the Configure click looks like a no-op.
## Deferred so the just-shown panel has a settled rect to scroll to.
_scroll_manual_panel_into_view.call_deferred(row["manual_panel"])
func _scroll_manual_panel_into_view(panel: Control) -> void:
if panel == null or not panel.is_inside_tree():
return
var ancestor := panel.get_parent()
while ancestor != null and not (ancestor is ScrollContainer):
ancestor = ancestor.get_parent()
if ancestor != null:
(ancestor as ScrollContainer).ensure_control_visible(panel)
func _on_copy_manual_command(client_id: String) -> void:
var row: Dictionary = _client_rows.get(client_id, {})
if row.is_empty():
return
DisplayServer.clipboard_set(row["manual_text"].text)
func _on_open_config_file(client_id: String) -> void:
var path := _client_config_path_for_row(client_id)
if path.is_empty():
return
if FileAccess.file_exists(path):
OS.shell_open(path)
return
_reveal_config_folder(path)
func _on_reveal_config_folder(client_id: String) -> void:
var path := _client_config_path_for_row(client_id)
if path.is_empty():
return
_reveal_config_folder(path)
func _client_config_path_for_row(client_id: String) -> String:
var row: Dictionary = _client_rows.get(client_id, {})
if row.is_empty():
return ""
return String(row.get("config_path", ""))
func _reveal_config_folder(path: String) -> void:
var dir := path.get_base_dir()
if dir.is_empty():
return
OS.shell_open(dir)
func _refresh_all_client_statuses() -> void:
_request_client_status_refresh(true)
func _perform_initial_client_status_refresh() -> void:
if not is_inside_tree():
return
_request_client_status_refresh(false)
func _request_client_status_refresh(force: bool = false) -> bool:
if _server_blocks_client_health():
return false
if _is_self_update_in_progress() or _client_rows.is_empty():
return false
var ids: Array[String] = []
for client_id in _client_rows:
ids.append(String(client_id))
client_status_refresh_requested.emit(ids, force)
return true
func present_client_status_refresh_results(results: Dictionary) -> void:
if _server_blocks_client_health():
return
var busy := _busy_client_actions()
for client_id in results:
if busy.has(String(client_id)):
continue
var result: Dictionary = results[client_id]
_apply_row_status(
String(client_id),
result.get("status", Client.Status.NOT_CONFIGURED),
str(result.get("error_msg", "")),
result.get("installed", false)
)
_refresh_clients_summary()
func _server_blocks_client_health() -> bool:
return ServerStateScript.blocks_client_health(
int(_lifecycle_snapshot.get("state", ServerStateScript.UNINITIALIZED))
)
func _server_blocked_client_message() -> String:
var message := str(_lifecycle_snapshot.get("message", ""))
return message if not message.is_empty() else "server incompatible"
func _refresh_drift_banner(mismatched_ids: Array[String]) -> void:
if _drift_banner == null:
return
## Sort so set-equality is order-independent — `_client_rows` iteration
## order is dict-insertion order, but a future change to the iteration
## site shouldn't make us repaint identical content.
mismatched_ids = mismatched_ids.duplicate()
mismatched_ids.sort()
if mismatched_ids == _last_mismatched_ids:
return
_last_mismatched_ids = mismatched_ids
if mismatched_ids.is_empty():
_drift_banner.visible = false
return
var names: Array[String] = []
for id in mismatched_ids:
names.append(ClientConfigurator.client_display_name(id))
## Active server URL is already shown on the WS:/HTTP: line above the
## Clients section, so it doesn't need to repeat here. Lead with the
## client names — that's the only thing the user can act on.
var verb := "needs" if mismatched_ids.size() == 1 else "need"
_drift_label.text = "%s %s to be reconfigured." % [", ".join(names), verb]
_drift_banner.visible = true
func _on_reconfigure_mismatched() -> void:
## Re-Configure every client whose URL is currently stale. Iterates the
## cached list from the most recent sweep instead of re-running
## `check_status` per row (saves ~18 filesystem reads per click). The
## trailing `_refresh_all_client_statuses()` re-sweeps anyway, so any
## entries the user manually fixed between sweep and click get re-counted
## as CONFIGURED there.
for client_id in _last_mismatched_ids:
if _client_rows.has(client_id):
_on_configure_client(client_id)
_refresh_all_client_statuses()
func _apply_row_status(
client_id: String,
status: Client.Status,
error_msg: String = "",
installed_override: Variant = null,
) -> void:
var row: Dictionary = _client_rows.get(client_id, {})
if row.is_empty():
return
row["status"] = status
var dot: ColorRect = row["dot"]
var configure_btn: Button = row["configure_btn"]
var remove_btn: Button = row["remove_btn"]
var name_label: Label = row["name_label"]
var base_name := ClientConfigurator.client_display_name(client_id)
_refresh_client_config_file_buttons(client_id)
match status:
Client.Status.CONFIGURED:
dot.color = Color.GREEN
configure_btn.text = "Reconfigure"
remove_btn.visible = true
## `error_msg` doubles as a detail slot on the green path: a
## successful configure passes the sweep note (#877). Transient by
## design — the next status refresh re-applies CONFIGURED with no
## detail, so the row settles back to its plain name.
name_label.text = (
"%s (%s)" % [base_name, error_msg] if not error_msg.is_empty() else base_name
)
Client.Status.NOT_CONFIGURED:
dot.color = COLOR_MUTED
configure_btn.text = "Configure"
remove_btn.visible = false
var installed: bool = installed_override if installed_override != null else ClientConfigurator.is_installed(client_id)
name_label.text = base_name if installed else "%s (not detected)" % base_name
Client.Status.CONFIGURED_MISMATCH:
## Amber matches the dock-level drift banner so a glance at the
## row + the banner read as the same condition.
dot.color = COLOR_AMBER
configure_btn.text = "Reconfigure"
remove_btn.visible = true
## Drift is usually a stale URL, but a `{scope}` client can also be
## registered in a scope the user did not select (#872), and
## "URL out of date" would be a wrong description of that. Prefer
## the probe's own words whenever it supplied any.
name_label.text = (
"%s (%s)" % [base_name, error_msg]
if not error_msg.is_empty()
else "%s (URL out of date)" % base_name
)
_:
dot.color = Color.RED
configure_btn.text = "Retry"
remove_btn.visible = false
name_label.text = "%s — %s" % [base_name, error_msg] if not error_msg.is_empty() else base_name
func _refresh_client_config_file_buttons(client_id: String) -> void:
var row: Dictionary = _client_rows.get(client_id, {})
if row.is_empty():
return
# Re-resolve on every refresh so the Open/Reveal buttons follow the entry
# when it lands in (or moves to) a higher-precedence merge tier (codex F3).
# F-3-4: switched from `effective_config_path` to the authoritative facade
# so multi-project-tier cases resolve to the LATEST tier (matching F2
# status semantics) instead of failing closed to `path_template`.
row["config_path"] = ClientConfigurator.effective_authoritative_path(client_id)
var config_path := String(row["config_path"])
var has_path := not config_path.is_empty()
var open_config_btn: Button = row["open_config_btn"]
var reveal_btn: Button = row["reveal_btn"]
open_config_btn.visible = has_path
reveal_btn.visible = has_path
open_config_btn.disabled = not has_path
reveal_btn.disabled = not has_path
if has_path:
open_config_btn.tooltip_text = "Open config file:\n%s" % config_path
reveal_btn.tooltip_text = "Reveal in folder:\n%s" % config_path.get_base_dir()
else:
open_config_btn.tooltip_text = ""
reveal_btn.tooltip_text = ""
# --- Update check & self-update ---
## Tolerates a null manager so test fixtures that build the dock without
## `_build_ui()` don't false-positive on the worker-spawn gate.
func _is_self_update_in_progress() -> bool:
return _update_install_in_flight
func _on_update_pressed() -> void:
if not _post_update_action.is_empty():
post_update_action_requested.emit(_post_update_action)
return
## Updating briefly disconnects AI tools while the add-on is replaced.
## A dock outside the scene tree has no dialog and proceeds directly.
if _update_confirm != null and is_inside_tree():
_update_confirm.dialog_text = update_confirm_text(
_update_candidate_version, ClientConfigurator.get_plugin_version()
)
_update_confirm.popup_centered()
return
update_requested.emit()
func _on_update_confirmed() -> void:
update_requested.emit()
static func update_confirm_text(version: String, current_version: String) -> String:
var target := "Godot AI v%s" % version if not version.is_empty() else "the new Godot AI"
var text := "Update to %s? Unsaved changes are kept." % target
if McpServerVersionCheck.attached_bridges_follow(current_version, version):
text += "\n\nRestart AI clients older than v4.0.4."
else:
text += "\n\nRestart your AI client after updating."
return text
func present_update_check(result: Dictionary) -> void:
_update_candidate_version = String(result.get("version", ""))
_update_label.text = String(result.get("label_text", ""))
_update_label.add_theme_color_override("font_color", _UPDATE_LABEL_COLOR)
_update_banner.visible = true
## A fresh candidate re-arms the action. The restarted editor after an
## update shows "Godot AI installed" with the button disabled; a newer release
## found later in that same session must still be installable. A running
## install or a pending post-update action keeps ownership of the button.
if _update_install_in_flight or not _post_update_action.is_empty():
return
_set_update_status("")
if _update_btn != null:
_update_btn.text = _UPDATE_ACTION_TEXT
_update_btn.disabled = false
## Apply only the keys present so the manager can ship partial updates
## (e.g. status-only during the download phase) without clobbering banner
## state. `button_text` names an action ("Retry client migration");
## progress and failure messages arrive as `status_text` and never replace
## the button label.
func present_update_state(state: Dictionary) -> void:
if state.has("post_update_action"):
_post_update_action = String(state["post_update_action"])
if _post_update_action.is_empty() and _update_btn != null:
_update_btn.text = _UPDATE_ACTION_TEXT
if state.has("install_in_flight"):
_update_install_in_flight = bool(state["install_in_flight"])
if String(state.get("post_update_action", "")) == "retry":
## The migration barrier refused: the connection really is blocked.
_post_update_server_pending = false
elif bool(state.get("install_in_flight", false)) or String(state.get("outcome", "")) == "success":
_post_update_server_pending = true
if _status_label != null:
_update_status()
elif state.has("install_in_flight"):
## The install ended without a swap (`_fail_update`): the previous
## version is live and the transport status is the truth again.
_post_update_server_pending = false
if _status_label != null:
_update_status()
if state.has("button_text") and _update_btn != null:
_update_btn.text = String(state["button_text"])
if state.has("button_disabled") and _update_btn != null:
_update_btn.disabled = bool(state["button_disabled"])
if state.has("status_text"):
_set_update_status(String(state["status_text"]))
if state.has("label_text") and _update_label != null:
_update_label.text = String(state["label_text"])
if state.has("banner_visible") and _update_banner != null:
_update_banner.visible = bool(state["banner_visible"])
if state.has("label_text") and _update_label != null:
## Installation is distinct from server/client readiness. Instructions
## can still name failed migrations or clients that must reconnect.
_update_label.add_theme_color_override("font_color", _UPDATE_LABEL_COLOR)
func _set_update_status(text: String) -> void:
if _update_status_label == null:
return
_update_status_label.text = text
_update_status_label.visible = not text.is_empty()