@tool class_name McpServerState extends RefCounted ## State machine for the plugin's server-spawn / adopt / version-verify ## lifecycle. Single source of truth — supersedes the boolean-flag thicket ## (`_server_started_this_session`, `_awaiting_server_version`, ## `_server_version_deadline_ms`, `_connection_blocked`, ## `_can_recover_incompatible`, `_refresh_retried`, ## `_adoption_watch_deadline_ms`) and the older terminal-only ## McpSpawnState string union. ## ## The integer values matter — they're what `get_server_status()` ## surfaces, what the dock pattern-matches on, and what the test suites ## assert against. Reordering the enum is a breaking change. ## ## The transitions are documented in `can_transition()`. The lifecycle ## manager calls `set_state()` which: ## 1. Validates the transition (logs a warning + no-ops on illegal). ## 2. Preserves first-writer-wins among terminal diagnoses so a late ## CRASHED from the watch loop can't clobber an earlier ## PORT_EXCLUDED from the proactive Windows reservation check. ## Fresh plugin instance, `_start_server` has not run yet. Default state. const UNINITIALIZED := 0 ## Process spawned via OS.create_process; watch loop is observing the ## SPAWN_GRACE_MS window. Transitions directly to READY (handshake_ack ## verifies a compatible version), CRASHED (process died early), or ## INCOMPATIBLE (handshake reported a mismatch). const SPAWNING := 1 ## (slot 2 reserved — keep wire-compat for clients pattern-matching ## numeric `editor_state.state` values; do not reuse.) ## Server is healthy and version-verified. Happy path. Includes both ## "spawned fresh" and "adopted compatible existing server" flavors — ## adoption flavor is recorded separately via `McpAdoptionLabel`. const READY := 3 ## Live server on the HTTP port returned a version that doesn't match ## what this plugin expects, OR returned no `handshake_ack` inside the ## timeout. Connection is blocked; recovery requires a kill+respawn ## click via `recover_incompatible_server`. const INCOMPATIBLE := 4 ## Spawned process exited inside the SPAWN_GRACE_MS window. Python ## traceback went to Godot's output log. Terminal — reload the plugin ## or restart the editor to retry. const CRASHED := 5 ## No server command resolved: no `.venv` Python, no `uvx` on PATH, no ## system `godot-ai`. Terminal — install guidance shown in dock. const NO_COMMAND := 6 ## Windows reserved the HTTP port via Hyper-V / WSL2 / Docker exclusion ## range. Caught proactively before bind. Terminal — port picker shown. const PORT_EXCLUDED := 7 ## HTTP port held by a process we didn't spawn (no matching managed ## record). Plugin armed an adoption-confirmation watcher; if the foreign ## occupant turns out to be a compatible godot-ai server, ## `handle_server_version_verified` transitions to READY. If the ## adoption deadline expires without a connection, the watcher self- ## disarms but the state stays at FOREIGN_PORT — the dock keeps showing ## "port held by another process" until the user reloads. The version- ## check seam (separate from the adoption deadline) is what fires ## INCOMPATIBLE on a positive-but-mismatched handshake. const FOREIGN_PORT := 8 ## Static re-entrancy guard fired (`_server_started_this_session` was ## already true). The plugin is being re-enabled within the same editor ## session; the previous instance still owns the spawn. Terminal — does ## NOT block READY paths, just records that this enable cycle no-op'd. const GUARDED := 9 ## stop_server / prepare_for_update_reload in progress. Transitional — ## next state is STOPPED. const STOPPING := 10 ## stop_server completed; `_server_pid` reset to -1, port may or may ## not be free. From here a fresh `start_server` call moves back through ## SPAWNING / READY. const STOPPED := 11 const _NAMES := { UNINITIALIZED: "uninitialized", SPAWNING: "spawning", READY: "ready", INCOMPATIBLE: "incompatible", CRASHED: "crashed", NO_COMMAND: "no_command", PORT_EXCLUDED: "port_excluded", FOREIGN_PORT: "foreign_port", GUARDED: "guarded", STOPPING: "stopping", STOPPED: "stopped", } ## Human-readable label. Used in startup-trace logs and transition ## warnings. Falls back to `unknown()` for unrecognised values so ## a future enum addition won't crash the formatter. static func name_of(state: int) -> String: return _NAMES.get(state, "unknown(%d)" % state) ## True for any state the dock should render as a non-OK diagnostic ## panel. Used as the "should we hide the spawn-failure panel?" gate. static func is_terminal_diagnosis(state: int) -> bool: return ( state == CRASHED or state == NO_COMMAND or state == PORT_EXCLUDED or state == INCOMPATIBLE or state == FOREIGN_PORT ) ## True only for READY. Other "ok-ish" states (SPAWNING) are still in ## flight; READY is the only state where the plugin can treat the server ## as fully healthy. static func is_healthy(state: int) -> bool: return state == READY ## True when the dock should consider the server unsuitable for client ## health checks (incompatible tool surface). Currently just INCOMPATIBLE ## — FOREIGN_PORT is transitional and may resolve to READY if the ## foreign occupant turns out to speak our handshake. static func blocks_client_health(state: int) -> bool: return state == INCOMPATIBLE ## Transition validation table. Returns true when `from -> to` is a ## legal transition the lifecycle manager should accept. Illegal ## transitions are silently no-op'd at the call site (with a ## `push_warning` log) — this preserves the first-writer-wins contract ## that prevents a late CRASHED from the watch loop overwriting an ## earlier PORT_EXCLUDED diagnosis. static func can_transition(from: int, to: int) -> bool: if from == to: return true ## Stop is always legal — teardown / install reload short-circuits ## any in-flight state. if to == STOPPING: return true if to == STOPPED and from == STOPPING: return true ## STOPPED can also be reached directly when `_server_pid <= 0` and ## stop_server early-returns; treat it as legal from any state to ## keep the teardown path forgiving. if to == STOPPED: return true ## STOPPED -> any (re-arm via restart paths). if from == STOPPED: return true ## GUARDED is sticky for the rest of this enable cycle; only stop is ## legal out of it. Already covered by the stop checks above. if from == GUARDED: return false ## Terminal diagnoses freeze further forward transitions. Recovery ## goes through STOPPING (covered above), so any other target is ## rejected — this is the first-writer-wins contract. if ( from == CRASHED or from == NO_COMMAND or from == PORT_EXCLUDED or from == INCOMPATIBLE ): return false ## UNINITIALIZED is the boot state — any target except STOPPING is ## reachable directly (start_server's early branches set ## terminal states without going through SPAWNING). if from == UNINITIALIZED: return true ## In-flight forward transitions. match from: SPAWNING: return ( to == READY or to == CRASHED or to == FOREIGN_PORT or to == INCOMPATIBLE ) FOREIGN_PORT: return to == READY or to == INCOMPATIBLE READY: ## Late incompatibility detection (e.g. version verifier ## re-arms after a foreign-port reconnect that turns out ## to be incompatible after all). return to == INCOMPATIBLE or to == CRASHED STOPPING: ## Recovery rollback: kill-then-respawn paths that fail to ## free the port re-latch INCOMPATIBLE (so the dock keeps ## the diagnostic UI) or fall back to UNINITIALIZED (clean ## baseline for a follow-up `_set_incompatible_server`). ## STOPPING -> STOPPED is handled by the early checks above. return to == INCOMPATIBLE or to == UNINITIALIZED return false