Files
tekton/addons/godot_ai/plugin.gd
T

1701 lines
70 KiB
GDScript

@tool
extends EditorPlugin
const GAME_HELPER_AUTOLOAD_NAME := "_mcp_game_helper"
const GAME_HELPER_AUTOLOAD_PATH := "res://addons/godot_ai/runtime/game_helper.gd"
## Editor-process Logger subclass — captures parse errors, @tool runtime
## errors, and push_error/push_warning so the LLM can read them via
## `logs_read(source="editor")`. Loaded dynamically because
## `extends Logger` requires Godot 4.5+. The logger script lives in the
## `.gdignore`'d `runtime/loggers/` folder so Godot's editor scan never
## parses it (no "Could not find base class Logger" error on < 4.5), and
## LoggerLoader compiles it from source at runtime only after the
## ClassDB.class_exists("Logger") gate below. See issue #231 / #475.
const LoggerLoader := preload("res://addons/godot_ai/runtime/logger_loader.gd")
## EditorSettings keys used to remember which server process the plugin
## spawned — survives editor restarts, lets a later editor session adopt
## and manage a server it didn't spawn itself. See #135.
const MANAGED_SERVER_PID_SETTING := "godot_ai/managed_server_pid"
const MANAGED_SERVER_VERSION_SETTING := "godot_ai/managed_server_version"
const MANAGED_SERVER_WS_PORT_SETTING := "godot_ai/managed_server_ws_port"
const UPDATE_RELOAD_RUNNER_SCRIPT := preload("res://addons/godot_ai/update_reload_runner.gd")
## Preloaded so `_stop_server` / `force_restart_server` have a local script
## dependency for the cleanup helper. See utils/uv_cache_cleanup.gd for what
## this does and why it lives next to the server-stop hot path.
const UvCacheCleanup := preload("res://addons/godot_ai/utils/uv_cache_cleanup.gd")
## Server lifecycle + port discovery extracted from this file (#297 PR 5).
## State enums + version-check seam extracted in PR 6 (#297). Plugin.gd
## keeps thin shims so the dock and characterization tests see an
## unchanged public surface; spawn-machinery state now lives in the
## lifecycle manager.
const ServerLifecycleManager := preload("res://addons/godot_ai/utils/server_lifecycle.gd")
const PortResolver := preload("res://addons/godot_ai/utils/port_resolver.gd")
const ServerStateScript := preload("res://addons/godot_ai/utils/mcp_server_state.gd")
const StartupPathScript := preload("res://addons/godot_ai/utils/mcp_startup_path.gd")
## Plugin-class scripts used by this file. The script-local preload aliases
## are ordinary dependency shorthand and keep construction sites compact.
## They are not the self-update safety boundary; #398 was stale Script-object
## content from a mixed old/new snapshot, fixed by the runner's single-phase
## write-before-scan model.
const Connection := preload("res://addons/godot_ai/connection.gd")
const Dispatcher := preload("res://addons/godot_ai/dispatcher.gd")
const Telemetry := preload("res://addons/godot_ai/telemetry.gd")
const LogBuffer := preload("res://addons/godot_ai/utils/log_buffer.gd")
const GameLogBuffer := preload("res://addons/godot_ai/utils/game_log_buffer.gd")
const EditorLogBuffer := preload("res://addons/godot_ai/utils/editor_log_buffer.gd")
const Dock := preload("res://addons/godot_ai/mcp_dock.gd")
const DebuggerPlugin := preload("res://addons/godot_ai/debugger/mcp_debugger_plugin.gd")
const ClientConfigurator := preload("res://addons/godot_ai/client_configurator.gd")
const WindowsPortReservation := preload("res://addons/godot_ai/utils/windows_port_reservation.gd")
## Handlers — preloaded as consts instead of registered via `class_name` so
## they don't pollute the project-wide global scope. A user project that
## happens to define its own `InputHandler`, `SceneHandler`, etc. would
## otherwise hard-error on plugin enable.
const EditorHandler := preload("res://addons/godot_ai/handlers/editor_handler.gd")
const SceneHandler := preload("res://addons/godot_ai/handlers/scene_handler.gd")
const NodeHandler := preload("res://addons/godot_ai/handlers/node_handler.gd")
const ProjectHandler := preload("res://addons/godot_ai/handlers/project_handler.gd")
const ClientHandler := preload("res://addons/godot_ai/handlers/client_handler.gd")
const ScriptHandler := preload("res://addons/godot_ai/handlers/script_handler.gd")
const ResourceHandler := preload("res://addons/godot_ai/handlers/resource_handler.gd")
const ApiHandler := preload("res://addons/godot_ai/handlers/api_handler.gd")
const FilesystemHandler := preload("res://addons/godot_ai/handlers/filesystem_handler.gd")
const SignalHandler := preload("res://addons/godot_ai/handlers/signal_handler.gd")
const AutoloadHandler := preload("res://addons/godot_ai/handlers/autoload_handler.gd")
const InputHandler := preload("res://addons/godot_ai/handlers/input_handler.gd")
const TestHandler := preload("res://addons/godot_ai/handlers/test_handler.gd")
const BatchHandler := preload("res://addons/godot_ai/handlers/batch_handler.gd")
const UiHandler := preload("res://addons/godot_ai/handlers/ui_handler.gd")
const ThemeHandler := preload("res://addons/godot_ai/handlers/theme_handler.gd")
const AnimationHandler := preload("res://addons/godot_ai/handlers/animation_handler.gd")
const MaterialHandler := preload("res://addons/godot_ai/handlers/material_handler.gd")
const ParticleHandler := preload("res://addons/godot_ai/handlers/particle_handler.gd")
const CameraHandler := preload("res://addons/godot_ai/handlers/camera_handler.gd")
const AudioHandler := preload("res://addons/godot_ai/handlers/audio_handler.gd")
const PhysicsShapeHandler := preload("res://addons/godot_ai/handlers/physics_shape_handler.gd")
const EnvironmentHandler := preload("res://addons/godot_ai/handlers/environment_handler.gd")
const TextureHandler := preload("res://addons/godot_ai/handlers/texture_handler.gd")
const CurveHandler := preload("res://addons/godot_ai/handlers/curve_handler.gd")
const ControlDrawRecipeHandler := preload("res://addons/godot_ai/handlers/control_draw_recipe_handler.gd")
## The Python server writes its own PID here on startup (passed as
## `--pid-file`) and unlinks on clean exit. Deterministic replacement
## for scraping `netstat -ano` to find the port owner — especially on
## Windows where `OS.kill` on the uvx launcher doesn't take the Python
## child with it, and the scrape was the only path to the real PID.
## See issue for #154-era Windows update friction.
## Re-export of PortResolver.SERVER_PID_FILE so the spawn flags, the
## resolver, and characterization tests share one source of truth.
const SERVER_PID_FILE := PortResolver.SERVER_PID_FILE
## How long we watch the spawned server for early exit. If the process is
## still alive when this expires, we stop watching. Mid-session crashes
## after this point get caught by the WebSocket disconnect flow.
const SERVER_WATCH_MS := 30 * 1000
## Python's import graph (FastMCP + Rich + uvicorn) plus the pid-file write
## take a beat on cold starts, especially on Windows. Hold off on declaring
## a spawn a crash until this window elapses so the watch loop has time to
## observe either the pid-file (dev venv) or the port listening (uvx).
const SPAWN_GRACE_MS := 5 * 1000
const SERVER_STATUS_PATH := "/godot-ai/status"
const SERVER_STATUS_PROBE_TIMEOUT_MS := 800
const SERVER_HANDSHAKE_VERSION_TIMEOUT_MS := 5 * 1000
const STARTUP_TRACE_COUNTER_NAMES := [
"powershell",
"netstat",
"netsh",
"lsof",
"http_status_probe",
"server_command_discovery",
]
## Untyped on purpose — see policy below. Type fences move to handler `_init`
## sites that take typed parameters.
##
## Self-update field and load-surface policy: plugin entry-load fields that
## survive reload stay untyped. Typed fields against plugin-defined classes
## were the #242 / #244 crash class: Godot can reparse a long-lived script
## while its old field storage and the new type shape disagree. Static-var
## initializers are the most dangerous form because they execute at
## script-load; a top-level typed Dictionary/Array storage change can fail
## before `_enter_tree` runs.
##
## The mitigation is two-part:
## (1) Field declarations are untyped (this block).
## (2) Construction and static access use local names declared at the top
## of the file (e.g. `Connection`, `Dispatcher`, `LogBuffer`,
## `ClientConfigurator`, `WindowsPortReservation`, ...), which keeps
## this entry script's load surface explicit and reviewable.
##
## Constructors, constants, and static methods on `Mcp*` classes are not the
## self-update safety metric under the single-phase runner. The old syntactic
## lint counted bare `Mcp*.MEMBER` references, but #398 was caused by the
## runner scanning a mixed old/new snapshot and reusing stale Script-object
## content. Bare names and preload aliases can both be parsed against stale
## content under an old two-phase runner; from the fixed runner onward the
## full v(N+1) snapshot is written before the scan. In short: preload aliases
## are not the self-update safety metric.
##
## `tests/unit/test_plugin_self_update_safety.py` locks this wording in.
##
## `_editor_logger` is untyped because its script extends Godot 4.5+'s Logger
## class: `logger_loader.gd` compiles it at runtime from on-disk source
## (FileAccess + `GDScript.new()`) past the `ClassDB.class_exists("Logger")`
## gate in `_attach_editor_logger`, so the plugin still parses on 4.4. Null on
## Godot < 4.5 or before `_attach_editor_logger` runs; "attached" state IS
## exactly "non-null".
var _connection
var _dispatcher
var _telemetry
var _log_buffer
var _game_log_buffer
var _editor_log_buffer
var _editor_logger
var _dock
var _handlers: Array = [] # prevent GC of RefCounted handlers
var _debugger_plugin
## Spawn / stop / adopt orchestration plus state machine; allocated in
## `_init` so test fixtures (which never enter the tree) can drive
## `_start_server`. Owns `_server_pid`, `_server_state`, the version-
## check seam, and the adoption-confirmation deadline — see
## `utils/server_lifecycle.gd`.
var _lifecycle
static var _server_started_this_session := false # guard against re-entrant spawns
static var _resolved_ws_port := ClientConfigurator.DEFAULT_WS_PORT
## Server-watch timer lives on the plugin because it's a Node — the
## manager is RefCounted and can't host children.
var _server_watch_timer: Timer = null
var _headless_disabled := false
var _startup_trace_enabled := false
var _startup_trace_start_ms := 0
var _startup_trace_last_ms := 0
var _startup_trace_counters: Dictionary = {}
var _startup_trace_netsh_start_count := 0
func _init() -> void:
_lifecycle = ServerLifecycleManager.new(self)
func _enter_tree() -> void:
_startup_trace_begin()
## `_process` is only used by the adoption-confirmation watcher; keep
## it off until `_watch_for_adoption_confirmation` arms it, so the
## plugin has zero per-frame cost in the common case.
set_process(false)
if _mcp_disabled_for_headless_launch():
_headless_disabled = true
print("MCP | plugin disabled in headless mode")
return
## Self-update from a pre-loggers/ version leaves the old logger scripts
## orphaned at runtime/*.gd (the runner only writes files in the new ZIP,
## it doesn't prune). Those still `extends Logger` and re-emit the parse
## errors on Godot < 4.5. Delete them once so upgraders match a fresh
## install. No-op on fresh installs and dev checkouts (files absent).
_cleanup_legacy_logger_scripts()
## Register port overrides before spawn so `http_port()` / `ws_port()`
## return the user's configured values (if any) when `_start_server`
## builds the CLI args.
ClientConfigurator.ensure_settings_registered()
_startup_trace_phase("settings_registered")
_log_buffer = LogBuffer.new()
_start_server()
_startup_trace_phase("server_start")
_game_log_buffer = GameLogBuffer.new()
_editor_log_buffer = EditorLogBuffer.new()
_attach_editor_logger()
_dispatcher = Dispatcher.new(_log_buffer)
_startup_trace_phase("core_objects")
_connection = Connection.new()
_connection.log_buffer = _log_buffer
_connection.ws_port = _resolved_ws_port
_connection.connect_blocked = _lifecycle.is_connection_blocked()
_connection.connect_block_reason = _lifecycle.get_status_dict().get("message", "")
if (
not _lifecycle.is_connection_blocked()
and not ServerStateScript.is_terminal_diagnosis(_lifecycle.get_state())
):
_arm_server_version_check()
_telemetry = Telemetry.new(_connection)
_debugger_plugin = DebuggerPlugin.new(_log_buffer, _game_log_buffer, _editor_log_buffer)
add_debugger_plugin(_debugger_plugin)
_ensure_game_helper_autoload()
var editor_handler := EditorHandler.new(_log_buffer, _connection, _debugger_plugin, _game_log_buffer, _editor_log_buffer)
var scene_handler := SceneHandler.new(_connection)
var node_handler := NodeHandler.new(get_undo_redo())
var project_handler := ProjectHandler.new(_connection, _debugger_plugin, _editor_log_buffer)
var client_handler := ClientHandler.new()
var script_handler := ScriptHandler.new(get_undo_redo(), _connection)
var resource_handler := ResourceHandler.new(get_undo_redo(), _connection)
var api_handler := ApiHandler.new()
var filesystem_handler := FilesystemHandler.new()
var signal_handler := SignalHandler.new(get_undo_redo())
var autoload_handler := AutoloadHandler.new()
var input_handler := InputHandler.new()
var test_handler := TestHandler.new(get_undo_redo(), _log_buffer)
var batch_handler := BatchHandler.new(_dispatcher, get_undo_redo())
var ui_handler := UiHandler.new(get_undo_redo())
var theme_handler := ThemeHandler.new(get_undo_redo())
var animation_handler := AnimationHandler.new(get_undo_redo())
var material_handler := MaterialHandler.new(get_undo_redo())
var particle_handler := ParticleHandler.new(get_undo_redo())
var camera_handler := CameraHandler.new(get_undo_redo())
var audio_handler := AudioHandler.new(get_undo_redo())
var physics_shape_handler := PhysicsShapeHandler.new(get_undo_redo())
var environment_handler := EnvironmentHandler.new(get_undo_redo(), _connection)
var texture_handler := TextureHandler.new(get_undo_redo(), _connection)
var curve_handler := CurveHandler.new(get_undo_redo(), _connection)
var control_draw_recipe_handler := ControlDrawRecipeHandler.new(get_undo_redo())
_handlers = [editor_handler, scene_handler, node_handler, project_handler, client_handler, script_handler, resource_handler, api_handler, filesystem_handler, signal_handler, autoload_handler, input_handler, test_handler, batch_handler, ui_handler, theme_handler, animation_handler, material_handler, particle_handler, camera_handler, audio_handler, physics_shape_handler, environment_handler, texture_handler, curve_handler, control_draw_recipe_handler]
_dispatcher.register("get_editor_state", editor_handler.get_editor_state)
_dispatcher.register("get_scene_tree", scene_handler.get_scene_tree)
_dispatcher.register("get_open_scenes", scene_handler.get_open_scenes)
_dispatcher.register("find_nodes", scene_handler.find_nodes)
_dispatcher.register("create_scene", scene_handler.create_scene)
_dispatcher.register("open_scene", scene_handler.open_scene)
_dispatcher.register("save_scene", scene_handler.save_scene)
_dispatcher.register("save_scene_as", scene_handler.save_scene_as)
_dispatcher.register("get_selection", editor_handler.get_selection)
_dispatcher.register("create_node", node_handler.create_node)
_dispatcher.register("delete_node", node_handler.delete_node)
_dispatcher.register("reparent_node", node_handler.reparent_node)
_dispatcher.register("set_property", node_handler.set_property)
_dispatcher.register("rename_node", node_handler.rename_node)
_dispatcher.register("duplicate_node", node_handler.duplicate_node)
_dispatcher.register("move_node", node_handler.move_node)
_dispatcher.register("add_to_group", node_handler.add_to_group)
_dispatcher.register("remove_from_group", node_handler.remove_from_group)
_dispatcher.register("set_selection", node_handler.set_selection)
_dispatcher.register("get_node_properties", node_handler.get_node_properties)
_dispatcher.register("get_children", node_handler.get_children)
_dispatcher.register("get_groups", node_handler.get_groups)
_dispatcher.register("get_logs", editor_handler.get_logs)
_dispatcher.register("clear_logs", editor_handler.clear_logs)
_dispatcher.register("take_screenshot", editor_handler.take_screenshot)
_dispatcher.register("get_performance_monitors", editor_handler.get_performance_monitors)
_dispatcher.register("reload_plugin", editor_handler.reload_plugin)
_dispatcher.register("quit_editor", editor_handler.quit_editor)
_dispatcher.register("game_eval", editor_handler.game_eval)
_dispatcher.register("game_command", editor_handler.game_command)
_dispatcher.register("get_project_setting", project_handler.get_project_setting)
_dispatcher.register("set_project_setting", project_handler.set_project_setting)
_dispatcher.register("run_project", project_handler.run_project)
_dispatcher.register("stop_project", project_handler.stop_project)
_dispatcher.register("search_filesystem", project_handler.search_filesystem)
_dispatcher.register("configure_client", client_handler.configure_client)
_dispatcher.register("remove_client", client_handler.remove_client)
_dispatcher.register("check_client_status", client_handler.check_client_status)
_dispatcher.register("create_script", script_handler.create_script)
_dispatcher.register("patch_script", script_handler.patch_script)
_dispatcher.register("read_script", script_handler.read_script)
_dispatcher.register("attach_script", script_handler.attach_script)
_dispatcher.register("detach_script", script_handler.detach_script)
_dispatcher.register("find_symbols", script_handler.find_symbols)
_dispatcher.register("search_resources", resource_handler.search_resources)
_dispatcher.register("load_resource", resource_handler.load_resource)
_dispatcher.register("assign_resource", resource_handler.assign_resource)
_dispatcher.register("create_resource", resource_handler.create_resource)
_dispatcher.register("get_resource_info", resource_handler.get_resource_info)
_dispatcher.register("get_class_info", api_handler.get_class_info)
_dispatcher.register("read_file", filesystem_handler.read_file)
_dispatcher.register("write_file", filesystem_handler.write_file)
_dispatcher.register("reimport", filesystem_handler.reimport)
_dispatcher.register("list_signals", signal_handler.list_signals)
_dispatcher.register("connect_signal", signal_handler.connect_signal)
_dispatcher.register("disconnect_signal", signal_handler.disconnect_signal)
_dispatcher.register("list_autoloads", autoload_handler.list_autoloads)
_dispatcher.register("add_autoload", autoload_handler.add_autoload)
_dispatcher.register("remove_autoload", autoload_handler.remove_autoload)
_dispatcher.register("list_actions", input_handler.list_actions)
_dispatcher.register("add_action", input_handler.add_action)
_dispatcher.register("remove_action", input_handler.remove_action)
_dispatcher.register("bind_event", input_handler.bind_event)
_dispatcher.register("run_tests", test_handler.run_tests)
_dispatcher.register("get_test_results", test_handler.get_test_results)
_dispatcher.register("batch_execute", batch_handler.batch_execute)
_dispatcher.register("set_anchor_preset", ui_handler.set_anchor_preset)
_dispatcher.register("set_text", ui_handler.set_text)
_dispatcher.register("build_layout", ui_handler.build_layout)
_dispatcher.register("create_theme", theme_handler.create_theme)
_dispatcher.register("theme_set_color", theme_handler.set_color)
_dispatcher.register("theme_set_constant", theme_handler.set_constant)
_dispatcher.register("theme_set_font_size", theme_handler.set_font_size)
_dispatcher.register("theme_set_stylebox_flat", theme_handler.set_stylebox_flat)
_dispatcher.register("apply_theme", theme_handler.apply_theme)
_dispatcher.register("animation_player_create", animation_handler.create_player)
_dispatcher.register("animation_create", animation_handler.create_animation)
_dispatcher.register("animation_add_property_track", animation_handler.add_property_track)
_dispatcher.register("animation_add_method_track", animation_handler.add_method_track)
_dispatcher.register("animation_set_autoplay", animation_handler.set_autoplay)
_dispatcher.register("animation_play", animation_handler.play)
_dispatcher.register("animation_stop", animation_handler.stop)
_dispatcher.register("animation_list", animation_handler.list_animations)
_dispatcher.register("animation_get", animation_handler.get_animation)
_dispatcher.register("animation_create_simple", animation_handler.create_simple)
_dispatcher.register("animation_delete", animation_handler.delete_animation)
_dispatcher.register("animation_validate", animation_handler.validate_animation)
_dispatcher.register("animation_preset_fade", animation_handler.preset_fade)
_dispatcher.register("animation_preset_slide", animation_handler.preset_slide)
_dispatcher.register("animation_preset_shake", animation_handler.preset_shake)
_dispatcher.register("animation_preset_pulse", animation_handler.preset_pulse)
_dispatcher.register("material_create", material_handler.create_material)
_dispatcher.register("material_set_param", material_handler.set_param)
_dispatcher.register("material_set_shader_param", material_handler.set_shader_param)
_dispatcher.register("material_get", material_handler.get_material)
_dispatcher.register("material_list", material_handler.list_materials)
_dispatcher.register("material_assign", material_handler.assign_material)
_dispatcher.register("material_apply_to_node", material_handler.apply_to_node)
_dispatcher.register("material_apply_preset", material_handler.apply_preset)
_dispatcher.register("particle_create", particle_handler.create_particle)
_dispatcher.register("particle_set_main", particle_handler.set_main)
_dispatcher.register("particle_set_process", particle_handler.set_process)
_dispatcher.register("particle_set_draw_pass", particle_handler.set_draw_pass)
_dispatcher.register("particle_restart", particle_handler.restart_particle)
_dispatcher.register("particle_get", particle_handler.get_particle)
_dispatcher.register("particle_apply_preset", particle_handler.apply_preset)
_dispatcher.register("camera_create", camera_handler.create_camera)
_dispatcher.register("camera_configure", camera_handler.configure)
_dispatcher.register("camera_set_limits_2d", camera_handler.set_limits_2d)
_dispatcher.register("camera_set_damping_2d", camera_handler.set_damping_2d)
_dispatcher.register("camera_follow_2d", camera_handler.follow_2d)
_dispatcher.register("camera_get", camera_handler.get_camera)
_dispatcher.register("camera_list", camera_handler.list_cameras)
_dispatcher.register("camera_apply_preset", camera_handler.apply_preset)
_dispatcher.register("audio_player_create", audio_handler.create_player)
_dispatcher.register("audio_player_set_stream", audio_handler.set_stream)
_dispatcher.register("audio_player_set_playback", audio_handler.set_playback)
_dispatcher.register("audio_play", audio_handler.play)
_dispatcher.register("audio_stop", audio_handler.stop)
_dispatcher.register("audio_list", audio_handler.list_streams)
_dispatcher.register("physics_shape_autofit", physics_shape_handler.autofit)
_dispatcher.register("environment_create", environment_handler.create_environment)
_dispatcher.register("gradient_texture_create", texture_handler.create_gradient_texture)
_dispatcher.register("noise_texture_create", texture_handler.create_noise_texture)
_dispatcher.register("curve_set_points", curve_handler.set_points)
_dispatcher.register(
"control_draw_recipe", control_draw_recipe_handler.control_draw_recipe
)
_connection.dispatcher = _dispatcher
add_child(_connection)
_startup_trace_phase("handlers_registered")
# Dock panel
_dock = Dock.new()
_dock.name = "Godot AI"
_dock.setup(_connection, _log_buffer, self)
add_control_to_dock(DOCK_SLOT_RIGHT_BL, _dock)
_startup_trace_phase("dock_attached")
_log_buffer.log("plugin loaded")
if _telemetry != null:
_telemetry.record_dock_startup()
_flush_pending_self_update_telemetry()
_telemetry.flush_pending_plugin_reload()
var startup_path: String = str(_lifecycle.get_startup_path())
_startup_trace_finish(startup_path if not startup_path.is_empty() else "loaded")
## Public wrapper around the dev-server-toggle telemetry emit. Lets the
## dock (or any other caller) record without reaching into ``_telemetry``
## directly — keeps the plugin's internal field encapsulated. The dev
## server is a Python subprocess unrelated to the plugin's own
## lifecycle, so emission can be synchronous (no EditorSettings persist
## dance like ``plugin_reload`` / ``self_update``).
func record_dev_server_toggle(action: String) -> void:
if _telemetry == null:
return
_telemetry.record_dev_server_toggle(action)
## Drain any self_update event written by `update_reload_runner` during the
## previous disable -> enable window.
func _flush_pending_self_update_telemetry() -> void:
var key := UPDATE_RELOAD_RUNNER_SCRIPT.PENDING_SELF_UPDATE_TELEMETRY_KEY
var parsed = Telemetry._drain_editor_setting_dict(key)
if parsed == null:
return
var status := str(parsed.get("status", "unknown"))
var error := str(parsed.get("error", ""))
## Positional args: GDScript doesn't support keyword args in calls
## (unlike Python). from_version + to_version are empty strings here
## — only ``status`` and ``error`` are known at flush time.
_telemetry.record_self_update(status, "", "", error)
func _exit_tree() -> void:
if _headless_disabled:
_server_started_this_session = false
_headless_disabled = false
return
## Outer-to-inner teardown. Dispatcher Callables hold RefCounted handlers
## alive past the point where Godot reloads their class_name scripts — the
## first post-reload call into a typed-array-holding handler (e.g.
## McpGameLogBuffer._storage) then SIGSEGVs against a stale class descriptor.
## See issue #46.
# Stop inbound work first so _process can't enqueue new commands or
# null-deref log_buffer on the next tick mid-teardown.
if _connection:
_connection.teardown()
# Break the Callable -> handler ref chain before dropping _handlers, so the
# array clear actually decrefs the handler RefCounteds to zero.
if _dispatcher:
_dispatcher.clear()
# Handler destructors run here, while their class_name scripts are still loaded.
_handlers.clear()
if _dock:
remove_control_from_docks(_dock)
_dock.queue_free()
_dock = null
if _connection:
_connection.queue_free()
_connection = null
if _debugger_plugin:
remove_debugger_plugin(_debugger_plugin)
_debugger_plugin = null
## Detach the editor logger BEFORE nulling the buffer. After remove_logger
## returns, Godot guarantees no further virtual calls — so the logger's
## next access to `_buffer` (if any in flight) lands on a still-live
## ref-counted buffer, not a freed one.
_detach_editor_logger()
_dispatcher = null
_log_buffer = null
_game_log_buffer = null
_editor_log_buffer = null
_stop_server()
## Symmetric with prepare_for_update_reload: the static guard persists
## across disable/enable within a single editor session, so the re-enabled
## plugin instance's _start_server would short-circuit and never respawn.
## Pre-#159 this was masked — the old kill path usually left Python alive
## and the new instance adopted it on port 8000. Now that _stop_server is
## deterministic, nothing is left to adopt and the reload hangs.
_server_started_this_session = false
print("MCP | plugin unloaded")
## Attach editor_logger.gd as a Godot logger so editor-process script
## errors (parse errors, @tool runtime errors, EditorPlugin errors,
## push_error/push_warning) flow into _editor_log_buffer for
## logs_read(source="editor"). Logger subclassing is 4.5+ only; the
## ClassDB gate keeps the plugin loadable on 4.4 with no-op editor logs
## (the buffer stays empty, logs_read returns no entries).
##
## Limitation called out in the issue: parse errors fired *before* the
## plugin's _enter_tree (e.g. during the editor's initial filesystem
## scan, or for scripts that fail on first project open) happen before
## add_logger is called and are not captured. There's no public API to
## drain the editor's already-emitted error history; rescanning the
## file would re-emit them but at the cost of disrupting the user's
## editing state, so we accept the gap.
func _attach_editor_logger() -> void:
if not (ClassDB.class_exists("Logger") and OS.has_method("add_logger")):
return
var logger_script := LoggerLoader.build(LoggerLoader.EDITOR_LOGGER_PATH)
if logger_script == null:
return
_editor_logger = logger_script.new(_editor_log_buffer)
OS.call("add_logger", _editor_logger)
## Remove the pre-2.5.8 logger scripts left at runtime/*.gd by a self-update
## (the runner doesn't prune files dropped between versions). They `extends
## Logger` and would re-emit "Could not find base class Logger" parse errors
## on Godot < 4.5 even though the live copies now live in the .gdignore'd
## runtime/loggers/ folder. Idempotent: existence-guarded, so it's a no-op on
## fresh installs and symlinked dev checkouts.
func _cleanup_legacy_logger_scripts() -> void:
var legacy := [
"res://addons/godot_ai/runtime/editor_logger.gd",
"res://addons/godot_ai/runtime/editor_logger.gd.uid",
"res://addons/godot_ai/runtime/game_logger.gd",
"res://addons/godot_ai/runtime/game_logger.gd.uid",
]
for res_path in legacy:
if FileAccess.file_exists(res_path):
DirAccess.remove_absolute(ProjectSettings.globalize_path(res_path))
func _detach_editor_logger() -> void:
if _editor_logger != null and OS.has_method("remove_logger"):
OS.call("remove_logger", _editor_logger)
_editor_logger = null
## Register the game-side autoload on plugin enable. Runs the helper inside
## the game process so the editor-side debugger plugin can request
## framebuffer captures over EngineDebugger messages. Removed on
## _disable_plugin so disabling the plugin leaves project.godot clean.
func _enable_plugin() -> void:
if _mcp_disabled_for_headless_launch():
return
_ensure_game_helper_autoload()
static func _mcp_disabled_for_headless_launch() -> bool:
return _mcp_disabled_for_headless(
OS.get_cmdline_args(),
DisplayServer.get_name(),
OS.get_environment("GODOT_AI_ALLOW_HEADLESS")
)
static func _mcp_disabled_for_headless(args: PackedStringArray, display_name: String, allow_value: String) -> bool:
if McpSettings.truthy(allow_value):
return false
return _args_request_headless(args) or display_name.to_lower() == "headless"
static func _args_request_headless(args: PackedStringArray) -> bool:
for i in range(args.size()):
var arg := args[i]
if arg == "--headless":
return true
if arg == "--display-driver" and i + 1 < args.size() and args[i + 1] == "headless":
return true
if arg.begins_with("--display-driver=") and arg.get_slice("=", 1) == "headless":
return true
return false
func _disable_plugin() -> void:
var key := "autoload/" + GAME_HELPER_AUTOLOAD_NAME
if not ProjectSettings.has_setting(key):
return
ProjectSettings.clear(key)
ProjectSettings.save()
func _ensure_game_helper_autoload() -> void:
## Write the autoload directly to ProjectSettings and save immediately.
## EditorPlugin.add_autoload_singleton only mutates in-memory settings —
## the on-disk project.godot is only persisted when the editor saves
## (e.g. on quit). CI spawns the game subprocess before any save fires,
## so the child process never sees the autoload and the capture times
## out. Mirror AutoloadHandler's pattern: set_setting + save().
var key := "autoload/" + GAME_HELPER_AUTOLOAD_NAME
var value := "*" + GAME_HELPER_AUTOLOAD_PATH # "*" prefix = singleton
if ProjectSettings.get_setting(key, "") == value:
return ## already registered with the right target
ProjectSettings.set_setting(key, value)
ProjectSettings.set_initial_value(key, "")
ProjectSettings.set_as_basic(key, true)
var err := ProjectSettings.save()
if err != OK:
push_warning("MCP: failed to save project.godot after registering %s autoload (error %d)"
% [GAME_HELPER_AUTOLOAD_NAME, err])
func _startup_trace_begin() -> void:
_startup_trace_enabled = ClientConfigurator.startup_trace_enabled()
if not _startup_trace_enabled:
return
_startup_trace_start_ms = Time.get_ticks_msec()
_startup_trace_last_ms = _startup_trace_start_ms
_startup_trace_netsh_start_count = WindowsPortReservation.netsh_query_count()
_startup_trace_counters.clear()
for counter in STARTUP_TRACE_COUNTER_NAMES:
_startup_trace_counters[counter] = 0
print(
"MCP startup trace | begin platform=%s http_port=%d ws_port=%d"
% [
OS.get_name(),
ClientConfigurator.http_port(),
ClientConfigurator.ws_port(),
]
)
func _startup_trace_count(counter: String, amount: int = 1) -> void:
if not _startup_trace_enabled:
return
_startup_trace_counters[counter] = int(_startup_trace_counters.get(counter, 0)) + amount
func _startup_trace_phase(name: String) -> void:
if not _startup_trace_enabled:
return
var now := Time.get_ticks_msec()
print(
"MCP startup trace | phase=%s delta_ms=%d total_ms=%d"
% [name, now - _startup_trace_last_ms, now - _startup_trace_start_ms]
)
_startup_trace_last_ms = now
func _startup_trace_finish(path: String) -> void:
if not _startup_trace_enabled:
return
var now := Time.get_ticks_msec()
_startup_trace_counters["netsh"] = (
WindowsPortReservation.netsh_query_count() - _startup_trace_netsh_start_count
)
print(
"MCP startup trace | done path=%s total_ms=%d counters=%s"
% [path, now - _startup_trace_start_ms, str(_startup_trace_counters)]
)
func _start_server() -> void:
_lifecycle.start_server()
## Test-fixture shim — characterization tests in test_plugin_lifecycle
## reach for this instance method directly. Delegates to the manager's
## state-owning copy.
func _set_incompatible_server(live: Dictionary, expected_version: String, port: int) -> void:
_lifecycle._set_incompatible_server(live, expected_version, port)
## Static shim — kept on the plugin class because the characterization
## tests assert against `GodotAiPlugin._incompatible_server_message`.
## Implementation moved to ServerLifecycleManager.
static func _incompatible_server_message(
live: Dictionary,
expected_version: String,
port: int,
expected_ws_port: int
) -> String:
return ServerLifecycleManager._incompatible_server_message(
live, expected_version, port, expected_ws_port
)
static func _server_version_compatibility(
actual_version: String, expected_version: String
) -> Dictionary:
return ServerLifecycleManager._server_version_compatibility(
actual_version, expected_version
)
static func _server_status_compatibility(
actual_version: String,
expected_version: String,
actual_ws_port: int,
expected_ws_port: int,
) -> Dictionary:
return ServerLifecycleManager._server_status_compatibility(
actual_version, expected_version, actual_ws_port, expected_ws_port
)
static func _managed_record_has_version_drift(record_version: String, current_version: String) -> bool:
return ServerLifecycleManager._managed_record_has_version_drift(record_version, current_version)
static func _probe_live_server_status(port: int, timeout_ms: int = SERVER_STATUS_PROBE_TIMEOUT_MS) -> Dictionary:
var result := {
"reachable": false,
"version": "",
"name": "",
"ws_port": 0,
"status_code": 0,
"error": "",
}
var client := HTTPClient.new()
var err := client.connect_to_host("127.0.0.1", port)
if err != OK:
result["error"] = "connect_%d" % err
return result
var deadline := Time.get_ticks_msec() + timeout_ms
while client.get_status() == HTTPClient.STATUS_RESOLVING or client.get_status() == HTTPClient.STATUS_CONNECTING:
client.poll()
if Time.get_ticks_msec() >= deadline:
result["error"] = "connect_timeout"
return result
OS.delay_msec(10)
if client.get_status() != HTTPClient.STATUS_CONNECTED:
result["error"] = "connect_status_%d" % client.get_status()
return result
err = client.request(HTTPClient.METHOD_GET, SERVER_STATUS_PATH, ["Accept: application/json"])
if err != OK:
result["error"] = "request_%d" % err
return result
var body := PackedByteArray()
while true:
var status := client.get_status()
if status == HTTPClient.STATUS_REQUESTING:
client.poll()
elif status == HTTPClient.STATUS_BODY:
client.poll()
var chunk := client.read_response_body_chunk()
if chunk.size() > 0:
body.append_array(chunk)
elif status == HTTPClient.STATUS_CONNECTED:
break
else:
result["error"] = "response_status_%d" % status
return result
if Time.get_ticks_msec() >= deadline:
result["error"] = "response_timeout"
return result
OS.delay_msec(10)
var response_code := client.get_response_code()
result["status_code"] = response_code
if response_code != 200:
result["error"] = "http_%d" % response_code
return result
var parsed = JSON.parse_string(body.get_string_from_utf8())
if not (parsed is Dictionary):
result["error"] = "invalid_json"
return result
result["reachable"] = true
result["name"] = str(parsed.get("name", ""))
result["version"] = _extract_server_version(parsed)
result["ws_port"] = int(parsed.get("ws_port", 0))
## `package_path` was added in v2.4.4 (#416) so the dock's
## "Incompatible server" banner can name the source of a version
## skew. Older servers omit it; treat the missing field as "".
result["package_path"] = str(parsed.get("package_path", ""))
return result
func _probe_live_server_status_for_port(port: int) -> Dictionary:
_startup_trace_count("http_status_probe")
return _probe_live_server_status(port)
static func _extract_server_version(payload: Dictionary) -> String:
var version := str(payload.get("server_version", ""))
if version.is_empty():
version = str(payload.get("version", ""))
return version
static func _live_status_identifies_godot_ai(live: Dictionary) -> bool:
return ServerLifecycleManager._live_status_identifies_godot_ai(live)
func _verified_status_version(live: Dictionary) -> String:
if not ServerLifecycleManager._live_status_identifies_godot_ai(live):
return ""
return str(live.get("version", ""))
func _verified_status_ws_port(live: Dictionary) -> int:
if not ServerLifecycleManager._live_status_identifies_godot_ai(live):
return 0
return int(live.get("ws_port", 0))
func _refresh_dock_client_statuses() -> bool:
if _dock == null:
return false
if not _dock.has_method("_refresh_all_client_statuses"):
return false
_dock.call("_refresh_all_client_statuses")
return true
## Test-fixture shim — characterization tests in test_plugin_lifecycle
## still drive the first-writer-wins terminal-diagnosis behaviour through
## this method. Delegates to the manager's `set_terminal_diagnosis`
## (which preserves the same first-writer-wins contract).
func _set_spawn_state(state: int) -> void:
_lifecycle.set_terminal_diagnosis(state)
## Arm the one-shot connection watcher. Called from `_start_server`'s
## FOREIGN_PORT branch: we flagged the diagnostic preemptively assuming
## the port holder doesn't speak MCP, but if it turns out to be another
## editor's server our WebSocket will open and we need to retract the
## diagnostic.
##
## We intentionally poll `_connection.is_connected` from `_process`
## instead of wiring a new signal on McpConnection. A signal added in the
## same release as a new consumer would be another shape-coupled update:
## old two-phase runners can parse the consumer while the McpConnection
## Script object still reflects v(N). Polling only reads `is_connected`
## (present on every shipped McpConnection), so old-runner upgrade windows
## do not depend on a same-release signal addition.
##
## The watch self-disarms after SPAWN_GRACE_MS so per-frame cost drops
## back to zero if it is ever armed by a legacy adoption path.
func _watch_for_adoption_confirmation() -> void:
_lifecycle.arm_adoption_watch()
_update_process_enabled()
func _arm_server_version_check() -> void:
## `arm_version_check` resolves an empty expected via the plugin
## version, so we can pass the raw field value through.
_lifecycle.arm_version_check(_connection, str(_lifecycle._server_expected_version))
_update_process_enabled()
func _update_process_enabled() -> void:
set_process(
_lifecycle.get_adoption_watch_deadline_ms() > 0
or _lifecycle.is_awaiting_server_version()
)
func _process(_delta: float) -> void:
var now := Time.get_ticks_msec()
var version_check = _lifecycle.get_version_check()
if version_check != null:
version_check.tick(now)
_lifecycle.tick_adoption_watch(now)
_update_process_enabled()
## A WebSocket opening only proves the occupant speaks enough of the editor
## protocol to accept a session. Compatibility is decided by the server
## version in `handshake_ack`, so this only arms that check.
func _on_connection_established() -> void:
if _lifecycle.get_state() == ServerStateScript.FOREIGN_PORT:
_arm_server_version_check()
## Test-fixture shim — characterization tests poke the verified path
## directly. Delegates to the version-check seam; the manager resolves
## an empty expected version via `_resolve_expected_version`.
func _on_server_version_verified(version: String) -> void:
_lifecycle.handle_server_version_verified(
str(_lifecycle._server_expected_version), version
)
_update_process_enabled()
## Test-fixture shim — same shape as `_on_server_version_verified`.
func _on_server_version_unverified() -> void:
_lifecycle.handle_server_version_unverified(
str(_lifecycle._server_expected_version)
)
_update_process_enabled()
## Start a 1s-tick timer that watches the spawned server for up to
## SERVER_WATCH_MS. If the process dies inside the window we drain the
## captured pipes and mark the server as crashed so the dock can surface
## what went wrong. After the window expires we close the pipes so they
## don't pin file descriptors or fill their kernel buffers. See #146.
func _start_server_watch() -> void:
_stop_server_watch()
_server_watch_timer = Timer.new()
_server_watch_timer.wait_time = 1.0
_server_watch_timer.one_shot = false
_server_watch_timer.timeout.connect(_check_server_health)
add_child(_server_watch_timer)
_server_watch_timer.start()
func _stop_server_watch() -> void:
if _server_watch_timer != null:
_server_watch_timer.stop()
_server_watch_timer.queue_free()
_server_watch_timer = null
func _check_server_health() -> void:
_lifecycle.check_server_health()
## True when the first spawn looks like a stale-uvx-index failure and we
## haven't already retried. Fail signal: launcher process already declared
## dead by the caller, pid-file was never written (Python never got to
## argparse), and we're on the uvx tier (the only tier where `--refresh`
## means anything). Bug #172 — after a fresh PyPI publish, uvx's local
## index metadata keeps saying the new version doesn't exist for ~10 min,
## which cascaded into an infinite reconnect loop pre-#171. Retry-at-spawn
## catches every entry path (Update, Reload Plugin, Reconnect, editor
## restart, crash recovery) — unlike the older Update-only precheck.
func _should_retry_with_refresh() -> bool:
return _retry_with_refresh_allowed(
_lifecycle._refresh_retried,
ClientConfigurator.get_server_launch_mode(),
_read_pid_file(),
)
## Pure decision helper — environment-state readers stay in the instance
## method above, the logic lives here so tests can drive the three inputs
## directly without spoofing static caches or pid-files on disk.
static func _retry_with_refresh_allowed(already_retried: bool, launch_mode: String, pid_from_file: int) -> bool:
return (
not already_retried
and launch_mode == "uvx"
and pid_from_file == 0
)
func _respawn_with_refresh() -> void:
_lifecycle.respawn_with_refresh()
## Snapshot of the server-spawn outcome for the dock.
##
## `state` is one of the `McpServerState.*` int constants; the dock owns
## the UI copy per state via its own `_crash_body_for_state`. `exit_ms`
## is only meaningful for `CRASHED`.
func get_server_status() -> Dictionary:
return _lifecycle.get_status_dict()
func get_resolved_ws_port() -> int:
return _resolved_ws_port
func _set_resolved_ws_port(port: int) -> void:
_resolved_ws_port = port
if _connection != null:
_connection.ws_port = port
func _resolve_ws_port() -> int:
return PortResolver.resolve_ws_port(
ClientConfigurator.ws_port(),
ClientConfigurator.MAX_PORT,
_log_buffer,
)
## Test-compat shim — characterization tests call this static directly.
static func _resolved_ws_port_for_existing_server(
record_ws_port: int,
record_version: String,
current_version: String,
fresh_resolved: int
) -> int:
return PortResolver.resolved_ws_port_for_existing_server(
record_ws_port,
record_version,
current_version,
fresh_resolved,
)
static func _resolve_ws_port_from_output(
configured_port: int,
netsh_output: String,
span: int = 2048
) -> int:
return PortResolver.resolve_ws_port_from_output(
configured_port,
netsh_output,
ClientConfigurator.MAX_PORT,
span,
)
## Plugin-level shim around the resolver — keeps the startup-trace
## counter increment and the `_ProofPlugin` override hook on the plugin.
func _is_port_in_use(port: int) -> bool:
if PortResolver.can_bind_local_port(port):
## POSIX can still have an IPv6 wildcard listener on this port
## even when an IPv4 loopback bind succeeds. Confirm through
## lsof so startup and kill-path discovery agree.
if OS.get_name() != "Windows":
_startup_trace_count("lsof")
return PortResolver.is_port_in_use_via_scrape(port)
return false
if OS.get_name() == "Windows":
_startup_trace_count("netstat")
else:
_startup_trace_count("lsof")
return PortResolver.is_port_in_use_via_scrape(port)
## Pass `_startup_trace_count` so the resolver bumps the right counter
## per scraper that actually ran (Windows can fall through netstat →
## PowerShell — counting both unconditionally would over-report).
func _find_pid_on_port(port: int) -> int:
return PortResolver.find_pid_on_port(port, _startup_trace_count)
func _find_all_pids_on_port(port: int) -> Array[int]:
return PortResolver.find_all_pids_on_port(port, _startup_trace_count)
static func _execute_windows_powershell(script: String, output: Array) -> int:
return PortResolver.execute_windows_powershell(script, output)
static func _windows_listener_pids_from_execute_result(exit_code: int, output: Array) -> Array[int]:
return PortResolver.windows_listener_pids_from_execute_result(exit_code, output)
static func _windows_listener_execute_result_in_use(exit_code: int, output: Array) -> bool:
return PortResolver.windows_listener_execute_result_in_use(exit_code, output)
static func _parse_lsof_pids(raw: String) -> Array[int]:
return PortResolver.parse_lsof_pids(raw)
static func _parse_pid_lines(raw: String) -> Array[int]:
return PortResolver.parse_pid_lines(raw)
## Find the managed server PID deterministically: prefer the pid-file
## the Python server writes on startup (see runtime_info.py), fall back
## to scraping `netstat -ano` / `lsof` only when the file is missing or
## stale. This is the replacement for raw port-scraping: on Windows the
## uvx launcher PID doesn't cover the Python child, and netstat parsing
## is fragile.
##
## Returns 0 when no server can be identified.
func _find_managed_pid(port: int) -> int:
var pid := _read_pid_file()
if pid > 0 and _pid_alive(pid):
return pid
return _find_pid_on_port(port)
## `live` is the result of a prior `_probe_live_server_status_for_port`
## call that the caller already has on hand. When non-empty it short-
## circuits the internal probe at the bottom of this helper, so a single
## `_start_server` invocation that probes once at the top can thread the
## same snapshot through compatibility check + recovery without paying
## for a second ~500 ms localhost HTTPClient poll loop. Default `{}`
## preserves the historical behavior for callers outside the spawn flow
## (`can_recover_incompatible_server`, the dock's UI buttons), where a
## fresh probe is the right thing.
func _evaluate_strong_port_occupant_proof(port: int, live: Dictionary = {}) -> Dictionary:
var result := {"proof": "", "pids": []}
var listener_pids := _find_all_pids_on_port(port)
if listener_pids.is_empty():
return result
var record := _read_managed_server_record()
var record_pid := int(record.get("pid", 0))
var record_version := str(record.get("version", ""))
if record_pid > 1 and record_pid != OS.get_process_id():
## Brand-verify the recorded PID before trusting it as a kill target.
## A recorded PID can outlive the server it named and be recycled by
## the kernel for an unrelated process that happens to bind the same
## port — without the cmdline brand gate (the same one the
## `pidfile_listener` branch enforces) that process could be killed.
## See #525.
if (
listener_pids.has(record_pid)
and _pid_alive_for_proof(record_pid)
and _pid_cmdline_is_godot_ai_for_proof(record_pid)
):
return {"proof": "managed_record", "pids": [record_pid]}
var legacy_targets := _legacy_pidfile_kill_targets(port, listener_pids)
if not legacy_targets.is_empty():
return {"proof": "pidfile_listener", "pids": legacy_targets}
var current_live: Dictionary = live if not live.is_empty() else _probe_live_server_status_for_port(port)
if (
_live_status_identifies_godot_ai(current_live)
and not record_version.is_empty()
and str(current_live.get("version", "")) == record_version
):
return {"proof": "status_matches_record", "pids": listener_pids}
return result
## See `_evaluate_strong_port_occupant_proof` for the `live` contract.
## Threads `live` through the strong-proof delegate so neither helper
## probes when the caller already knows the port-owner status.
func _evaluate_recovery_port_occupant_proof(port: int, live: Dictionary = {}) -> Dictionary:
var proof := _evaluate_strong_port_occupant_proof(port, live)
if not str(proof.get("proof", "")).is_empty():
return proof
var current_live: Dictionary = live if not live.is_empty() else _probe_live_server_status_for_port(port)
if _live_status_identifies_godot_ai(current_live):
return {"proof": "status_name", "pids": _find_all_pids_on_port(port)}
return {"proof": "", "pids": []}
func _recover_strong_port_occupant(port: int, wait_s: float, pre_kill_live: Dictionary = {}) -> bool:
return _lifecycle.recover_strong_port_occupant(port, wait_s, pre_kill_live)
func _legacy_pidfile_kill_targets(_port: int, listener_pids: Array[int]) -> Array[int]:
var targets: Array[int] = []
var pidfile_pid := _read_pid_file_for_proof()
if pidfile_pid <= 1 or pidfile_pid == OS.get_process_id():
return targets
## An alive, branded pid-file PID is sufficient ownership proof. Under
## `uvicorn --reload` the reloader writes the pid-file but a child worker
## binds the port, so `listener_pids` never contains the reloader PID.
## Requiring `listener_pids.has(pidfile_pid)` here used to silently skip
## the kill path for the entire reload-shaped server family. The branded
## listener loop below still does the per-PID brand check so we never
## kill an unrelated process that happens to share the port.
if not _pid_alive_for_proof(pidfile_pid) or not _pid_cmdline_is_godot_ai_for_proof(pidfile_pid):
return targets
for pid in listener_pids:
if pid <= 1 or pid == OS.get_process_id():
continue
## Reuse the brand result already proven above when this listener is
## the same PID as the pidfile — saves a parent-chain walk and a
## shell-out (PowerShell on Windows, /proc on Linux, ps on macOS) per
## startup proof evaluation.
if pid == pidfile_pid or _pid_cmdline_is_godot_ai_for_proof(pid):
targets.append(pid)
## Also kill the reloader/launcher itself when it isn't already a listener.
## Without this, `--reload` workers would be killed but their parent would
## immediately respawn a replacement and the port would never free.
if not targets.has(pidfile_pid):
targets.append(pidfile_pid)
return targets
func _read_pid_file_for_proof() -> int:
return _read_pid_file()
func _pid_alive_for_proof(pid: int) -> bool:
return _pid_alive(pid)
func _pid_cmdline_is_godot_ai_for_proof(pid: int) -> bool:
return _pid_cmdline_is_godot_ai(pid)
static func _parse_windows_netstat_pid(stdout: String, port: int) -> int:
return PortResolver.parse_windows_netstat_pid(stdout, port)
static func _parse_windows_netstat_pids(stdout: String, port: int) -> Array[int]:
return PortResolver.parse_windows_netstat_pids(stdout, port)
static func _parse_windows_netstat_listening(stdout: String, port: int) -> bool:
return PortResolver.parse_windows_netstat_listening(stdout, port)
static func _split_on_whitespace(s: String) -> PackedStringArray:
return PortResolver.split_on_whitespace(s)
static func _read_pid_file() -> int:
return PortResolver.read_pid_file()
static func _clear_pid_file() -> void:
PortResolver.clear_pid_file()
func _stop_server() -> void:
_lifecycle.stop_server()
## Clear the managed-server record and pid-file only if `port` is free.
## Returns true when state was cleared. Extracted from `_stop_server` so
## the "preserve on failed kill" contract is independently testable.
func _finalize_stop_if_port_free(port: int) -> bool:
if _is_port_in_use(port):
return false
_clear_managed_server_record()
_clear_pid_file()
return true
## Shared tail of the server CLI: transport, ports, and `--pid-file`. Both
## the initial spawn in `_start_server` and the `--refresh` retry in
## `_respawn_with_refresh` go through here so a new flag added in one place
## can't silently drop out of the other.
static func _build_server_flags(port: int, ws_port: int) -> Array[String]:
var flags: Array[String] = []
flags.assign([
"--transport", "streamable-http",
"--port", str(port),
"--ws-port", str(ws_port),
"--pid-file", ProjectSettings.globalize_path(SERVER_PID_FILE),
])
## Append `--exclude-domains` only when the user has actually picked at
## least one domain to drop. Skipping the empty case keeps spawns
## compatible with older (pre-1.4.2) servers that don't know the flag —
## relevant during staggered plugin/server upgrades in user-mode installs.
var excluded := ClientConfigurator.excluded_domains()
if not excluded.is_empty():
flags.append("--exclude-domains")
flags.append(excluded)
return flags
## Returns true only when we can prove `pid`'s command line carries the
## `godot-ai` brand AND a server flag (`--pid-file` / `--transport`). Used by
## automatic kill paths (`_legacy_pidfile_kill_targets`) so a stale pidfile
## whose PID has been recycled by an unrelated listener can't hand us a
## kill target. If the OS lookup fails or returns an empty cmdline we
## conservatively return false — better to surface incompatible-server and
## let the user click Restart than to kill the wrong process.
func _pid_cmdline_is_godot_ai(pid: int) -> bool:
## Walks up the parent chain so a uvicorn `--reload` worker whose
## cmdline is just `multiprocessing.spawn` still matches when its
## parent reloader carries the godot_ai brand. Bound the walk so a
## hypothetical loop or runaway PPID can't stall the editor.
var current := pid
for _i in range(5):
if current <= 1:
return false
var cmd := ""
if OS.get_name() == "Windows":
cmd = _windows_pid_commandline(current)
else:
cmd = _posix_pid_commandline(current)
if _commandline_is_godot_ai_server(cmd):
return true
current = _pid_parent(current)
return false
func _pid_parent(pid: int) -> int:
if pid <= 1:
return 0
if OS.get_name() == "Windows":
var output: Array = []
var script := (
"Get-CimInstance Win32_Process -Filter 'ProcessId = %d' | "
+ "Select-Object -ExpandProperty ParentProcessId"
) % pid
_startup_trace_count("powershell")
if _execute_windows_powershell(script, output) != 0 or output.is_empty():
return 0
return int(str(output[0]).strip_edges())
var output_posix: Array = []
if OS.execute("ps", ["-o", "ppid=", "-p", str(pid)], output_posix, true) != 0 or output_posix.is_empty():
return 0
return int(str(output_posix[0]).strip_edges())
static func _commandline_is_godot_ai_server(cmd: String) -> bool:
if cmd.is_empty():
return false
var lower := cmd.to_lower()
## The server is invoked with `--pid-file <user>/godot_ai_server.pid`,
## so the path itself contains "godot_ai". A naive substring brand
## search would falsely match an unrelated process whose cmdline
## happens to reference a similarly-named pidfile path. Strip the
## value (but leave the bare flag for the has_flag check) before
## brand matching.
var brand_search := _strip_pidfile_value(lower)
var has_brand := brand_search.find("godot-ai") >= 0 or brand_search.find("godot_ai") >= 0
var has_flag := lower.find("--pid-file") >= 0 or lower.find("--transport") >= 0
return has_brand and has_flag
static func _strip_pidfile_value(cmd: String) -> String:
var rx := RegEx.new()
## Match `--pid-file=<token>` and `--pid-file <token>`; keep the bare
## flag so the flag-presence check still succeeds for a real server.
if rx.compile("--pid-file(?:=|\\s+)\\S+") != OK:
return cmd
return rx.sub(cmd, "--pid-file ", true)
func _windows_pid_commandline(pid: int) -> String:
var output: Array = []
var script := (
"Get-CimInstance Win32_Process -Filter 'ProcessId = %d' | "
+ "Select-Object -ExpandProperty CommandLine"
) % pid
_startup_trace_count("powershell")
var exit_code := _execute_windows_powershell(script, output)
if exit_code != 0 or output.is_empty():
return ""
return str(output[0])
## POSIX command-line lookup. Linux exposes `/proc/<pid>/cmdline` as
## NUL-separated argv — read it directly so we avoid a `ps` fork on Linux
## and get the full argv rather than the truncated/quoted form some `ps`
## builds emit. Falls back to `ps -ww -p <pid> -o args=` on macOS / *BSD,
## which lack a Linux-style `/proc/<pid>/cmdline`. Returns "" on failure
## so callers conservatively reject the PID rather than killing it blind.
func _posix_pid_commandline(pid: int) -> String:
var proc_path := "/proc/%d/cmdline" % pid
if FileAccess.file_exists(proc_path):
var f := FileAccess.open(proc_path, FileAccess.READ)
if f != null:
## procfs pseudo-files report length 0 (the kernel generates
## content on read). `get_length()` therefore returns 0 and
## `get_buffer(0)` reads nothing. Read in chunks until EOF
## instead. Cap at ARG_MAX-class bound so a hypothetically
## misbehaving file can never stall the editor frame.
var bytes := PackedByteArray()
var max_bytes := 1 << 20 # 1 MiB
while bytes.size() < max_bytes:
var chunk := f.get_buffer(4096)
if chunk.is_empty():
break
bytes.append_array(chunk)
if f.eof_reached():
break
f.close()
## /proc cmdline is NUL-separated argv; convert NULs to spaces
## so the substring fingerprint matches the same way it does on
## the Windows path. Empty (kernel threads, exited processes)
## bubbles up as "" via the strip below.
for i in range(bytes.size()):
if bytes[i] == 0:
bytes[i] = 0x20
return bytes.get_string_from_utf8().strip_edges()
## `-ww` removes ps's column-width truncation so trailing flags like
## --pid-file / --transport aren't dropped from the args= field.
## Both procps (Linux) and BSD ps (macOS / *BSD) accept the
## double-w form.
var output: Array = []
var exit_code := OS.execute("ps", ["-ww", "-p", str(pid), "-o", "args="], output, true)
if exit_code != 0 or output.is_empty():
return ""
return str(output[0]).strip_edges()
## True if the given PID corresponds to a live (non-zombie) process.
## POSIX uses `ps -o stat=` (see inline comment for the zombie rationale);
## Windows uses `tasklist`. Called by `_start_server` to distinguish a live
## managed server that outlived its editor from a stale EditorSettings
## record, and by `_check_server_health` to detect a fast-failing launcher.
static func _pid_alive(pid: int) -> bool:
return PortResolver.pid_alive(pid)
## Calls `_is_port_in_use` (not `PortResolver.wait_for_port_free`) so
## `_ProofPlugin` overrides keep driving the loop.
func _wait_for_port_free(port: int, timeout_s: float) -> void:
var deadline := Time.get_ticks_msec() + int(timeout_s * 1000.0)
while _is_port_in_use(port):
if Time.get_ticks_msec() >= deadline:
push_warning("MCP | port %d still in use after %.1fs — proceeding anyway" % [port, timeout_s])
return
OS.delay_msec(100)
func _read_managed_server_record() -> Dictionary:
var es := EditorInterface.get_editor_settings()
if es == null:
return {"pid": 0, "version": "", "ws_port": 0}
var pid: int = 0
if es.has_setting(MANAGED_SERVER_PID_SETTING):
pid = int(es.get_setting(MANAGED_SERVER_PID_SETTING))
var version: String = ""
if es.has_setting(MANAGED_SERVER_VERSION_SETTING):
version = str(es.get_setting(MANAGED_SERVER_VERSION_SETTING))
var ws_port: int = 0
if es.has_setting(MANAGED_SERVER_WS_PORT_SETTING):
ws_port = int(es.get_setting(MANAGED_SERVER_WS_PORT_SETTING))
return {"pid": pid, "version": version, "ws_port": ws_port}
func _write_managed_server_record(pid: int, version: String) -> void:
var es := EditorInterface.get_editor_settings()
if es == null:
return
es.set_setting(MANAGED_SERVER_PID_SETTING, pid)
es.set_setting(MANAGED_SERVER_VERSION_SETTING, version)
es.set_setting(MANAGED_SERVER_WS_PORT_SETTING, _resolved_ws_port)
func _clear_managed_server_record() -> void:
var es := EditorInterface.get_editor_settings()
if es == null:
return
if es.has_setting(MANAGED_SERVER_PID_SETTING):
es.set_setting(MANAGED_SERVER_PID_SETTING, 0)
if es.has_setting(MANAGED_SERVER_VERSION_SETTING):
es.set_setting(MANAGED_SERVER_VERSION_SETTING, "")
if es.has_setting(MANAGED_SERVER_WS_PORT_SETTING):
es.set_setting(MANAGED_SERVER_WS_PORT_SETTING, 0)
func prepare_for_update_reload() -> void:
_lifecycle.prepare_for_update_reload()
func _adopt_compatible_server(record_version: String, current_version: String, owner: int) -> String:
return _lifecycle.adopt_compatible_server(record_version, current_version, owner)
static func _compatible_adoption_log_message(
owner_label: String,
owned_pid: int,
observed_owner_pid: int,
live_version: String,
live_ws_port: int,
current_version: String
) -> String:
if owner_label == "managed":
return "MCP | adopted managed server (PID %d, live v%s, WS %d, plugin v%s)" % [
owned_pid,
live_version,
live_ws_port,
current_version
]
return "MCP | adopted external server owner_pid=%d (live v%s, WS %d, plugin v%s)" % [
observed_owner_pid,
live_version,
live_ws_port,
current_version
]
## Hand the self-update over to a tiny runner that is not owned by this
## EditorPlugin. The runner keeps the editor process alive, but disables this
## plugin before extracting/scanning the new scripts so every plugin-owned
## instance tears down on pre-update bytecode and pre-update field storage.
func install_downloaded_update(zip_path: String, temp_dir: String, source_dock: Control) -> void:
prepare_for_update_reload()
var detached_dock = null
if _dock != null and is_instance_valid(_dock):
detached_dock = _dock
remove_control_from_docks(_dock)
_dock = null
elif source_dock != null and is_instance_valid(source_dock):
detached_dock = source_dock
remove_control_from_docks(source_dock)
var runner = UPDATE_RELOAD_RUNNER_SCRIPT.new()
var parent: Node = EditorInterface.get_base_control()
if parent == null:
parent = get_tree().root
parent.add_child(runner)
runner.start(zip_path, temp_dir, detached_dock)
func can_recover_incompatible_server() -> bool:
return _lifecycle.can_recover_incompatible_server()
func _resume_connection_after_recovery() -> void:
if _connection == null:
return
var state: int = _lifecycle.get_state()
if (
_lifecycle.is_connection_blocked()
or (
state != ServerStateScript.SPAWNING
and state != ServerStateScript.READY
)
):
return
_connection.connect_blocked = false
_connection.connect_block_reason = ""
_connection.server_version = ""
_connection.set_process(true)
_arm_server_version_check()
func recover_incompatible_server() -> bool:
if not _lifecycle.recover_incompatible_server():
return false
_resume_connection_after_recovery()
return true
## Kill whichever process is holding `http_port()` right now — by resolving
## the port-owning PID via pid-file / netstat / lsof, independent of whether
## we ever set the manager's `_server_pid` — then clear ownership state
## and respawn via the lifecycle manager. The dock's version-mismatch
## banner wires here when the plugin adopted a foreign server whose
## `server_version` drifts from the current plugin version.
func force_restart_server() -> void:
_lifecycle.force_restart_server()
## Single entry point for the dock's primary "Restart Dev Server" button.
## The user clicking Restart is explicit consent to take over the HTTP port,
## so this is aggressive: any PID holding the port gets killed (managed,
## branded-dev, or orphan multiprocessing.spawn workers whose parent died
## so brand detection misses them). After the port frees we spawn a fresh
## --reload dev server. Returns true if a kill happened, false if the port
## was already free and we just spawned.
func force_restart_or_start_dev_server() -> bool:
var port := ClientConfigurator.http_port()
var killed := false
if has_managed_server():
_lifecycle.reset_for_force_restart()
if _is_port_in_use(port):
_kill_processes_and_windows_spawn_children(_find_all_pids_on_port(port))
killed = true
if killed:
## OS.kill returns synchronously but uvicorn's listener can take
## longer to release the port. Without this wait, start_dev_server's
## fixed 500ms timer races the old shutdown and the new --reload
## spawn fails to bind.
_wait_for_port_free(port, 5.0)
start_dev_server()
return killed
func start_dev_server() -> void:
## Start a dev server with --reload that survives plugin reloads.
## Kills any managed server first, waits for the port to free, then spawns.
##
## PYTHONPATH handling: when `res://` sits inside a checkout that owns a
## `src/godot_ai/` (root repo or a git worktree), prepend that `src/` to
## PYTHONPATH so `import godot_ai` and uvicorn's `reload_dirs` both pick
## up *this* tree's source rather than the root repo's editable install.
## On the root repo the path matches the installed package, so this is a
## no-op; in a worktree it's what makes `--reload` actually watch the
## worktree's Python. See #84.
_stop_server()
get_tree().create_timer(0.5).timeout.connect(func():
var server_cmd := ClientConfigurator.get_server_command()
if server_cmd.is_empty():
push_warning("MCP | could not find server command for dev server")
return
var cmd: String = server_cmd[0]
_set_resolved_ws_port(_resolve_ws_port())
var inner_args: Array[String] = []
inner_args.assign(server_cmd.slice(1))
inner_args.append_array([
"--transport", "streamable-http",
"--port", str(ClientConfigurator.http_port()),
"--ws-port", str(_resolved_ws_port),
"--reload",
])
var worktree_src := ClientConfigurator.find_worktree_src_dir(ProjectSettings.globalize_path("res://"))
var prev_pythonpath := OS.get_environment("PYTHONPATH")
if not worktree_src.is_empty():
var sep := ";" if OS.get_name() == "Windows" else ":"
var new_pp := worktree_src if prev_pythonpath.is_empty() else worktree_src + sep + prev_pythonpath
OS.set_environment("PYTHONPATH", new_pp)
var injected_telemetry: bool = _lifecycle._inject_telemetry_env()
var pid := OS.create_process(cmd, inner_args)
if injected_telemetry:
OS.unset_environment("GODOT_AI_DISABLE_TELEMETRY")
## Restore PYTHONPATH immediately — the spawned child has already
## copied the env, so the editor's own process state returns to
## baseline. Leaving it set would leak to any later OS.create_process
## from unrelated paths.
if not worktree_src.is_empty():
if prev_pythonpath.is_empty():
OS.unset_environment("PYTHONPATH")
else:
OS.set_environment("PYTHONPATH", prev_pythonpath)
if pid > 0:
## Match `server_lifecycle.gd::start_server`'s log wording —
## "prefix" since we prepended to any pre-existing PYTHONPATH,
## not replaced it. See #429 review.
var suffix := " (PYTHONPATH prefix=%s)" % worktree_src if not worktree_src.is_empty() else ""
print("MCP | started dev server with --reload (PID %d): %s %s%s" % [pid, cmd, " ".join(inner_args), suffix])
else:
push_warning("MCP | failed to start dev server")
)
func stop_dev_server() -> void:
## Stop any server running on the HTTP port (by port, not PID).
## Used for dev servers whose PID we don't track across reloads.
if _lifecycle.get_server_pid() > 0:
# We have a managed server — use normal stop
_stop_server()
return
var port := ClientConfigurator.http_port()
var candidates: Array[int] = []
for pid in _find_all_pids_on_port(port):
var candidate := int(pid)
if _pid_cmdline_is_godot_ai(candidate):
candidates.append(candidate)
var killed := _kill_processes_and_windows_spawn_children(candidates)
if not killed.is_empty():
print("MCP | stopped dev server on port %d" % port)
func _kill_processes_and_windows_spawn_children(pids: Array[int]) -> Array[int]:
var unique: Array[int] = []
for pid in pids:
if pid > 0 and not unique.has(pid):
unique.append(pid)
if OS.get_name() == "Windows":
for child_pid in _find_windows_spawn_children(unique):
if not unique.has(child_pid):
unique.append(child_pid)
var killed: Array[int] = []
for pid in unique:
if OS.get_name() == "Windows":
var output: Array = []
var exit_code := OS.execute("taskkill", ["/PID", str(pid), "/T", "/F"], output, true)
if exit_code == 0 or not _pid_alive(pid):
killed.append(pid)
else:
OS.kill(pid)
killed.append(pid)
return killed
func _find_windows_spawn_children(parent_pids: Array[int]) -> Array[int]:
if parent_pids.is_empty():
var empty: Array[int] = []
return empty
var found: Array[int] = []
for parent_pid in parent_pids:
var output: Array = []
var script := (
"Get-CimInstance Win32_Process | "
+ "Where-Object { $_.CommandLine -like '*spawn_main(parent_pid=%d*' } | "
+ "ForEach-Object { $_.ProcessId }"
) % parent_pid
_startup_trace_count("powershell")
var exit_code := _execute_windows_powershell(script, output)
if exit_code != 0 or output.is_empty():
continue
for pid in _parse_pid_lines(str(output[0])):
if not found.has(pid):
found.append(pid)
return found
func is_dev_server_running() -> bool:
## Returns true if a branded dev server is running on the HTTP port
## that we didn't start as managed.
if _lifecycle.get_server_pid() > 0:
return false
for pid in _find_all_pids_on_port(ClientConfigurator.http_port()):
if _pid_cmdline_is_godot_ai(int(pid)):
return true
return false
func has_managed_server() -> bool:
## Returns true if the plugin is currently managing a server process it spawned.
return _lifecycle.has_managed_server()
func can_restart_managed_server() -> bool:
## Restart is allowed only when we have ownership proof. A live PID
## means this plugin spawned/adopted a managed server; a non-empty
## managed record is the cross-session proof used by the drift branch.
return _lifecycle.can_restart_managed_server()