716 lines
34 KiB
GDScript
716 lines
34 KiB
GDScript
@tool
|
|
extends Node
|
|
## The editor-side MCP server — the transport orchestration facade.
|
|
##
|
|
## Constructs, wires, and drives the transport subsystems, keeping only the
|
|
## lifecycle and the cross-subsystem seams: ws_transport owns the TCP listener +
|
|
## WS peer accept/poll + auth-handshake framing, notifier owns notification
|
|
## framing/sends, server_request_router + dispatch_lane own request routing and
|
|
## lane dispatch, and scene_lease / mutation_watchdog / unfocused_sleep_controller
|
|
## / lsp_publisher own their domains. All command logic lives in per-domain
|
|
## modules under commands/.
|
|
|
|
signal client_connected(peer_count: int)
|
|
signal client_disconnected(peer_count: int)
|
|
signal command_received(method: String)
|
|
## Emitted when the MCP server reports a new GDScript LSP verdict
|
|
## (set_reported_lsp_status) so the dock refreshes exactly on change — no polling,
|
|
## no stale label even if the status is re-assessed later (e.g. on an LSP call).
|
|
signal lsp_status_changed
|
|
## Emitted after the session token is re-written to a new user:// path following a
|
|
## user-path change, carrying that new token path. The plugin (registry lifecycle
|
|
## owner) re-publishes the entry via ensure_registered, which preserves any active
|
|
## runtime_port/runtime_pid — register would null them and break Mode-B discovery
|
|
## for a game running across the rename.
|
|
signal token_rewritten(token_path: String)
|
|
## Emitted when the listen state changes — a fresh bind, a listen conflict
|
|
## (pinned port occupied / scan band exhausted / lost rebind) raised or cleared,
|
|
## or a port-config error. The dock re-reads get_port_warning() + the listening
|
|
## state and repaints. Immediate counterpart to the dock's own refresh pull.
|
|
signal port_status_changed
|
|
## Emitted on a fresh listener bind (initial, or late — e.g. a pinned port that
|
|
## frees after a startup conflict), carrying the bound port. The composition root
|
|
## (re)publishes the registry entry on it, so the actual bound port lands in
|
|
## projects.json in every mode — the ground truth the server's desync cross-check
|
|
## reads.
|
|
signal port_bound(port: int)
|
|
|
|
const Modules := preload("res://addons/godot_mcp_toolkit/core/modules.gd")
|
|
const MCPAuth := preload("res://addons/godot_mcp_toolkit/security/auth.gd")
|
|
const UndoRedoHelpers := preload("res://addons/godot_mcp_toolkit/scene/undo_redo_helpers.gd")
|
|
const Notifier := preload("res://addons/godot_mcp_toolkit/transport/notifier.gd")
|
|
const WsTransport := preload("res://addons/godot_mcp_toolkit/transport/ws_transport.gd")
|
|
const MutationWatchdog := preload("res://addons/godot_mcp_toolkit/transport/dispatch/mutation_watchdog.gd")
|
|
const SceneLease := preload("res://addons/godot_mcp_toolkit/scene/scene_lease.gd")
|
|
const ServerRequestRouter := preload("res://addons/godot_mcp_toolkit/transport/dispatch/server_request_router.gd")
|
|
const DispatchLane := preload("res://addons/godot_mcp_toolkit/transport/dispatch/dispatch_lane.gd")
|
|
const UnfocusedSleepController := preload("res://addons/godot_mcp_toolkit/core/unfocused_sleep_controller.gd")
|
|
const LspPublisher := preload("res://addons/godot_mcp_toolkit/paths/lsp_publisher.gd")
|
|
const PortConfig := preload("res://addons/godot_mcp_toolkit/transport/port_config.gd")
|
|
|
|
# Default editor scan band (inclusive). GODOT_MCP_EDITOR_PORT pins an exact port
|
|
# (bind-exact-or-fail); GODOT_MCP_EDITOR_PORT_MIN/_MAX relocate the band. Resolved
|
|
# by PortConfig; the two modes are mutually exclusive (a pin ignores the band).
|
|
const PORT_MIN := 6550
|
|
const PORT_MAX := 6560 # 6550..6560 inclusive
|
|
const _ENV_PIN := "GODOT_MCP_EDITOR_PORT"
|
|
const _ENV_PORT_MIN := "GODOT_MCP_EDITOR_PORT_MIN"
|
|
const _ENV_PORT_MAX := "GODOT_MCP_EDITOR_PORT_MAX"
|
|
const BIND := "127.0.0.1"
|
|
const JSONRPC_VERSION := "2.0"
|
|
# Throttle re-listen retries to avoid log spam when the port is
|
|
# briefly held by another process (e.g. a second editor instance, a stale
|
|
# debugger). 60 frames ~= 1s at 60fps; the bridge's reconnect backoff sits
|
|
# on the same order so we don't pile retries on top of the bridge's.
|
|
const _RELISTEN_FRAME_INTERVAL := 60
|
|
# Poll TCPServer/WebSocket peers every Nth frame instead of every frame.
|
|
# Godot has a race between plugin _process work and the main-loop work
|
|
# triggered by FileSystem-dock interactions (reentrancy through
|
|
# Main::iteration — see godotengine/godot#46893, #54864, #110891).
|
|
# Two mitigations stack:
|
|
# 1. Frame-skip: poll at ~15Hz (4 frames) instead of 60Hz, shrinking
|
|
# the collision window ~4x.
|
|
# 2. Deferred dispatch: _process schedules the poll body via
|
|
# call_deferred instead of running it inline, moving our I/O out
|
|
# of the _process call stack where reentrancy is most dangerous.
|
|
# Side-effect: commands run inside the deferred-call context, so Godot
|
|
# APIs that use the progress dialog (e.g. save_scene) log benign
|
|
# progress_dialog.cpp errors. This is acceptable — the alternative
|
|
# (inline _process poll) causes reproducible editor crashes.
|
|
# No upstream structural fix exists as of Godot 4.5/4.6-dev.
|
|
# Cold/hot sleep mode (set_process(false) with no client) was considered and
|
|
# rejected: no TCPServer accept signal exists to wake on, and gating the loop
|
|
# fights this deferral + the always-run _mutation_watchdog.tick() for a tiny gain.
|
|
const _POLL_FRAME_INTERVAL := 4
|
|
# Auth timeout. Peers that don't send a valid auth message within this
|
|
# window are closed with WS close code 1008 (Policy Violation).
|
|
const _AUTH_TIMEOUT_MS := 2000
|
|
|
|
# Owns the TCP listener + WS peers + auth-handshake framing + the bound port +
|
|
# the session token. This file injects the editor-side decisions/side-effects as
|
|
# Callables (auth-ack payload, boost-on-authed, lease cleanup on close) and drives
|
|
# it via _poll_connections → transport.pump().
|
|
var _transport: WsTransport = null
|
|
var _poll_frame_counter := 0
|
|
# Resolved listen-port configuration (PortConfig.resolve result). A non-empty
|
|
# "error" is fatal — the editor server does not listen and the dock shows why.
|
|
var _port_config: Dictionary = {}
|
|
# Captured at start() so editor.get_console's log-file selection heuristic
|
|
# can prefer post-boot logs over stale rotated ones.
|
|
var _plugin_boot_time: int = 0
|
|
var _registry: MCPToolkitCommandRegistry = null
|
|
# Routes a parsed JSON-RPC request to the lane its registry policy selects (read /
|
|
# mutation / scene-lease) and drives it. Owns the in-flight cancellable-context map + the
|
|
# three lanes; this file keeps only the lifecycle wiring + the cross-subsystem seams the
|
|
# lanes need injected (the scene-lease handlers, the watchdog, command_received). Built in
|
|
# start(); _handle_message hands it each authed frame. See server_request_router.gd / dispatch_lane.gd.
|
|
var _router: ServerRequestRouter = null
|
|
# The LSP endpoint publisher (resolve THIS editor's GDScript-LSP endpoint,
|
|
# publish it to the registry, re-publish on a debounced settings change) + the
|
|
# server-reported LSP liveness mirror live in lsp_publisher.gd. This file keeps only
|
|
# the cross-subsystem TRIGGER points (start() → connect the watch; stop() → disconnect
|
|
# it; server reports a verdict → set_reported_lsp_status), the public LSP getters the
|
|
# dock + commands reach (delegated to this child), and the lsp_status_changed SIGNAL
|
|
# (re-emitted here when the child reports a change — the dock binds it on the server).
|
|
# Constructed in start() with the bound-port source + the status-changed re-emit
|
|
# injected. See lsp_publisher.gd.
|
|
var _lsp: LspPublisher = null
|
|
# The unfocused-responsive mechanism (lower/restore/self-heal the machine-wide
|
|
# unfocused-sleep EditorSetting + its first-writer-wins backup) lives in
|
|
# unfocused_sleep_controller.gd. This file keeps only the cross-subsystem TRIGGER
|
|
# points (first authed connect → lower; last disconnect → restore; start() →
|
|
# self-heal first) and the public getters the dock reads, delegating each to this
|
|
# child. Constructed in start() with the EditorSettings accessor injected. See
|
|
# unfocused_sleep_controller.gd.
|
|
var _unfocused: UnfocusedSleepController = null
|
|
## Set by plugin.gd so domain commands can call EditorPlugin API
|
|
## (e.g. add_autoload_singleton for immediate editor cache refresh).
|
|
var editor_plugin: EditorPlugin = null
|
|
|
|
## Node holding UndoRedo helper methods that domain commands reference by
|
|
## string name. Populated in start(); command closures access it via
|
|
## server.undo_helpers.
|
|
var undo_helpers: Node = null
|
|
|
|
# -- Mutation serialisation ---------------------------------------------------
|
|
# When multiple WebSocket peers are connected, mutation commands must not
|
|
# interleave at await boundaries. A single-flight flag + FIFO queue ensures
|
|
# that at most one mutation executes at any time. Read-only commands bypass
|
|
# the lock entirely.
|
|
#
|
|
# The single-flight flag + FIFO queue + the mutation execute/drain live in
|
|
# dispatch_lane.gd's MutationLane (constructed by the dispatcher). This file keeps only
|
|
# the watchdog INSTANCE (it must tick every _process frame, independent of lane state)
|
|
# and hands it to the dispatcher to wire into the mutation lane's recovery hook.
|
|
|
|
# Mutation watchdog — recovers the lock if an in-flight mutation's coroutine
|
|
# aborts or never resolves (it would otherwise wedge ALL mutations permanently).
|
|
# It owns the in-flight identity + the adaptive deadline + the generation counter.
|
|
# Constructed in start() and handed to the dispatcher, which wires its force_clear hook
|
|
# into the mutation lane; armed at execution-start by that lane, ticked every _process
|
|
# frame HERE (it must run regardless of lane state). See mutation_watchdog.gd.
|
|
var _mutation_watchdog: MutationWatchdog = null
|
|
|
|
# -- Scene lease ---------------------------------------------------------------
|
|
# When multiple peers target different scenes, a time-bounded lease prevents
|
|
# cross-scene contamination. Tab-dependent commands queue until the peer's
|
|
# affinity scene matches the active tab.
|
|
#
|
|
# The lease mechanism (state + acquire/renew/release/steal/drain + the
|
|
# scene-affinity queue + scene.open contention) lives in scene_lease.gd; the dispatch
|
|
# ROUTING into it lives in the scene-lease lane (dispatch_lane.gd), reached through
|
|
# the dispatcher. Constructed in start() with the editor's root-resolver + the lane seams
|
|
# injected; the scene-lease lane routes via _scene_lease.try_queue_for_lease /
|
|
# handle_scene_open, and _process ticks _scene_lease.check_expiry. See scene_lease.gd.
|
|
var _scene_lease: SceneLease = null
|
|
|
|
|
|
func set_registry(registry: MCPToolkitCommandRegistry) -> void:
|
|
_registry = registry
|
|
# Push to the children if they already exist (set_registry normally runs before
|
|
# start() builds them, in which case _init_scene_lease / _init_router seed it).
|
|
if _scene_lease != null:
|
|
_scene_lease.set_registry(registry)
|
|
if _router != null:
|
|
_router.set_registry(registry)
|
|
|
|
|
|
## Release the command registry and all its Callable references.
|
|
## Called during plugin teardown to break reference chains before node deletion.
|
|
func clear_registry() -> void:
|
|
if _registry != null:
|
|
_registry.clear()
|
|
_registry = null
|
|
# Break the children's registry + seam Callable chains too (they hold the registry
|
|
# and seams bound back to this server).
|
|
if _scene_lease != null:
|
|
_scene_lease.clear_registry()
|
|
if _router != null:
|
|
_router.clear()
|
|
|
|
|
|
func get_plugin_boot_time() -> int:
|
|
return _plugin_boot_time
|
|
|
|
|
|
func is_listening() -> bool:
|
|
return _transport != null and _transport.is_listening()
|
|
|
|
|
|
func get_authed_peer_count() -> int:
|
|
return _transport.get_authed_count() if _transport != null else 0
|
|
|
|
|
|
func get_bound_port() -> int:
|
|
return _transport.get_bound_port() if _transport != null else -1
|
|
|
|
|
|
## The GDScript LSP endpoint THIS editor's setting points at (default
|
|
## 127.0.0.1:6005). Thin static delegate to lsp_publisher.gd (C9) — kept here because
|
|
## plugin.gd + the registry callers call MCPServer.resolve_lsp_endpoint() statically;
|
|
## the resolved host/port are passed into register()/ensure_registered() so
|
|
## registry_client.gd stays editor-clean for the Mode-B runtime autoload.
|
|
static func resolve_lsp_endpoint() -> Dictionary:
|
|
return LspPublisher.resolve_lsp_endpoint()
|
|
|
|
|
|
## The MCP server reports the authoritative LSP verdict here (editor.set_lsp_status);
|
|
## delegates to the LSP publisher, which stores it and re-emits lsp_status_changed via
|
|
## the injected handler so the dock refreshes. The signal stays on this object (the
|
|
## dock binds it here). See lsp_publisher.gd.
|
|
func set_reported_lsp_status(status: Dictionary) -> void:
|
|
if _lsp != null:
|
|
_lsp.set_reported_lsp_status(status)
|
|
|
|
|
|
func get_reported_lsp_status() -> Dictionary:
|
|
return _lsp.get_reported_lsp_status() if _lsp != null else {}
|
|
|
|
|
|
func get_command_methods() -> Array:
|
|
if _registry == null:
|
|
return []
|
|
return _registry.get_all_methods()
|
|
|
|
|
|
## Send a notification to all authenticated WebSocket peers.
|
|
## Triggers a server-side tool-list reload without a restart
|
|
## (e.g. after the tool surface changes, such as a group activation
|
|
## or an extension being added).
|
|
func broadcast_notification(notification_type: String, params: Dictionary = {}) -> void:
|
|
var authed: Array = _transport.get_authed_peers() if _transport != null else []
|
|
var count := Notifier.broadcast(authed, notification_type, params, "[MCPServer]")
|
|
print("[MCPServer] broadcasting %s to %d authed peer%s" % [
|
|
notification_type, count, "" if count == 1 else "s"])
|
|
|
|
|
|
func bind_user_path_monitor(monitor: RefCounted) -> void:
|
|
monitor.user_path_changed.connect(_on_user_path_changed)
|
|
|
|
|
|
func _on_user_path_changed() -> void:
|
|
_rewrite_token_after_rename()
|
|
|
|
|
|
## Re-write the current in-memory token to the new user:// path after a
|
|
## config/name change. Does NOT generate a new token — existing connections
|
|
## stay authenticated. Announces the new token path so the registry owner
|
|
## (plugin.gd) re-publishes the entry runtime-preservingly.
|
|
func _rewrite_token_after_rename() -> void:
|
|
if _transport == null:
|
|
return # No running transport → no token to re-write, nothing bound.
|
|
var write_err := MCPAuth.write_token(_transport.get_token())
|
|
if write_err != OK:
|
|
push_warning("[MCPServer] failed to re-write token after rename (err %d)" % write_err)
|
|
else:
|
|
print("[MCPServer] token re-written to %s" % MCPAuth.get_token_path())
|
|
# Announce the new token path; the plugin re-publishes via ensure_registered so
|
|
# an active game's runtime_port/runtime_pid survive the rename (register nulls
|
|
# them). The server doesn't reach into the registry from the rename path.
|
|
if _transport.get_bound_port() > 0:
|
|
token_rewritten.emit(MCPAuth.get_token_path())
|
|
|
|
|
|
func regenerate_token() -> void:
|
|
var token := MCPAuth.generate_token()
|
|
if _transport != null:
|
|
_transport.set_token(token)
|
|
var write_err := MCPAuth.write_token(token)
|
|
if write_err != OK:
|
|
push_warning("[MCPServer] failed to write rotated token (err %d)" % write_err)
|
|
else:
|
|
print("[MCPServer] token rotated, written to %s" % MCPAuth.get_token_path())
|
|
# Close all existing peers — they must re-auth with the new token.
|
|
if _transport != null:
|
|
_transport.close_all(1008, "token rotated")
|
|
|
|
|
|
func start() -> void:
|
|
# Self-heal a leftover boost (from a crash or a concurrent instance) before
|
|
# listening, so the global key can never persist without a live connection.
|
|
# Build the child first (self_heal is its method); no peer can be authed yet.
|
|
_init_unfocused_sleep()
|
|
_unfocused.self_heal(get_authed_peer_count())
|
|
if undo_helpers == null:
|
|
undo_helpers = UndoRedoHelpers.new()
|
|
undo_helpers.name = "UndoRedoHelpers"
|
|
add_child(undo_helpers)
|
|
_plugin_boot_time = int(Time.get_unix_time_from_system())
|
|
_init_transport()
|
|
_init_mutation_watchdog()
|
|
# Build scene_lease + dispatcher, then cross-wire. The two have a mutual seam
|
|
# dependency (the scene-lease lane routes into the lease child; the lease child's
|
|
# queued paths re-enter the read/mutation lanes), so both are CONSTRUCTED first, then
|
|
# wired: _init_router binds the lanes to the (now-existing) lease methods, and
|
|
# _init_scene_lease binds the lease seams to the (now-existing) lane methods.
|
|
_init_scene_lease()
|
|
_init_router()
|
|
_wire_scene_lease_to_lanes()
|
|
_init_lsp_publisher()
|
|
var token := MCPAuth.generate_token()
|
|
# A port-config error leaves _transport null (start() still wires the rest so the
|
|
# dock + LSP watch come up and can surface the error); skip the listen path.
|
|
if _transport != null:
|
|
_transport.set_token(token)
|
|
var write_err := MCPAuth.write_token(token)
|
|
if write_err != OK:
|
|
push_warning("[MCPServer] failed to write token (err %d); auth will still be enforced but bridge may not find the file" % write_err)
|
|
else:
|
|
var token_path := MCPAuth.get_token_path()
|
|
print("[MCPServer] session token written to %s" % token_path)
|
|
if _transport != null:
|
|
_transport.ensure_listening()
|
|
else:
|
|
port_status_changed.emit()
|
|
_connect_lsp_settings_watch()
|
|
|
|
|
|
# Build the WS transport and inject the editor-side seams: the auth-vs-dispatch
|
|
# router, the auth-ack payload (Mode A adds godot_version + version), the
|
|
# boost-on-first-authed callback, the lease/unfocused/signal cleanup on close, the
|
|
# fresh-bind announce (registry re-publish + dock repaint), and the listen
|
|
# conflict → dock warning. Resolves the listen-port config first; a config error
|
|
# leaves _transport null so start() skips listening (dock shows why).
|
|
func _init_transport() -> void:
|
|
_port_config = PortConfig.resolve(_ENV_PIN, _ENV_PORT_MIN, _ENV_PORT_MAX, PORT_MIN, PORT_MAX)
|
|
_log_port_config()
|
|
if not str(_port_config.get("error", "")).is_empty():
|
|
return
|
|
_transport = WsTransport.new()
|
|
var pinned: bool = _port_config["mode"] == PortConfig.MODE_PINNED
|
|
var base: int = int(_port_config["port"]) if pinned else int(_port_config["port_min"])
|
|
var count: int = 1 if pinned else int(_port_config["port_max"]) - int(_port_config["port_min"]) + 1
|
|
# await_messages = true: the editor dispatches sequentially within a poll tick
|
|
# (its mutation serialisation depends on this).
|
|
_transport.configure("[MCPServer]", base, count, BIND,
|
|
_RELISTEN_FRAME_INTERVAL, _AUTH_TIMEOUT_MS, true,
|
|
"no free port in %d-%d; will retry every ~1s", pinned,
|
|
"Free the port, or change %s (or unset it to use the scanned band)." % _ENV_PIN)
|
|
_transport.set_handlers(_handle_message, _build_auth_ack, _on_peer_authed,
|
|
_on_peer_closed, _on_transport_bound, _on_listen_conflict_changed)
|
|
|
|
|
|
# Log the resolved listen-port config at startup: the port + source (so both the
|
|
# editor console and the dock can tell WHY this port was chosen — pin, band, or
|
|
# default), a one-line note when a pin makes the band moot, or a
|
|
# loud push_error on a config error (bad pin / MIN > MAX) — never a silent default.
|
|
func _log_port_config() -> void:
|
|
var config_error := str(_port_config.get("error", ""))
|
|
if not config_error.is_empty():
|
|
push_error("[MCPServer] Invalid port config: %s - the editor MCP server did not start. Fix the environment variable and restart the editor (env is read at editor launch; re-enabling the plugin keeps the old value)." % config_error)
|
|
return
|
|
if bool(_port_config.get("band_ignored", false)):
|
|
print("[MCPServer] note: %s pins an exact port, so the %s/%s band is ignored." % [
|
|
_ENV_PIN, _ENV_PORT_MIN, _ENV_PORT_MAX])
|
|
match str(_port_config.get("source", "")):
|
|
"env-pin":
|
|
print("[MCPServer] port %d (pinned via %s)" % [int(_port_config["port"]), _ENV_PIN])
|
|
"env-band":
|
|
print("[MCPServer] port: scanning %d-%d (band via %s/%s)" % [
|
|
int(_port_config["port_min"]), int(_port_config["port_max"]),
|
|
_ENV_PORT_MIN, _ENV_PORT_MAX])
|
|
_:
|
|
print("[MCPServer] port: scanning %d-%d (default)" % [
|
|
int(_port_config["port_min"]), int(_port_config["port_max"])])
|
|
|
|
|
|
# Fresh listener bind (initial or late, both modes). Announce the port so the
|
|
# composition root (re)publishes the registry entry — this is what keeps a late
|
|
# pinned bind discoverable — and repaint the dock's listen state.
|
|
func _on_transport_bound(port: int) -> void:
|
|
port_bound.emit(port)
|
|
port_status_changed.emit()
|
|
|
|
|
|
# Listen-conflict state changed in the transport (unbindable ⇄ bound). Re-emit so
|
|
# the dock repaints; the dock also pulls the state on its own refresh, so a missed
|
|
# emit (e.g. during start(), before the dock binds) is still caught.
|
|
func _on_listen_conflict_changed(_port: int, _active: bool) -> void:
|
|
port_status_changed.emit()
|
|
|
|
|
|
## The resolved listen-port source for the dock status label: "pinned" /
|
|
## "band" / "default", or "" when the config failed to resolve.
|
|
func get_port_source() -> String:
|
|
match str(_port_config.get("source", "")):
|
|
"env-pin":
|
|
return "pinned"
|
|
"env-band":
|
|
return "band"
|
|
"default":
|
|
return "default"
|
|
_:
|
|
return ""
|
|
|
|
|
|
## The current not-listening warning for the dock: a fatal port-config error, a
|
|
## pinned-port conflict, or a scan-band-exhausted conflict. Returns
|
|
## { "active": bool, "message": String, "label": String } — message is the full
|
|
## warning-panel text, label the concise reason for the status row; active is
|
|
## false when the server bound normally.
|
|
func get_port_warning() -> Dictionary:
|
|
var config_error := str(_port_config.get("error", ""))
|
|
if not config_error.is_empty():
|
|
return {
|
|
"active": true,
|
|
"message": "Invalid port config: %s — the MCP editor server did not start. Fix the environment variable and restart the editor (env is read at editor launch; re-enabling the plugin keeps the old value)." % config_error,
|
|
"label": "invalid port config",
|
|
}
|
|
if _transport != null and _transport.is_listen_conflict():
|
|
if _port_config.get("mode", "") == PortConfig.MODE_PINNED:
|
|
var pinned_port: int = int(_port_config["port"])
|
|
return {
|
|
"active": true,
|
|
"message": "Pinned port: %d not available — the MCP editor server did not bind. Free the port, or change %s (or unset it to use the scanned band)." % [pinned_port, _ENV_PIN],
|
|
"label": "pinned port %d not available" % pinned_port,
|
|
}
|
|
# A lost listen socket is retried on the SAME port only (get_bound_port()
|
|
# stays set), so range-wide advice would be ineffective — name that port.
|
|
var lost_port: int = _transport.get_bound_port()
|
|
if lost_port > 0:
|
|
return {
|
|
"active": true,
|
|
"message": "Port: %d not available — the MCP editor server lost its listen socket and is retrying that port. Free port %d to recover." % [lost_port, lost_port],
|
|
"label": "port %d not available" % lost_port,
|
|
}
|
|
var low: int = int(_port_config.get("port_min", PORT_MIN))
|
|
var high: int = int(_port_config.get("port_max", PORT_MAX))
|
|
return {
|
|
"active": true,
|
|
"message": "Range of ports: %d-%d not available — the MCP editor server did not bind. Free a port in the range, or move it with %s/%s." % [low, high, _ENV_PORT_MIN, _ENV_PORT_MAX],
|
|
"label": "ports %d-%d not available" % [low, high],
|
|
}
|
|
return {"active": false, "message": "", "label": ""}
|
|
|
|
|
|
# Build the mutation watchdog. Its force_clear recovery hook is wired by the mutation
|
|
# lane (in the dispatcher) — the lane owns the single-flight flag the hook clears, so the
|
|
# hook belongs with the lane. This file only constructs the watchdog and ticks() it
|
|
# every _process frame (it must run regardless of lane state).
|
|
func _init_mutation_watchdog() -> void:
|
|
_mutation_watchdog = MutationWatchdog.new()
|
|
|
|
|
|
# Build the unfocused-sleep controller, injecting the EditorSettings accessor (the
|
|
# child's ONLY editor reach — behind a Callable so it stays the testability seam, and
|
|
# the EditorInterface name stays in this editor file, mirroring scene_lease's
|
|
# root-resolver). The orchestrator keeps the trigger points (boost on first authed,
|
|
# restore on last disconnect, self-heal at start); the child owns the mechanism.
|
|
func _init_unfocused_sleep() -> void:
|
|
_unfocused = UnfocusedSleepController.new()
|
|
_unfocused.set_settings_accessor(func() -> EditorSettings:
|
|
return EditorInterface.get_editor_settings())
|
|
|
|
|
|
# Construct the scene-lease coordinator and seed the registry. Its seams are wired in
|
|
# _wire_scene_lease_to_lanes (after the dispatcher's lanes exist — they have a mutual
|
|
# dependency). set_registry usually ran before start() built this, so seed the registry.
|
|
func _init_scene_lease() -> void:
|
|
_scene_lease = SceneLease.new()
|
|
_scene_lease.set_registry(_registry)
|
|
|
|
|
|
# Build the dispatcher + its three lanes, injecting the cross-subsystem seams the lanes
|
|
# need (each behind a Callable so the dispatcher + lanes stay editor-clean): the
|
|
# command_received re-emit, the mutation watchdog, and the scene-lease lane's seams bound
|
|
# to the (already-constructed) lease child — handle_scene_open / try_queue_for_lease /
|
|
# cancel_queued for routing, and inject_concurrency_metadata / post_mutation_cleanup /
|
|
# drain for the mutation lane's completion path. set_registry usually ran before start(),
|
|
# so seed the dispatcher's registry too.
|
|
func _init_router() -> void:
|
|
_router = ServerRequestRouter.new()
|
|
_router.set_registry(_registry)
|
|
_router.build_lanes(
|
|
func(method: String) -> void: command_received.emit(method),
|
|
_mutation_watchdog,
|
|
_scene_lease.inject_concurrency_metadata,
|
|
_scene_lease.post_mutation_cleanup,
|
|
_scene_lease.drain,
|
|
_scene_lease.handle_scene_open,
|
|
_scene_lease.try_queue_for_lease,
|
|
_scene_lease.cancel_queued,
|
|
)
|
|
|
|
|
|
# Inject the lane methods into the scene-lease child (the second half of the mutual
|
|
# wiring): the editor's root-resolver (the ONLY EditorInterface reach — behind a Callable
|
|
# so it is the testability seam), the command_received re-emit, the read lane's
|
|
# result-returning core (the queued-read path injects concurrency metadata before
|
|
# sending, so it needs the result, not a send), and the mutation lane's busy-enqueue +
|
|
# execute entry. Runs after _init_router built the lanes.
|
|
func _wire_scene_lease_to_lanes() -> void:
|
|
var mutation_lane = _router.mutation_lane()
|
|
_scene_lease.set_handlers(
|
|
func() -> Node: return EditorInterface.get_edited_scene_root(),
|
|
func(method: String) -> void: command_received.emit(method),
|
|
_router.read_lane().run_returning,
|
|
mutation_lane.enqueue_if_busy,
|
|
mutation_lane.execute,
|
|
)
|
|
|
|
|
|
# Build the LSP publisher and inject the two seams it needs from this orchestrator: the
|
|
# bound-WS-port source (the transport owns the port; the watch reads it to skip
|
|
# re-publishing before the server is listening) and the lsp_status_changed re-emit (the
|
|
# signal stays on this object because the dock binds it here). resolve_lsp_endpoint is
|
|
# static on the child — it names EditorInterface directly, which is fine for this
|
|
# editor-only file (never runtime-preloaded). Constructed in start() before the watch
|
|
# is connected.
|
|
func _init_lsp_publisher() -> void:
|
|
_lsp = LspPublisher.new()
|
|
_lsp.set_bound_port_provider(func() -> int:
|
|
return _transport.get_bound_port() if _transport != null else -1)
|
|
_lsp.set_status_changed_handler(func() -> void: lsp_status_changed.emit())
|
|
|
|
|
|
func stop() -> void:
|
|
set_process(false)
|
|
_disconnect_lsp_settings_watch()
|
|
if _transport != null:
|
|
_transport.close_all(1000, "")
|
|
if _unfocused != null:
|
|
_unfocused.restore()
|
|
if _transport != null:
|
|
_transport.shutdown_listener()
|
|
print("[MCPServer] stopped")
|
|
|
|
|
|
# LSP settings-watch trigger points (the mechanism lives in lsp_publisher.gd, C9).
|
|
# start() connects the watch (resolve baseline + listen on EditorSettings.settings_changed
|
|
# for a mid-session GDScript LSP port/host change → debounced registry re-publish);
|
|
# stop() disconnects it (I12 symmetry). Thin null-guarded delegates so the lifecycle
|
|
# sequencing stays here while the child owns the watch + debounce + re-publish.
|
|
func _connect_lsp_settings_watch() -> void:
|
|
if _lsp != null:
|
|
_lsp.connect_settings_watch()
|
|
|
|
|
|
func _disconnect_lsp_settings_watch() -> void:
|
|
if _lsp != null:
|
|
_lsp.disconnect_settings_watch()
|
|
|
|
|
|
# -- Frame loop ----------------------------------------------------------------
|
|
|
|
|
|
func _process(_delta: float) -> void:
|
|
# The mutation watchdog must always run, independent of the poll cadence
|
|
# and lease state — it is the sole recovery for a wedged mutation lock. (The
|
|
# null guard mirrors _poll_connections: start() builds it synchronously in
|
|
# _enter_tree before any _process, but a stray pre-start tick stays a no-op.)
|
|
if _mutation_watchdog != null:
|
|
_mutation_watchdog.tick()
|
|
Modules.LogBuffer.poll()
|
|
_poll_frame_counter += 1
|
|
if _poll_frame_counter < _POLL_FRAME_INTERVAL:
|
|
return
|
|
_poll_frame_counter = 0
|
|
if _scene_lease != null:
|
|
_scene_lease.check_expiry()
|
|
# Dispatch via call_deferred to move network I/O out of the _process
|
|
# call stack, reducing the reentrancy collision surface with Godot's
|
|
# EditorFileSystem scan/import work (see comment on _POLL_FRAME_INTERVAL).
|
|
call_deferred("_poll_connections")
|
|
|
|
|
|
func _poll_connections() -> void:
|
|
# Defensive: _process can only fire after start() built the transport (both run
|
|
# in plugin _enter_tree, synchronously), but guard anyway so a stray pre-start
|
|
# tick is a no-op rather than a null deref (the pre-extraction loop tolerated a
|
|
# null listener the same way).
|
|
if _transport == null:
|
|
return
|
|
# Skip this re-entrant tick while a save's Main::iteration() re-entry is
|
|
# in flight — no command may dispatch mid-save.
|
|
if MCPToolkitSafeSceneOps.is_dispatching():
|
|
return
|
|
# pump() returns whether an authed peer closed this pass. Aggregate the
|
|
# disconnect ONCE per tick with the FINAL authed count (the pre-extraction
|
|
# shape), using a local so reentrant deferred polls during a mutation await
|
|
# can't clobber each other.
|
|
var had_authed_disconnect: bool = await _transport.pump()
|
|
if had_authed_disconnect:
|
|
var authed_now := _transport.get_authed_count()
|
|
if authed_now == 0:
|
|
_unfocused.restore()
|
|
client_disconnected.emit(authed_now)
|
|
|
|
|
|
# -- Message handling ----------------------------------------------------------
|
|
|
|
|
|
# The transport delivers each raw frame here. We parse, then route: unauthenticated
|
|
# peers go through the transport's auth handshake (we additionally run the
|
|
# human-only version-mismatch check); authenticated peers go to dispatch. The
|
|
# transport awaits this, so dispatch stays sequential within a poll tick.
|
|
func _handle_message(peer: WebSocketPeer, text: String) -> void:
|
|
var parser := JSON.new()
|
|
var parse_error := parser.parse(text)
|
|
if parse_error != OK:
|
|
_send_error(peer, null, -32700, "Parse error: %s" % parser.get_error_message())
|
|
return
|
|
|
|
var message = parser.data
|
|
if typeof(message) != TYPE_DICTIONARY:
|
|
_send_error(peer, null, -32600, "Invalid Request: top-level must be an object")
|
|
return
|
|
|
|
if not _transport.is_authed(peer):
|
|
if _transport.validate_auth(peer, message):
|
|
# Version mismatch check — human-only (editor console), nothing on the
|
|
# MCP wire. Runs only after a successful auth, like the pre-extraction
|
|
# handshake. A pre-handshake server sends no version → skip.
|
|
var server_ver: String = str(message.get("version", ""))
|
|
if not server_ver.is_empty():
|
|
_check_version_mismatch(Modules.VersionUtils.read_plugin_version(), server_ver)
|
|
return
|
|
|
|
await _router.route_request(peer, message)
|
|
|
|
|
|
# Supplies the Mode-A auth-ack payload to the transport: the bare {authed:true} plus
|
|
# this editor's Godot + plugin versions and its display mode (the runtime sends
|
|
# {authed:true} only). `headless` is the wire signal the server reads to branch its
|
|
# headless-degraded tool assertions. Pure — no side effects; the boost/signal happen in
|
|
# _on_peer_authed.
|
|
func _build_auth_ack(_message: Dictionary) -> Dictionary:
|
|
var vi := Engine.get_version_info()
|
|
return {
|
|
"authed": true,
|
|
"godot_version": "%d.%d.%d" % [vi["major"], vi["minor"], vi["patch"]],
|
|
"version": Modules.VersionUtils.read_plugin_version(),
|
|
"headless": Modules.VersionUtils.is_headless(),
|
|
}
|
|
|
|
|
|
# Fired by the transport after a peer authenticates. On the FIRST authed peer,
|
|
# boost the unfocused responsiveness; always re-emit client_connected for the dock.
|
|
func _on_peer_authed(count: int) -> void:
|
|
if count == 1:
|
|
_unfocused.lower()
|
|
client_connected.emit(count)
|
|
|
|
|
|
# Fired by the transport once per closed peer (the transport has already erased
|
|
# its peer maps). Does the editor-only PER-PEER cleanup: drop the scene affinity,
|
|
# release the lease if this peer held it, drop the peer's queued scene commands.
|
|
# The aggregate client_disconnected emit + unfocused restore happen ONCE per tick
|
|
# in _poll_connections, keyed off pump()'s return — not here. was_authed is unused
|
|
# here (the aggregate uses the transport's batch result).
|
|
func _on_peer_closed(peer: WebSocketPeer, _was_authed: bool) -> void:
|
|
_scene_lease.on_peer_closed(peer)
|
|
|
|
|
|
func _check_version_mismatch(local: String, remote: String) -> void:
|
|
match Modules.VersionUtils.version_skew(local, remote):
|
|
"major":
|
|
push_error("[MCPServer] Major version mismatch - plugin %s, server %s. Update both to the same major version." % [local, remote])
|
|
"minor":
|
|
push_warning("[MCPServer] Version mismatch - plugin %s, server %s. Consider updating." % [local, remote])
|
|
_:
|
|
pass # ok / patch: compatible by construction; unknown: non-semver, not comparable
|
|
|
|
|
|
# Dispatch routing, the mutation lane, and the read/scene-lease routes live in
|
|
# server_request_router.gd + dispatch_lane.gd; _handle_message hands each authed
|
|
# frame to _router.route_request. The framing helper below stays — the orchestrator
|
|
# itself sends the pre-dispatch parse errors (-32700 / -32600) in _handle_message.
|
|
|
|
|
|
func _send_error(peer: WebSocketPeer, id, code: int, error_message: String) -> void:
|
|
Notifier.send_error(peer, id, code, error_message)
|
|
|
|
|
|
# -- Unfocused sleep management -----------------------------------------------
|
|
# The lower/restore/self-heal mechanism + the first-writer-wins backup live in
|
|
# unfocused_sleep_controller.gd. This file keeps the public
|
|
# getters the dock reads and delegates each to _unfocused; the cross-subsystem
|
|
# trigger points (boost on first authed connect, restore on last disconnect,
|
|
# self-heal at start) live in _on_peer_authed / _poll_connections / start(). The
|
|
# null guards keep the dock's pre-start indicator refresh honest (the child is
|
|
# built in start()), preserving the pre-extraction defaults.
|
|
|
|
|
|
## True when the user has opted in (default true). Missing/unavailable settings
|
|
## fall back to the default so the opt-in never blocks on settings availability.
|
|
func is_unfocused_responsive_enabled() -> bool:
|
|
return _unfocused.is_unfocused_responsive_enabled() if _unfocused != null else true
|
|
|
|
|
|
## fps implied by the configured boosted value, for the dock indicator + log.
|
|
func get_unfocused_responsive_fps() -> int:
|
|
return _unfocused.get_unfocused_responsive_fps() if _unfocused != null else 60
|
|
|
|
|
|
## True while THIS instance holds the boost active — delegated; semantics in
|
|
## unfocused_sleep_controller.gd's is_unfocused_boost_active.
|
|
func is_unfocused_boost_active() -> bool:
|
|
return _unfocused.is_unfocused_boost_active() if _unfocused != null else false
|
|
|
|
|
|
## Apply or restore the unfocused-responsive boost immediately when the opt-in
|
|
## setting changes, rather than waiting for the next connect/disconnect.
|
|
func notify_unfocused_responsive_setting_changed() -> void:
|
|
if _unfocused != null:
|
|
_unfocused.notify_unfocused_responsive_setting_changed(get_authed_peer_count())
|