2026-10-09 12:49:07 +02:00

145 lines
7.7 KiB
GDScript

@tool
extends RefCounted
## Resolves THIS editor's GDScript-LSP endpoint, publishes it to the registry, and
## re-publishes on a debounced settings change — plus holds the server-reported LSP
## liveness mirror the dock renders.
##
## The editor can't read its own LSP bind status, so there are two halves of one
## "LSP verdict" concern. (1) OUTBOUND: resolve the endpoint THIS editor's setting
## points at (network/language_server/remote_host/port, default 127.0.0.1:6005) and
## publish lsp_host/lsp_port into the registry entry; EditorSettings.settings_changed
## fires globally, so the watch debounces by comparing the re-resolved endpoint
## against the last published one (live re-publish on change). (2) INBOUND: the MCP
## server reports the authoritative verdict via set_reported_lsp_status (it can do
## reliable cross-process liveness the editor cannot); the dock renders whatever was
## last reported and refreshes exactly on change.
##
## Editor-side ONLY — and TAINTED by design: resolve_lsp_endpoint() and the watch
## name EditorInterface directly (they read network/language_server/* and connect to
## EditorSettings.settings_changed). That is allowed because the Mode-B runtime
## autoload has NO LSP publishing (it publishes runtime_port, not an LSP endpoint)
## and must NEVER preload this file. #91713 therefore does not gate this child, but a
## runtime reference to it WOULD taint the autoload, so keep it strictly editor-only:
## only mcp_server.gd (the editor server) preloads it. The orchestrator owns the
## cross-subsystem TRIGGER points (start() → resolve + publish + connect-watch;
## stop() → disconnect-watch; server reports status → set_reported_lsp_status); this
## child owns only the MECHANISM behind each, and the lsp_status_changed SIGNAL stays
## on the orchestrator (the dock binds it there) — this child reports a change via the
## injected status-changed handler so the orchestrator re-emits.
##
## resolve_lsp_endpoint() stays a static EditorInterface reader (verbatim from the
## server, where plugin.gd + the registry callers call it statically): it passes the
## resolved host/port VALUES into RegistryClient.register / ensure_registered, so
## registry_client.gd stays editor-clean for the Mode-B runtime autoload (it never
## resolves the endpoint itself — see registry_client.gd::_build_entry). Unlike the
## sibling tainted children (scene_lease / unfocused), this one does NOT take an
## injected EditorSettings accessor: an injected accessor would force the static
## resolver to become an instance method and break that static call graph for no
## taint gain (the file is never runtime-preloaded), so the lower-risk choice is to
## name EditorInterface directly.
const RegistryClient := preload("res://addons/godot_mcp_toolkit/registry/registry_client.gd")
const MCPAuth := preload("res://addons/godot_mcp_toolkit/security/auth.gd")
# Last LSP endpoint published to the registry — the re-publish baseline. The watch
# compares the re-resolved endpoint against these to debounce unrelated setting churn.
var _last_lsp_host: String = ""
var _last_lsp_port: int = -1
# Authoritative LSP verdict the MCP server last reported (editor.set_lsp_status). The
# editor can't read its own LSP bind status, so the server tells us and the dock
# renders it. {} until a server connects.
var _reported_lsp_status: Dictionary = {}
# Injected seams (wired once by mcp_server._init_lsp_publisher). Keep the
# lsp_status_changed signal + the transport on the orchestrator — this child does not
# own either.
# _on_status_changed: func() -> void — the orchestrator re-emits lsp_status_changed
# when the reported verdict changes (the dock binds that signal on the server).
var _on_status_changed: Callable = Callable()
# _bound_port_provider: func() -> int — the live bound WS port (the transport owns it;
# the watch needs it to skip re-publishing before/after the server is listening,
# exactly as the pre-extraction watch read _transport.get_bound_port()).
var _bound_port_provider: Callable = Callable()
## The GDScript LSP endpoint THIS editor's setting points at (default
## 127.0.0.1:6005). A --lsp-port override is invisible here — the engine consumes
## it before OS.get_cmdline_args() and never writes it to the setting — so that
## case rides GODOT_MCP_LSP_PORT on the server (see docs/multi-instance.md).
## Static + editor-only (names EditorInterface); the registry callers pass the
## result into register()/ensure_registered() so registry_client.gd stays
## editor-clean for the Mode-B runtime autoload.
static func resolve_lsp_endpoint() -> Dictionary:
var host := "127.0.0.1"
var port := 6005
var es := EditorInterface.get_editor_settings()
if es != null:
if es.has_setting("network/language_server/remote_host"):
host = str(es.get_setting("network/language_server/remote_host"))
if es.has_setting("network/language_server/remote_port"):
port = int(es.get_setting("network/language_server/remote_port"))
return {"host": host, "port": port}
## Wire the orchestrator's lsp_status_changed re-emit. Call once at construction,
## before any reported status arrives.
func set_status_changed_handler(on_status_changed: Callable) -> void:
_on_status_changed = on_status_changed
## Wire the live bound-WS-port source (the transport owns the port; this child does
## not). Call once at construction, before connect_settings_watch / any re-publish.
func set_bound_port_provider(bound_port_provider: Callable) -> void:
_bound_port_provider = bound_port_provider
## The MCP server reports the authoritative LSP verdict here — it can do reliable
## cross-process liveness (process.kill) and the real connection/root-verify, which
## the editor cannot (no engine API for its own LSP bind status). The dock renders
## whatever was last reported. Keys: state ("active"/"conflict"/"unavailable"), host,
## port, detail. Empty until an MCP server connects. Notifies the orchestrator so it
## re-emits lsp_status_changed (the signal stays on the server for the dock).
func set_reported_lsp_status(status: Dictionary) -> void:
_reported_lsp_status = status.duplicate()
if _on_status_changed.is_valid():
_on_status_changed.call()
func get_reported_lsp_status() -> Dictionary:
return _reported_lsp_status
## Re-publish the registry entry when the editor's GDScript LSP port/host
## setting changes mid-session, so the published endpoint never goes stale.
## EditorSettings.settings_changed fires globally; we debounce by comparing the
## re-resolved endpoint against the last published one. Connected from start(),
## disconnected in stop() (I12 symmetry).
func connect_settings_watch() -> void:
var lsp := resolve_lsp_endpoint()
_last_lsp_host = lsp["host"]
_last_lsp_port = lsp["port"]
var es := EditorInterface.get_editor_settings()
if es != null and es.has_signal("settings_changed") \
and not es.settings_changed.is_connected(_on_editor_settings_changed):
es.settings_changed.connect(_on_editor_settings_changed)
func disconnect_settings_watch() -> void:
var es := EditorInterface.get_editor_settings()
if es != null and es.has_signal("settings_changed") \
and es.settings_changed.is_connected(_on_editor_settings_changed):
es.settings_changed.disconnect(_on_editor_settings_changed)
func _on_editor_settings_changed() -> void:
var bound_port: int = _bound_port_provider.call() if _bound_port_provider.is_valid() else -1
if bound_port <= 0:
return
var lsp := resolve_lsp_endpoint()
if lsp["host"] == _last_lsp_host and lsp["port"] == _last_lsp_port:
return # LSP endpoint unchanged — ignore unrelated editor-setting churn.
_last_lsp_host = lsp["host"]
_last_lsp_port = lsp["port"]
RegistryClient.ensure_registered(bound_port, MCPAuth.get_published_token_path(), lsp["host"], lsp["port"])
print("[MCPServer] LSP endpoint changed -> re-published %s:%d" % [lsp["host"], lsp["port"]])