fix(network): prevent null multiplayer peer crashes offline/teardown and fix Nakama RPC session validation

This commit is contained in:
2026-07-10 17:36:26 +08:00
parent 2b4c9d9dcb
commit b30709de3d
84 changed files with 5291 additions and 982 deletions
+1 -1
View File
@@ -14,7 +14,7 @@ The plugin auto-starts the MCP server and connects over WebSocket. No manual con
## Requirements
- Godot 4.3+ (4.4+ recommended)
- Godot 4.5+ (4.7+ recommended)
- [uv](https://docs.astral.sh/uv/) (used to install the Python server)
<details>
<summary>Install uv</summary>
+53 -19
View File
@@ -24,6 +24,7 @@ const CliStrategy := preload("res://addons/godot_ai/clients/_cli_strategy.gd")
const ManualCommand := preload("res://addons/godot_ai/clients/_manual_command.gd")
const CliFinder := preload("res://addons/godot_ai/clients/_cli_finder.gd")
const WindowsPortReservation := preload("res://addons/godot_ai/utils/windows_port_reservation.gd")
const PortResolver := preload("res://addons/godot_ai/utils/port_resolver.gd")
const SERVER_NAME := "godot-ai"
@@ -37,8 +38,14 @@ const DEFAULT_WS_PORT := 9500
const STARTUP_TRACE_ENV := "GODOT_AI_STARTUP_TRACE"
const MIN_PORT := 1024
const MAX_PORT := 65535
## Cap on `can_bind_local_port` probes per `suggest_free_port` call so a
## pathological run of occupied ports can't stall the (cold-path) caller.
## 64 localhost binds are sub-millisecond; finding a free port realistically
## takes one or two probes, so this only bounds the worst case.
const SUGGEST_PORT_MAX_PROBES := 64
const SETTING_WS_PORT := "godot_ai/ws_port"
const SETTING_STARTUP_TRACE := "godot_ai/log_startup_timing"
const _DISCOVERY_TIMEOUT_MS := 3000
## Active HTTP port: user override (if in range) or `DEFAULT_HTTP_PORT`.
@@ -131,15 +138,39 @@ static func excluded_domains() -> String:
return ",".join(parts)
## Clamp `start` into the legal port range, then walk
## `candidate`..`candidate+span-1` and return the first port that is NOT
## currently excluded by Windows' winnat reservation table. Falls back to the
## clamped candidate if nothing clears (caller can apply anyway — user may
## just retry). On non-Windows this is a no-op: all ports pass, returns the
## clamped candidate.
## Suggest a port the caller can actually switch to. Walks
## `candidate`..`candidate+span-1` and returns the first port that is both
## (a) NOT inside a Windows winnat reservation range (Hyper-V / WSL2 / Docker
## grab these; bind fails with WinError 10013 and netstat shows nothing) and
## (b) actually bindable right now on 127.0.0.1. The bind probe is what makes
## "free" honest on macOS/Linux, where the reservation table is empty but the
## next port up may still be occupied — the same suggestion feeds the dock
## crash body, the port-picker spinbox, and the non-recoverable INCOMPATIBLE
## log line. Falls back to the clamped candidate if nothing in the window
## clears both checks (caller surfaces it as a best-effort hint; the user can
## retry or pick another). Best-effort by nature: a TOCTOU window remains
## between the probe and the caller actually binding the port. The bind probe
## is bounded to `SUGGEST_PORT_MAX_PROBES` attempts so this cold path can't
## stall on a pathological run of occupied ports.
static func suggest_free_port(start: int, span: int = 2048) -> int:
var candidate := clampi(start, MIN_PORT, MAX_PORT - span + 1)
return WindowsPortReservation.suggest_non_excluded_port(candidate, span, MAX_PORT)
var limit := mini(candidate + span - 1, MAX_PORT)
var p := candidate
var probes := 0
while p <= limit and probes < SUGGEST_PORT_MAX_PROBES:
## Jump past a whole Windows-reserved range in one step (no-op on
## POSIX: returns `p` unchanged), so we don't probe port-by-port
## through the large adjacent ranges those services reserve. The
## jump itself runs no bind probes, so it doesn't count against the cap.
var not_reserved := WindowsPortReservation.suggest_non_excluded_port(p, limit - p + 1, MAX_PORT)
if not_reserved < p or not_reserved > limit:
break
p = not_reserved
probes += 1
if PortResolver.can_bind_local_port(p):
return p
p += 1
return candidate
# --- Client operations (string id) ---------------------------------------
@@ -509,8 +540,8 @@ static func invalidate_uvx_cli_cache() -> void:
## Thread safety: `CliFinder.invalidate()` guards `_cache` / `_searched`
## with a mutex so it can race safely against worker threads calling
## `find()` from `_run_client_action_worker`. The mutex is held only
## across the dictionary clear, never across `OS.execute`, so this call
## can never block the main thread on a subprocess.
## across the dictionary clear, never across the bounded subprocess lookup,
## so this call can never block the main thread on a subprocess.
static func invalidate_cli_cache() -> void:
CliFinder.invalidate()
@@ -521,10 +552,9 @@ static var _uv_version_searched: bool = false
## Cached for the editor session. The dock's `_refresh_setup_status`
## (called via `call_deferred` from `_build_ui`) calls this on the
## main thread in user mode, so a single cold `OS.execute(uvx,
## ["--version"])` adds ~80 ms to the dock's first paint on Linux and
## more on Windows. Subsequent calls (focus-in refresh, manual Refresh
## clicks) reuse the cached string.
## main thread in user mode, so the cold `uvx --version` probe is
## wall-clock bounded and cached. Subsequent calls (focus-in refresh,
## manual Refresh clicks) reuse the cached string.
##
## Invalidate via `invalidate_uv_version_cache()` when the user
## installs / reinstalls uv via the dock so the next refresh reflects
@@ -539,9 +569,10 @@ static func check_uv_version() -> String:
_uv_version_searched = true
_uv_version_cache = ""
return ""
var output: Array = []
if OS.execute(uvx, ["--version"], output, true) == 0 and output.size() > 0:
_uv_version_cache = output[0].strip_edges()
var result := McpCliExec.run(uvx, ["--version"], _DISCOVERY_TIMEOUT_MS, false)
if int(result.get("exit_code", -1)) == 0:
var lines := PackedStringArray(str(result.get("stdout", "")).split("\n"))
_uv_version_cache = lines[0].strip_edges() if lines.size() > 0 else ""
else:
_uv_version_cache = ""
_uv_version_searched = true
@@ -612,9 +643,12 @@ static func find_worktree_src_dir(start_dir: String) -> String:
static func _find_system_install() -> String:
var cmd := "which" if OS.get_name() != "Windows" else "where"
var output: Array = []
if OS.execute(cmd, ["godot-ai"], output, true) == 0 and output.size() > 0:
var found: String = output[0].strip_edges()
var result := McpCliExec.run(cmd, ["godot-ai"], _DISCOVERY_TIMEOUT_MS, false)
if int(result.get("exit_code", -1)) == 0:
var lines := PackedStringArray(str(result.get("stdout", "")).split("\n"))
if lines.is_empty():
return ""
var found := CliFinder._pick_best_path(lines) if OS.get_name() == "Windows" else lines[0].strip_edges()
if not found.is_empty():
return found
return ""
+34 -8
View File
@@ -34,6 +34,7 @@ extends RefCounted
const DEFAULT_TIMEOUT_MS := 8000
const _POLL_INTERVAL_MS := 50
const _KILL_GRACE_MS := 500
static func run(
@@ -44,6 +45,15 @@ static func run(
) -> Dictionary:
if exe.is_empty():
return _spawn_failed_result()
return _run_piped(exe, args, timeout_ms, capture_stderr)
static func _run_piped(
exe: String,
args: Array,
timeout_ms: int,
capture_stderr: bool,
) -> Dictionary:
var spawn_exe := exe
var spawn_args := args
@@ -76,12 +86,18 @@ static func run(
var deadline := Time.get_ticks_msec() + maxi(timeout_ms, _POLL_INTERVAL_MS)
while OS.is_process_running(pid):
if Time.get_ticks_msec() >= deadline:
## Read whatever made it to the pipes before we kill the
## process — partial output beats blank "timed out" when the
## CLI was emitting useful diagnostics on its way to hanging.
var partial_stdout := _drain_pipe(stdio)
var partial_stderr := _drain_pipe(stderr_pipe) if capture_stderr else ""
## Kill before draining: a pipe read can block while the child is
## still alive. Once it exits, drain any buffered partial output.
OS.kill(pid)
var kill_deadline := Time.get_ticks_msec() + _KILL_GRACE_MS
while OS.is_process_running(pid) and Time.get_ticks_msec() < kill_deadline:
OS.delay_msec(_POLL_INTERVAL_MS)
var partial_stdout := ""
var partial_stderr := ""
if not OS.is_process_running(pid):
partial_stdout = _drain_pipe(stdio)
partial_stderr = _drain_pipe(stderr_pipe) if capture_stderr else ""
_close_pipes(stdio, stderr_pipe)
return {
"exit_code": -1,
@@ -119,9 +135,19 @@ static func _spawn_failed_result() -> Dictionary:
static func _drain_pipe(pipe: Variant) -> String:
if pipe is FileAccess:
return (pipe as FileAccess).get_as_text()
return ""
if not (pipe is FileAccess):
return ""
var f := pipe as FileAccess
var bytes := PackedByteArray()
var max_bytes := 1 << 20 # 1 MiB, far above expected client CLI output.
while bytes.size() < max_bytes:
var chunk := f.get_buffer(mini(4096, max_bytes - bytes.size()))
if chunk.is_empty():
break
bytes.append_array(chunk)
if f.eof_reached():
break
return bytes.get_string_from_utf8()
static func _join_streams(stdout: String, stderr_text: String) -> String:
+12 -11
View File
@@ -14,7 +14,7 @@ extends RefCounted
## the main thread (manual Refresh path). Godot `Dictionary` is not safe for
## concurrent mutation, so `_cache` / `_searched` access is guarded by
## `_mutex`. The mutex is held only across dictionary read/write — the slow
## `_resolve()` path (FileAccess + `OS.execute`) runs unlocked, so a
## `_resolve()` path (FileAccess + bounded subprocess lookup) runs unlocked, so a
## main-thread `invalidate()` can never block on a worker's subprocess.
## Two workers racing the same exe both call `_resolve()` and both write
## back the same answer; that's wasted work, not corruption.
@@ -24,6 +24,8 @@ static var _mutex: Mutex = Mutex.new()
static var _cache: Dictionary = {} # exe_name -> resolved path (or "")
static var _searched: Dictionary = {}
const _LOOKUP_TIMEOUT_MS := 3000
## Find any of the supplied exe names; returns the first hit.
## On Windows pass the .exe variant in `exe_names` if relevant.
@@ -54,8 +56,8 @@ static func _find_one(exe_name: String) -> String:
_mutex.unlock()
if already_searched:
return cached
# `_resolve()` does FileAccess + `OS.execute` (forks `bash -lc` /
# `which`), which can take 100ms-1s. Holding the mutex across that
# `_resolve()` does FileAccess + bounded subprocess lookup (forks
# `bash -lc` / `which`), which can take 100ms-1s. Holding the mutex across that
# would let a concurrent `invalidate()` on the main thread freeze the
# editor for the duration of the subprocess — which defeats the whole
# point of running CLI lookup off the main thread.
@@ -81,20 +83,19 @@ static func _resolve(exe_name: String) -> String:
var shell := OS.get_environment("SHELL")
if shell.is_empty():
shell = "/bin/bash"
var login_output: Array = []
var stripped := exe_name.trim_suffix(".exe")
var login_exit := OS.execute(shell, ["-lc", "command -v %s" % stripped], login_output, true)
if login_exit == 0 and login_output.size() > 0:
var login_found: String = login_output[0].strip_edges()
var login_result := McpCliExec.run(shell, ["-lc", "command -v %s" % stripped], _LOOKUP_TIMEOUT_MS, false)
if int(login_result.get("exit_code", -1)) == 0:
var login_found: String = str(login_result.get("stdout", "")).strip_edges()
if not login_found.is_empty() and FileAccess.file_exists(login_found):
return login_found
# 3. which / where with inherited PATH
var lookup := "where" if is_windows else "which"
var output: Array = []
var exit_code := OS.execute(lookup, [exe_name], output, true)
if exit_code == 0 and output.size() > 0:
var lines := PackedStringArray(output[0].split("\n"))
var result := McpCliExec.run(lookup, [exe_name], _LOOKUP_TIMEOUT_MS, false)
if int(result.get("exit_code", -1)) == 0:
var output := str(result.get("stdout", ""))
var lines := PackedStringArray(output.split("\n"))
var found := _pick_best_path(lines) if is_windows else lines[0].strip_edges()
if not found.is_empty():
return found
+10 -3
View File
@@ -7,13 +7,20 @@ func _init() -> void:
display_name = "Antigravity"
config_type = "json"
doc_url = "https://www.antigravity.dev/"
## Antigravity moved its shared MCP config from `~/.gemini/antigravity/`
## to `~/.gemini/config/` (IDE + CLI now read the same file there); the
## old path is left in `detect_paths` below so an existing install is
## still recognized, but new/updated entries write to the current path.
path_template = {
"unix": "~/.gemini/antigravity/mcp_config.json",
"windows": "$USERPROFILE/.gemini/antigravity/mcp_config.json",
"unix": "~/.gemini/config/mcp_config.json",
"windows": "$USERPROFILE/.gemini/config/mcp_config.json",
}
server_key_path = PackedStringArray(["mcpServers"])
entry_url_field = "serverUrl"
## `disabled` is user-state (they may have flipped the entry off in the
## UI); seeded on first Configure but preserved across reconfigure.
entry_initial_fields = {"disabled": false}
detect_paths = PackedStringArray(path_template.values())
detect_paths = PackedStringArray(path_template.values() + [
"~/.gemini/antigravity/mcp_config.json",
"$USERPROFILE/.gemini/antigravity/mcp_config.json",
])
+38 -1
View File
@@ -48,6 +48,12 @@ var server_version := ""
var dispatcher
var log_buffer
var surfaced_error_tracker
## Set by plugin.gd. Lets the per-frame play-state poll end game-run
## bookkeeping when the game exits on its own (self-quit, crash) — the
## debugger session's stopped signal is not reliably connected, and no MCP
## stop op runs in that path (#642).
var debugger_plugin
## Set by plugin.gd when the HTTP port is occupied by an incompatible or
## unverified server. Keeping the Connection node alive lets handlers and the
## dock share one object, but no WebSocket is opened to the wrong server.
@@ -89,6 +95,10 @@ func _process(delta: float) -> void:
if pause_processing:
return
_peer.poll()
## Run-stop bookkeeping must not wait behind the socket-state machine:
## if the game stops while disconnected, the first command drained on
## reconnect would still observe stale "live" state (PR #642 review).
_check_game_run_play_state(EditorInterface.is_playing_scene())
match _peer.get_ready_state():
WebSocketPeer.STATE_OPEN:
@@ -309,6 +319,8 @@ func send_deferred_response(request_id: String, payload: Dictionary) -> void:
## `readiness_after` payload field were ever dropped.
if not response.has("readiness"):
response["readiness"] = get_readiness()
if not response.has("error_watermark"):
_stamp_error_watermark(response)
if _send_json(response) and dispatcher != null:
dispatcher.complete_deferred_response(request_id)
@@ -319,10 +331,15 @@ func _hook_editor_signals() -> void:
EditorInterface.get_editor_settings() # ensure interface is ready
_last_scene_path = _get_current_scene_path()
_last_play_state = EditorInterface.is_playing_scene()
_last_play_state_for_run = _last_play_state
var _last_scene_path := ""
var _last_play_state := false
## Separate edge tracker for game-run bookkeeping: _last_play_state only
## advances when the play_state_changed event sends successfully, but ending
## run tracking must not depend on the websocket being up.
var _last_play_state_for_run := false
var _last_readiness := ""
@@ -359,7 +376,22 @@ func _check_state_changes() -> void:
if send_event("readiness_changed", {"readiness": readiness}):
_last_readiness = readiness
if log_buffer:
log_buffer.log("[event] readiness -> %s" % readiness)
## echo=false: readiness flips on every filesystem scan
## (each import cycles importing -> ready), so echoing to
## console spams every install during normal editing (#626).
## The line stays in the ring for the dock's log panel.
log_buffer.log("[event] readiness -> %s" % readiness, false)
## Playing→stopped edge for game-run bookkeeping. Runs every process tick
## (any socket state) so a self-quit game's run ends even while the
## transport is down or reconnecting.
func _check_game_run_play_state(playing: bool) -> void:
if playing == _last_play_state_for_run:
return
if not playing and debugger_plugin != null:
debugger_plugin.note_editor_play_stopped()
_last_play_state_for_run = playing
func _get_current_scene_path() -> String:
@@ -403,6 +435,7 @@ func _handle_outbound_backpressure(
return false
var err_response := _make_backpressure_error(request_id, buffered_bytes, message_bytes)
_stamp_error_watermark(err_response)
var err_text := JSON.stringify(err_response)
var err_bytes := err_text.to_utf8_buffer().size()
if _would_exceed_outbound_backpressure(buffered_bytes, err_bytes):
@@ -457,6 +490,10 @@ static func _make_backpressure_error(
}
func _stamp_error_watermark(response: Dictionary) -> void:
McpSurfacedErrorTracker.stamp_watermark(response, surfaced_error_tracker)
## Build a human-readable session ID of form "<slug>@<4hex>" from the project path.
## The slug is derived from the project directory name so agents can recognize
## which editor they're targeting; the hex suffix disambiguates same-project twins.
+565 -27
View File
@@ -64,6 +64,8 @@ const EVAL_PROBE_INTERVAL_SEC := 0.35
var _log_buffer: McpLogBuffer
var _game_log_buffer: McpGameLogBuffer
var _editor_log_buffer: McpEditorLogBuffer
var _surfaced_error_tracker
## Pending request_id -> {connection, timer, timeout_callable}.
## We retain the bound timeout lambda so `_clear_pending` can disconnect
@@ -80,12 +82,41 @@ var _game_run_token := 0
var _ready_run_token := -1
var _game_session_id := -1
var _game_run_active := false
var _manual_run_armed := false
var _game_run_started_msec := 0
var _game_run_started_editor_cursor := 0
var _game_run_started_debugger_cursor := 0
var _game_helper_expected := true
## #645: a GDScript parse error hit while an editor-launched game boots calls
## GDScriptLanguage::debug_break_parse — the game parks in a remote-debugger
## break BEFORE the helper's mcp:hello and before any record reaches the
## Errors tab, the editor Logger, or the game log. The only editor-side traces
## are the debugger break signals; the stack frames land in the Stack Trace
## panel a few frames later. Track the break here so game_status can report
## status="break" and a synthesized error record can name the failure.
var _break_active := false
var _break_can_debug := false
var _break_reason := ""
var _break_pre_live := false
var _break_run_token := -1
var _break_record_synthesized := false
## #645: how long after the break signal to scrape the Stack Trace panel for
## frames. The editor requests the stack dump from the game separately, so the
## panel is empty at signal time; ~0.5s has it populated. The late tick
## synthesizes with whatever is available so a scrape failure still yields a
## record carrying the break reason.
const BREAK_FRAME_SCRAPE_DELAYS_SEC: Array[float] = [0.5, 2.0]
signal game_ready
func _init(log_buffer: McpLogBuffer = null, game_log_buffer: McpGameLogBuffer = null) -> void:
func _init(log_buffer: McpLogBuffer = null, game_log_buffer: McpGameLogBuffer = null, editor_log_buffer: McpEditorLogBuffer = null, surfaced_error_tracker = null) -> void:
_log_buffer = log_buffer
_game_log_buffer = game_log_buffer
_editor_log_buffer = editor_log_buffer
_surfaced_error_tracker = surfaced_error_tracker
func _has_capture(prefix: String) -> bool:
@@ -102,32 +133,512 @@ func _has_capture(prefix: String) -> bool:
## plugin.gd's _enter_tree logs "plugin loaded", and ci-reload-test
## asserts "plugin loaded" is the first line after a plugin reload.
func _setup_session(session_id: int) -> void:
_game_ready = false
_ready_run_token = -1
_connect_session_stopped(session_id)
_connect_session_break_signals(session_id)
if EditorInterface.is_playing_scene() and not _game_run_active:
_begin_game_run_tracking(_editor_log_cursor(), true, true, true, true, true)
else:
_game_ready = false
_ready_run_token = -1
_game_session_id = session_id
func begin_game_run() -> void:
func begin_game_run(editor_log_cursor: int = 0, helper_expected: bool = true) -> void:
_begin_game_run_tracking(editor_log_cursor, helper_expected, true, true)
func _begin_game_run_tracking(
editor_log_cursor: int = 0,
helper_expected: bool = true,
rotate_game_log: bool = true,
sticky_debugger_scan: bool = true,
quiet: bool = false,
manual_armed: bool = false,
) -> void:
_game_run_token += 1
_game_run_active = true
_manual_run_armed = manual_armed
_game_ready = false
_ready_run_token = -1
_game_session_id = -1
if _log_buffer:
_log_buffer.log("[debug] game capture pending run token %d" % _game_run_token)
clear_debug_break()
_game_run_started_msec = Time.get_ticks_msec()
_game_run_started_editor_cursor = maxi(0, editor_log_cursor)
if _surfaced_error_tracker != null:
_surfaced_error_tracker.note_game_run_started(sticky_debugger_scan)
_game_run_started_debugger_cursor = _surfaced_error_tracker.debugger_promoted_total()
else:
_game_run_started_debugger_cursor = 0
_game_helper_expected = helper_expected
var run_id := ""
if _game_log_buffer and rotate_game_log:
run_id = _game_log_buffer.clear_for_new_run()
if _log_buffer and not quiet:
var log_text := "[debug] game capture pending run token %d" % _game_run_token
if not run_id.is_empty():
log_text += " (run %s)" % run_id
_log_buffer.log(log_text)
func _editor_log_cursor() -> int:
return _editor_log_buffer.appended_total() if _editor_log_buffer != null else 0
func end_game_run() -> void:
_game_run_active = false
_manual_run_armed = false
_game_ready = false
_ready_run_token = -1
_game_session_id = -1
clear_debug_break()
if _surfaced_error_tracker != null:
_surfaced_error_tracker.note_game_run_stopped()
## Authoritative fallback for runs whose debugger `stopped` signal never
## fired or was never connected: the editor's play state falling to stopped
## means the game process is gone. A game that exits on its own
## (get_tree().quit(), crash) has no MCP stop op to run the bookkeeping, and
## without this game_status stayed "live" until the next run (#642 smoke).
## Called on the playing→stopped edge only, so the pre-play launch window
## (run tracking begun, is_playing_scene() not yet true) is never clipped.
func note_editor_play_stopped() -> void:
if not _game_run_active:
return
end_game_run()
func _connect_session_stopped(session_id: int) -> void:
var session = get_session(session_id)
if session == null:
return
var stopped := Callable(self, "_on_debugger_session_stopped").bind(session_id)
if not session.stopped.is_connected(stopped):
session.stopped.connect(stopped)
func _on_debugger_session_stopped(session_id: int) -> void:
if _game_session_id != -1 and session_id != _game_session_id:
return
## MCP-started runs normally end via project_manage(op="stop"), but a game
## that exits on its own (get_tree().quit(), crash) emits only this signal.
## Without ending the run here, game_status stays "live" until the next
## run's bookkeeping rewrites it (#642 live smoke). Before the game session
## attaches (_game_session_id == -1) only manual runs may end on this
## signal — a foreign session's stop must not cancel a launching MCP run.
if not _manual_run_armed and _game_session_id == -1:
return
end_game_run()
## --- #645: boot-time debugger breaks ---------------------------------------
func _connect_session_break_signals(session_id: int) -> void:
var session = get_session(session_id)
if session != null:
var breaked_cb := Callable(self, "_on_debugger_session_breaked").bind(session_id)
if not session.breaked.is_connected(breaked_cb):
session.breaked.connect(breaked_cb)
var continued_cb := Callable(self, "_on_debugger_session_continued").bind(session_id)
if not session.continued.is_connected(continued_cb):
session.continued.connect(continued_cb)
_connect_script_debugger_breaked()
## The session-level `breaked` signal carries only can_debug; the underlying
## ScriptEditorDebugger's own `breaked` also carries the human-readable break
## reason ("Parser Error: ..."), which is otherwise visible only in the
## Debugger UI. EditorDebuggerSession does not expose its debugger node, so
## locate ScriptEditorDebugger instances by walking the editor UI — the same
## approach as the tracker's Errors-tab scrape.
func _connect_script_debugger_breaked() -> void:
var base := EditorInterface.get_base_control()
if base == null:
return
var debuggers: Array[Node] = []
_collect_nodes_of_class(base, "ScriptEditorDebugger", debuggers)
for dbg in debuggers:
if not dbg.has_signal("breaked"):
continue
var cb := Callable(self, "_on_script_debugger_breaked")
if not dbg.is_connected("breaked", cb):
dbg.connect("breaked", cb)
static func _collect_nodes_of_class(node: Node, klass: String, out: Array[Node]) -> void:
if node.get_class() == klass:
out.append(node)
for child in node.get_children():
_collect_nodes_of_class(child, klass, out)
func _on_debugger_session_breaked(can_debug: bool, session_id: int) -> void:
if _game_session_id != -1 and session_id != _game_session_id:
return
note_debug_break(can_debug, "")
func _on_debugger_session_continued(session_id: int) -> void:
if _game_session_id != -1 and session_id != _game_session_id:
return
clear_debug_break()
## reallydid=false is Godot's "left the break" notification (debug_exit).
func _on_script_debugger_breaked(reallydid: bool, can_debug: bool, reason: String, _has_stackdump: bool) -> void:
if not reallydid:
clear_debug_break()
return
note_debug_break(can_debug, reason)
## Record that the game process is parked in a remote-debugger break. Fires
## once per break from the session signal (no reason text) and again moments
## later from the ScriptEditorDebugger signal (with reason) — the notices
## merge into one break. Public so tests can drive break state directly.
func note_debug_break(can_debug: bool, reason: String) -> void:
var first_notice := not _break_active
_break_active = true
_break_can_debug = can_debug
if not reason.is_empty():
_break_reason = reason
if not first_notice:
return
_break_run_token = _game_run_token
_break_pre_live = _game_run_active and not is_game_capture_ready()
_break_record_synthesized = false
if _log_buffer:
_log_buffer.log("[debug] debugger break (pre_live=%s can_debug=%s)" % [str(_break_pre_live), str(can_debug)])
if _break_pre_live:
_schedule_break_record_synthesis()
func clear_debug_break() -> void:
_break_active = false
_break_can_debug = false
_break_reason = ""
_break_pre_live = false
_break_run_token = -1
_break_record_synthesized = false
## Stack frames land in the Stack Trace panel a few frames after the break
## signal (the editor requests the stack dump separately), so the record is
## synthesized on short timers rather than at signal time.
func _schedule_break_record_synthesis() -> void:
var tree := Engine.get_main_loop() as SceneTree
if tree == null:
return
var token := _break_run_token
for i in BREAK_FRAME_SCRAPE_DELAYS_SEC.size():
var final := i == BREAK_FRAME_SCRAPE_DELAYS_SEC.size() - 1
var timer := tree.create_timer(BREAK_FRAME_SCRAPE_DELAYS_SEC[i])
timer.timeout.connect(func() -> void: _on_break_scrape_tick(token, final))
func _on_break_scrape_tick(run_token: int, final: bool) -> void:
if not _break_active or _break_record_synthesized or run_token != _break_run_token:
return
var frames := _scrape_break_stack_frames()
if frames.is_empty() and not final:
return
synthesize_break_error_record(frames)
## Read the debugger's Stack Trace panel rows. Row metadata is a Dictionary
## {frame, file, function, line} regardless of editor locale, so stack trees
## are identified by metadata shape rather than the translated column title.
func _scrape_break_stack_frames() -> Array[Dictionary]:
var base := EditorInterface.get_base_control()
if base == null:
return []
var debuggers: Array[Node] = []
_collect_nodes_of_class(base, "ScriptEditorDebugger", debuggers)
for dbg in debuggers:
var trees: Array[Node] = []
_collect_nodes_of_class(dbg, "Tree", trees)
for t in trees:
var frames := _frames_from_stack_tree(t as Tree)
if not frames.is_empty():
return frames
return []
static func _frames_from_stack_tree(tree: Tree) -> Array[Dictionary]:
var frames: Array[Dictionary] = []
var root := tree.get_root()
if root == null:
return frames
var item := root.get_first_child()
while item != null:
var meta = item.get_metadata(0)
if not (meta is Dictionary and meta.has("file") and meta.has("frame")):
return []
frames.append({
"path": str(meta.get("file", "")),
"line": int(meta.get("line", 0)),
"function": str(meta.get("function", "")),
})
item = item.get_next()
return frames
## Build the Errors-tab-shaped record for a boot-time break and promote it via
## the tracker so recent_editor_errors_since / logs_read / the watermark all
## surface it — the break itself produces no record anywhere else (#645).
## Public so tests can synthesize without waiting on scrape timers.
func synthesize_break_error_record(frames: Array[Dictionary]) -> void:
if _break_record_synthesized:
return
_break_record_synthesized = true
if _surfaced_error_tracker == null:
return
var reason := _break_reason
if reason.is_empty():
reason = "Game process broke into the debugger during startup (script parse/load error; reason not captured)"
var top: Dictionary = frames[0] if not frames.is_empty() else {}
var location := {
"path": str(top.get("path", "")),
"line": int(top.get("line", 0)),
"function": str(top.get("function", "")),
}
var entry := {
"source": "editor",
"level": "error",
"text": reason,
"path": location["path"],
"line": location["line"],
"function": location["function"],
"details": {
"debugger_tab": "Stack Trace",
"message": reason,
"error_type_name": "debugger_break",
"source": location.duplicate(true),
"resolved": location.duplicate(true),
"frames": frames.duplicate(true),
},
}
_surfaced_error_tracker.record_synthetic_error(entry)
if _log_buffer:
_log_buffer.log("[debug] synthesized boot-break error record: %s" % McpSurfacedErrorTracker.format_editor_error_summary(entry))
## --- end #645 ---------------------------------------------------------------
func is_game_capture_ready() -> bool:
return _game_run_active and _game_ready and _ready_run_token == _game_run_token
static func with_liveness_flags(status: Dictionary) -> Dictionary:
var enriched := status.duplicate(true)
var state := str(enriched.get("status", "stopped"))
enriched["helper_live"] = state == "live"
enriched["session_active"] = not state in ["not_live", "stopped"]
return enriched
func get_game_status(now_msec: int = -1, ready_wait_sec: float = GAME_READY_WAIT_SEC) -> Dictionary:
var resolved_now := Time.get_ticks_msec() if now_msec < 0 else now_msec
var ready_wait_msec := maxi(0, int(ready_wait_sec * 1000.0))
var elapsed_msec := maxi(0, resolved_now - _game_run_started_msec) if _game_run_active else 0
## "stopped" also covers idle/never-ran; no game run is currently active.
var status := "stopped"
if _game_run_active:
## #645: a parked process takes precedence over "live" — a game frozen
## in a remote-debugger break cannot service game-side tools even when
## its helper registered before the break.
if _break_active:
status = "break"
elif is_game_capture_ready():
status = "live"
elif not _game_helper_expected:
status = "no_helper"
elif elapsed_msec >= ready_wait_msec:
status = "not_live"
else:
status = "launching"
var out := {
"status": status,
"run_token": _game_run_token,
"active": _game_run_active,
"ready": is_game_capture_ready(),
"helper_expected": _game_helper_expected,
"run_started_msec": _game_run_started_msec,
"elapsed_msec": elapsed_msec,
"ready_wait_msec": ready_wait_msec,
"editor_log_cursor": _game_run_started_editor_cursor,
}
if status == "break":
out["break"] = {
"reason": _break_reason,
"can_debug": _break_can_debug,
"pre_live": _break_pre_live,
}
return with_liveness_flags(out)
func _explain_not_live(status: Dictionary, code: String = ErrorCodes.INTERNAL_ERROR) -> Dictionary:
var state := str(status.get("status", "stopped"))
var errors_info := recent_editor_errors_since(int(status.get("editor_log_cursor", 0)))
var recent_errors: Array = errors_info.get("errors", [])
var recent_errors_scope := str(errors_info.get("scope", "none"))
var truncated := bool(errors_info.get("truncated", false))
var data := {
"game_status": status.duplicate(true),
"recent_errors": recent_errors,
"recent_errors_scope": recent_errors_scope,
"recent_errors_may_predate_run": recent_errors_scope == "retained_recent",
"recent_errors_truncated": truncated,
}
data.merge(split_errors_by_scope(recent_errors, recent_errors_scope), true)
var message := ""
match state:
"not_live":
if not recent_errors.is_empty() and recent_errors_scope == "run":
message = "The game failed to load or crashed before the Godot AI game helper registered: %s. Check logs_read(source='editor', include_details=true)." % _format_editor_error_summary(recent_errors[0])
if truncated:
message += " Editor logs since this run may be truncated; showing retained errors."
elif not recent_errors.is_empty():
message = "The game is not responding and reported no load errors during this run. A recent editor error may be related, but may predate this run: %s. Check logs_read(source='editor', include_details=true)." % _format_editor_error_summary(recent_errors[0])
else:
message = "The game is not responding and reported no load errors before the helper-ready window elapsed. It may still be booting or may have failed silently; check logs_read(source='editor', include_details=true) and retry."
"break":
var break_info: Dictionary = status.get("break", {})
var break_reason := str(break_info.get("reason", ""))
var reason_suffix := (": %s" % break_reason) if not break_reason.is_empty() else ""
if bool(break_info.get("pre_live", true)):
message = "The game hit a script error during startup and is frozen at a debugger break%s. It cannot become live; call project_manage(op='stop') to end the run, fix the error, and relaunch. Check logs_read(source='editor', include_details=true)." % reason_suffix
else:
message = "The game is paused at a debugger break%s. Resume it from the editor's Debugger panel or call project_manage(op='stop')." % reason_suffix
"no_helper":
message = "The running game has no _mcp_game_helper autoload, so game-side tools cannot connect. If this is a headless or custom-main-loop project, use editor_screenshot(source='viewport') where applicable. Otherwise, re-enable the plugin and relaunch the game."
"launching":
message = "The game is still starting (%.1fs elapsed); the Godot AI game helper has not registered yet. Retry shortly." % (float(status.get("elapsed_msec", 0)) / 1000.0)
"stopped":
message = "The game is not running. Start the project and retry the game-side tool."
_:
message = "The game-side tool could not confirm the game is live (status=%s). Check logs_read(source='editor', include_details=true) and retry." % state
var err := ErrorCodes.make(code, message)
var inner: Dictionary = err.get("error", {})
inner["data"] = data
err["error"] = inner
return err
static func split_errors_by_scope(recent_errors: Array, scope: String) -> Dictionary:
var current_run_errors: Array = []
var retained_errors: Array = []
if scope == "run":
current_run_errors = recent_errors
elif scope == "retained_recent":
retained_errors = recent_errors
return {
"current_run_errors": current_run_errors,
"retained_errors": retained_errors,
}
## `force_debugger_scan` bypasses the tracker's scan gate for one read. Keep it
## false on per-frame polling paths (the run-liveness loop) — a forced scan
## walks the Debugger dock UI — and pass true only for one-shot reads that must
## see rows which landed after the last gated scan (#641).
func recent_editor_errors_since(cursor: int, force_debugger_scan: bool = false) -> Dictionary:
return _recent_editor_errors_since(cursor, force_debugger_scan)
func _recent_editor_errors_since(cursor: int, force_debugger_scan: bool = false) -> Dictionary:
var out: Array[Dictionary] = []
var truncated := false
if _surfaced_error_tracker != null:
var captured_by_tracker: Dictionary = _surfaced_error_tracker.editor_entries_since(
maxi(0, cursor),
_game_run_started_debugger_cursor,
force_debugger_scan,
)
truncated = bool(captured_by_tracker.get("truncated", false))
for raw_entry in captured_by_tracker.get("entries", []):
var compact := _compact_editor_error(raw_entry)
if compact.is_empty():
continue
out.append(compact)
if out.size() >= 5:
break
if not out.is_empty():
return {"errors": out, "truncated": truncated, "scope": "run"}
for raw_entry in _surfaced_error_tracker.retained_recent_editor_entries():
var compact := _compact_editor_error(raw_entry, true)
if compact.is_empty():
continue
out.append(compact)
if out.size() >= 5:
break
if not out.is_empty():
return {"errors": out, "truncated": false, "scope": "retained_recent"}
return {"errors": out, "truncated": false, "scope": "none"}
if _editor_log_buffer == null:
return {"errors": out, "truncated": false, "scope": "none"}
var captured: Dictionary = _editor_log_buffer.get_since(maxi(0, cursor), -1)
truncated = bool(captured.get("truncated", false))
for raw_entry in captured.get("entries", []):
var compact := _compact_editor_error(raw_entry)
if compact.is_empty():
continue
out.append(compact)
if out.size() >= 5:
break
if not out.is_empty():
return {"errors": out, "truncated": truncated, "scope": "run"}
for raw_entry in _reversed_entries(_editor_log_buffer.get_recent(McpEditorLogBuffer.MAX_LINES)):
var compact := _compact_editor_error(raw_entry, true)
if compact.is_empty():
continue
out.append(compact)
if out.size() >= 5:
break
if not out.is_empty():
return {"errors": out, "truncated": false, "scope": "retained_recent"}
return {"errors": out, "truncated": false, "scope": "none"}
func _compact_editor_error(raw_entry: Variant, fallback_recent: bool = false) -> Dictionary:
if not raw_entry is Dictionary:
return {}
var entry := raw_entry as Dictionary
if str(entry.get("level", "info")) != "error":
return {}
var path := str(entry.get("path", ""))
if fallback_recent and _is_diagnostic_noise_path(path):
return {}
var compact := {
"source": "editor",
"level": "error",
"text": str(entry.get("text", "")),
"path": path,
"line": int(entry.get("line", 0)),
"function": str(entry.get("function", "")),
}
if entry.has("details"):
compact["details"] = entry["details"].duplicate(true)
return compact
func _is_diagnostic_noise_path(path: String) -> bool:
return path.begins_with("res://addons/godot_ai/") or path.begins_with("res://tests/")
func _reversed_entries(entries: Array[Dictionary]) -> Array[Dictionary]:
var out: Array[Dictionary] = []
for i in range(entries.size() - 1, -1, -1):
out.append(entries[i])
return out
func _format_editor_error_summary(entry: Dictionary) -> String:
return McpSurfacedErrorTracker.format_editor_error_summary(entry)
func _capture(message: String, data: Array, session_id: int) -> bool:
## Godot passes the full "prefix:tail" string as `message`.
match message:
@@ -152,18 +663,22 @@ func _capture(message: String, data: Array, session_id: int) -> bool:
## Boot beacon from the game-side autoload. Tells us the
## game has registered its "mcp" capture and is safe to send
## take_screenshot to — before this, Godot's debugger would
## drop our message silently. Also marks a fresh play
## cycle: rotate the game-log buffer so each run starts
## clean and gets a new run_id.
## drop our message silently.
_game_ready = true
_ready_run_token = _game_run_token
game_ready.emit()
if _game_log_buffer:
var run_id := _game_log_buffer.clear_for_new_run()
if _log_buffer:
_log_buffer.log("[debug] <- mcp:hello from game_helper (run %s)" % run_id)
elif _log_buffer:
_log_buffer.log("[debug] <- mcp:hello from game_helper")
## #641: boot-time parse errors race the hello beacon — both ride
## the same debugger channel, and the editor inserts Errors-tab
## rows with a per-frame throttle, so rows can land moments after
## the run is declared live. Arm forced scans so those rows get
## promoted into the watermark even if no tool call follows.
if _surfaced_error_tracker != null:
_surfaced_error_tracker.schedule_deferred_scans()
if _log_buffer:
if _game_log_buffer:
_log_buffer.log("[debug] <- mcp:hello from game_helper (run %s)" % _game_log_buffer.run_id())
else:
_log_buffer.log("[debug] <- mcp:hello from game_helper")
return true
"mcp:eval_response":
_on_eval_response(data)
@@ -259,11 +774,17 @@ func _wait_then_send(
timeout_sec: float,
) -> void:
var deadline := Time.get_ticks_msec() + int(GAME_READY_WAIT_SEC * 1000.0)
while not is_game_capture_ready() and Time.get_ticks_msec() < deadline:
## #645: always yield at least one frame — the dispatcher registers the
## deferred request only after the handler returns DEFERRED_RESPONSE, so a
## same-frame error reply would be dropped as an expired request. The break
## check then bails out with the actionable break error instead of waiting
## out the full window (a game parked in a debugger break never beacons).
await tree.process_frame
while not is_game_capture_ready() and not _break_active and Time.get_ticks_msec() < deadline:
await tree.process_frame
if not is_game_capture_ready():
_send_error(connection, request_id, ErrorCodes.INTERNAL_ERROR,
"Game-side autoload never registered its debugger capture within %ds. Is the game actually running? Check Project Settings → Autoload for _mcp_game_helper." % int(GAME_READY_WAIT_SEC))
_send_error_response(connection, request_id,
_explain_not_live(get_game_status(-1, GAME_READY_WAIT_SEC), ErrorCodes.INTERNAL_ERROR))
return
_send_take_screenshot(tree, request_id, max_resolution, connection, timeout_sec)
@@ -357,16 +878,23 @@ func _on_timeout(request_id: String) -> void:
var connection: McpConnection = pending.connection
if connection == null or not is_instance_valid(connection):
return
_send_error(connection, request_id, ErrorCodes.INTERNAL_ERROR,
"Game screenshot timed out. The running game must include the _mcp_game_helper autoload (added automatically when the plugin is enabled — check Project Settings → Autoload). If the autoload is missing, re-enable the plugin and relaunch the game. For headless or custom-main-loop builds, use source='viewport' instead.")
var status := get_game_status(-1, GAME_READY_WAIT_SEC)
var err := ErrorCodes.make(ErrorCodes.INTERNAL_ERROR,
"Game screenshot timed out after reaching the game helper. The game may be busy or unable to render a frame. Check logs_read(source='game') and retry.")
if status.get("status", "") != "live":
err = _explain_not_live(status, ErrorCodes.INTERNAL_ERROR)
_send_error_response(connection, request_id, err)
if _log_buffer:
_log_buffer.log("[debug] !! screenshot timeout (%s)" % request_id)
func _send_error(connection: McpConnection, request_id: String, code: String, message: String) -> void:
_send_error_response(connection, request_id, ErrorCodes.make(code, message))
func _send_error_response(connection: McpConnection, request_id: String, err: Dictionary) -> void:
if connection == null or not is_instance_valid(connection):
return
var err := ErrorCodes.make(code, message)
connection.send_deferred_response(request_id, err)
@@ -440,15 +968,20 @@ func _wait_then_eval(
## the not-ready path returns its actionable error before the 15s server-side
## command timeout fires an opaque TimeoutError. See EVAL_READY_WAIT_SEC.
var deadline := Time.get_ticks_msec() + int(EVAL_READY_WAIT_SEC * 1000.0)
while not is_game_capture_ready() and Time.get_ticks_msec() < deadline:
## #645: the leading yield guarantees the dispatcher has registered the
## deferred request before any reply (a same-frame reply is dropped as
## expired); the break check bails out early because a parked game never
## registers its capture.
await tree.process_frame
while not is_game_capture_ready() and not _break_active and Time.get_ticks_msec() < deadline:
await tree.process_frame
if not is_game_capture_ready():
## #518: EVAL_GAME_NOT_READY (not INTERNAL_ERROR) — the play session is up
## but the game-side capture didn't register within the short wait. Fast
## and caller-actionable; classifying it apart from the opaque 10s hang
## keeps the INTERNAL_ERROR telemetry bucket meaning "the eval truly hung".
_send_error(connection, request_id, ErrorCodes.EVAL_GAME_NOT_READY,
"Game-side capture didn't register within %ds. The play session is already running, so the game is most likely still booting — wait a moment and retry. If it persists, the _mcp_game_helper autoload is missing or disabled (Project Settings → Autoload; added automatically when the plugin is enabled), or the game uses a custom main loop." % int(EVAL_READY_WAIT_SEC))
_send_error_response(connection, request_id,
_explain_not_live(get_game_status(-1, EVAL_READY_WAIT_SEC), ErrorCodes.EVAL_GAME_NOT_READY))
return
_send_eval(tree, code, request_id, connection, timeout_sec)
@@ -701,11 +1234,16 @@ func _wait_then_game_command(
timeout_sec: float,
) -> void:
var deadline := Time.get_ticks_msec() + int(GAME_READY_WAIT_SEC * 1000.0)
while not is_game_capture_ready() and Time.get_ticks_msec() < deadline:
## #645: the leading yield guarantees the dispatcher has registered the
## deferred request before any reply (a same-frame reply is dropped as
## expired); the break check bails out early because a parked game never
## registers its capture.
await tree.process_frame
while not is_game_capture_ready() and not _break_active and Time.get_ticks_msec() < deadline:
await tree.process_frame
if not is_game_capture_ready():
_send_error(connection, request_id, ErrorCodes.INTERNAL_ERROR,
"Game-side autoload never registered its debugger capture within %ds. Is the game actually running?" % int(GAME_READY_WAIT_SEC))
_send_error_response(connection, request_id,
_explain_not_live(get_game_status(-1, GAME_READY_WAIT_SEC), ErrorCodes.INTERNAL_ERROR))
return
_send_game_command(tree, op, params, request_id, connection, timeout_sec)
+20 -10
View File
@@ -9,6 +9,7 @@ var _command_queue: Array[Dictionary] = []
var _handlers: Dictionary = {} # command_name -> Callable
var _pending_deferred: Dictionary = {} # request_id -> {command, started_ms, timeout_ms}
var _log_buffer
var _surfaced_error_tracker
var mcp_logging := true
var deferred_timeout_overrides_ms: Dictionary = {}
@@ -16,16 +17,19 @@ const DEFAULT_DEFERRED_TIMEOUT_MS := 4500
const DEFERRED_TIMEOUT_MS_BY_COMMAND := {
"create_script": 4500,
"stop_project": 4500,
"run_project": 6000,
"take_screenshot": 30000,
"game_eval": 15000,
"game_command": 15000,
"scan_filesystem": 30000,
}
const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd")
const FuzzySuggestions := preload("res://addons/godot_ai/utils/fuzzy_suggestions.gd")
func _init(log_buffer: McpLogBuffer) -> void:
func _init(log_buffer: McpLogBuffer, surfaced_error_tracker = null) -> void:
_log_buffer = log_buffer
_surfaced_error_tracker = surfaced_error_tracker
## Register a command handler. The callable receives (params: Dictionary) -> Dictionary.
@@ -41,6 +45,11 @@ func clear() -> void:
_command_queue.clear()
_pending_deferred.clear()
_log_buffer = null
_surfaced_error_tracker = null
func set_surfaced_error_tracker(surfaced_error_tracker) -> void:
_surfaced_error_tracker = surfaced_error_tracker
## Invoke a registered handler directly by name. Returns the handler's raw
@@ -160,6 +169,7 @@ func _dispatch(cmd: Dictionary) -> Dictionary:
## See connection.gd::send_deferred_response for the deferred-response
## counterpart, which stamps the same field.
result["readiness"] = McpConnection.get_readiness()
_stamp_error_watermark(result)
if mcp_logging:
var status: String = result.get("status", "ok")
@@ -251,22 +261,22 @@ func _collect_deferred_timeouts() -> Array[Dictionary]:
## dispatcher emits so the server cache can't drift just because
## the editor happened to time out a deferred command.
response["readiness"] = McpConnection.get_readiness()
_stamp_error_watermark(response)
responses.append(response)
if mcp_logging and _log_buffer != null:
_log_buffer.log("[defer] %s (request %s) -> timeout" % [command, request_id])
return responses
func _stamp_error_watermark(response: Dictionary) -> void:
McpSurfacedErrorTracker.stamp_watermark(response, _surfaced_error_tracker)
static func _capture_compact_backtrace(max_frames: int = 8) -> String:
# Use Engine.call() instead of a direct Engine.capture_script_backtraces()
# reference: the method is Godot 4.4+, and 4.3's GDScript parser type-checks
# the static call against GDScriptNativeClass at parse time and rejects the
# whole script even when guarded by has_method() at runtime.
if Engine.has_method("capture_script_backtraces"):
var traces: Array = Engine.call("capture_script_backtraces", false)
for bt in traces:
if bt != null and not bt.is_empty():
return _trim_backtrace_string(bt.format(0, 2), max_frames)
var traces: Array = Engine.capture_script_backtraces(false)
for bt in traces:
if bt != null and not bt.is_empty():
return _trim_backtrace_string(bt.format(0, 2), max_frames)
return _format_stack_frames(get_stack(), max_frames)
+8 -1
View File
@@ -12,6 +12,9 @@ extends VBoxContainer
signal logging_enabled_changed(enabled: bool)
const Dock := preload("res://addons/godot_ai/mcp_dock.gd")
## Preload (not the McpSettings class_name) for consistency with the parse
## hazard note on `_log_buffer` below.
const Settings := preload("res://addons/godot_ai/utils/settings.gd")
## Untyped: a `: McpLogBuffer` annotation hits the class_name registry at
## script-load and trips the self-update parse hazard (#398). The type fence
@@ -50,7 +53,9 @@ func _build_ui() -> void:
_log_toggle = CheckButton.new()
_log_toggle.text = "Log"
_log_toggle.button_pressed = true
## Restore the persisted choice — a hardcoded `true` here meant the
## toggle reset to noisy on every editor restart (#626).
_log_toggle.button_pressed = Settings.mcp_logging_enabled()
_log_toggle.toggled.connect(_on_log_toggled)
log_header_row.add_child(_log_toggle)
@@ -62,6 +67,7 @@ func _build_ui() -> void:
_log_display.scroll_following = true
_log_display.bbcode_enabled = false
_log_display.selection_enabled = true
_log_display.visible = _log_toggle.button_pressed
add_child(_log_display)
@@ -91,5 +97,6 @@ func tick() -> void:
func _on_log_toggled(enabled: bool) -> void:
Settings.set_mcp_logging_enabled(enabled)
_log_display.visible = enabled
logging_enabled_changed.emit(enabled)
+138 -278
View File
@@ -14,20 +14,24 @@ var _debugger_plugin: McpDebuggerPlugin
var _game_log_buffer: McpGameLogBuffer
var _editor_log_buffer: McpEditorLogBuffer
var _debugger_errors_root: Node
var _debugger_search_root_cache: Node
var _surfaced_error_tracker
func _init(log_buffer: McpLogBuffer, connection: McpConnection = null, debugger_plugin: McpDebuggerPlugin = null, game_log_buffer: McpGameLogBuffer = null, editor_log_buffer: McpEditorLogBuffer = null, debugger_errors_root: Node = null) -> void:
func _init(log_buffer: McpLogBuffer, connection: McpConnection = null, debugger_plugin: McpDebuggerPlugin = null, game_log_buffer: McpGameLogBuffer = null, editor_log_buffer: McpEditorLogBuffer = null, debugger_errors_root: Node = null, surfaced_error_tracker = null) -> void:
_log_buffer = log_buffer
_connection = connection
_debugger_plugin = debugger_plugin
_game_log_buffer = game_log_buffer
_editor_log_buffer = editor_log_buffer
_debugger_errors_root = debugger_errors_root
_surfaced_error_tracker = surfaced_error_tracker
if _surfaced_error_tracker == null:
_surfaced_error_tracker = McpSurfacedErrorTracker.new(_editor_log_buffer, _game_log_buffer, _debugger_errors_root)
func get_editor_state(_params: Dictionary) -> Dictionary:
var scene_root := EditorInterface.get_edited_scene_root()
var game_status := _current_game_status()
var data := {
"godot_version": Engine.get_version_info().get("string", "unknown"),
"project_name": ProjectSettings.get_setting("application/config/name", ""),
@@ -38,6 +42,9 @@ func get_editor_state(_params: Dictionary) -> Dictionary:
## false between Play→Stop cycles. Lets capture-source=game callers
## poll for a real ready signal instead of guessing with sleep().
"game_capture_ready": _debugger_plugin != null and _debugger_plugin.is_game_capture_ready(),
"game_status": game_status,
"helper_live": bool(game_status.get("helper_live", false)),
"session_active": bool(game_status.get("session_active", false)),
}
## Half-installed addon tree from a failed self-update rollback. When
## non-empty, the agent / dock paint the operator-facing recovery copy
@@ -72,6 +79,7 @@ func get_logs(params: Dictionary) -> Dictionary:
var include_details: bool = bool(params.get("include_details", false))
var has_since_cursor := params.has("since_cursor") and params.get("since_cursor") != null
var since_cursor: int = maxi(0, int(params.get("since_cursor", 0)))
var since_run_id := "" if params.get("since_run_id", null) == null else str(params.get("since_run_id", ""))
if not source in VALID_LOG_SOURCES:
return ErrorCodes.make(
ErrorCodes.VALUE_OUT_OF_RANGE,
@@ -82,7 +90,7 @@ func get_logs(params: Dictionary) -> Dictionary:
"plugin":
return _get_plugin_logs(count, offset)
"game":
return _get_game_logs(count, offset, include_details)
return _get_game_logs(count, offset, include_details, since_run_id)
"editor":
return _get_editor_logs(count, offset, include_details, has_since_cursor, since_cursor)
"all":
@@ -90,6 +98,17 @@ func get_logs(params: Dictionary) -> Dictionary:
return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Unreachable")
func _current_game_status() -> Dictionary:
if _debugger_plugin == null:
return McpDebuggerPlugin.with_liveness_flags({
"status": "stopped",
"active": false,
"ready": false,
"helper_expected": true,
})
return _debugger_plugin.get_game_status()
func _get_plugin_logs(count: int, offset: int) -> Dictionary:
var all_lines := _log_buffer.get_recent(_log_buffer.total_count())
var page: Array[Dictionary] = []
@@ -107,7 +126,10 @@ func _get_plugin_logs(count: int, offset: int) -> Dictionary:
}
func _get_game_logs(count: int, offset: int, include_details: bool) -> Dictionary:
func _get_game_logs(count: int, offset: int, include_details: bool, since_run_id: String = "") -> Dictionary:
var game_status := _current_game_status()
var helper_live := bool(game_status.get("helper_live", false))
var session_active := bool(game_status.get("session_active", false))
if _game_log_buffer == null:
return {
"data": {
@@ -117,23 +139,75 @@ func _get_game_logs(count: int, offset: int, include_details: bool) -> Dictionar
"returned_count": 0,
"offset": offset,
"run_id": "",
"is_running": false,
"current_run_id": "",
"is_running": session_active,
"helper_live": helper_live,
"session_active": session_active,
"game_status": game_status,
"dropped_count": 0,
"stale_run_id": false,
}
}
var page := _entries_for_response(_game_log_buffer.get_range(offset, count), include_details)
return {
"data": {
"source": "game",
"lines": page,
"total_count": _game_log_buffer.total_count(),
"returned_count": page.size(),
"offset": offset,
"run_id": _game_log_buffer.run_id(),
"is_running": EditorInterface.is_playing_scene(),
"dropped_count": _game_log_buffer.dropped_count(),
}
var current_run_id := _game_log_buffer.run_id()
var target_run_id := since_run_id if not since_run_id.is_empty() else current_run_id
var stale_run_id := not since_run_id.is_empty() and since_run_id != current_run_id
var run_page := _game_log_buffer.get_run_page(target_run_id, offset, count)
var page := _entries_for_response(run_page.get("entries", []), include_details)
var data := {
"source": "game",
"lines": page,
"total_count": int(run_page.get("total_count", 0)),
"returned_count": page.size(),
"offset": offset,
"run_id": target_run_id,
"current_run_id": current_run_id,
"is_running": session_active,
"helper_live": helper_live,
"session_active": session_active,
"game_status": game_status,
"dropped_count": _game_log_buffer.dropped_count(),
"stale_run_id": stale_run_id,
}
_merge_editor_errors_hint(data, game_status)
return {"data": data}
## #641: boot-time parse errors happen while autoload scripts compile — before
## the game helper's logger attaches via OS.add_logger — so they can NEVER
## appear in the game buffer. They surface only through the editor scope
## (Errors-tab rows + editor logger). Cross-reference them here so an
## empty/clean game log is not mistaken for a clean launch.
func _merge_editor_errors_hint(data: Dictionary, game_status: Dictionary) -> void:
if _debugger_plugin == null:
return
## A since_run_id read of a prior run must not carry the CURRENT run's
## editor errors — the hint interprets the run being read.
if bool(data.get("stale_run_id", false)):
return
## run_token == 0 means no tracked run ever started this session; the
## run-start cursor would be 0 and every retained editor error would be
## misattributed to "this run".
if int(game_status.get("run_token", 0)) <= 0:
return
## One-shot read — force the scan so rows that landed after the last
## gated scan (and before the deferred timers fire) make the FIRST
## logs_read(source='game') response, not just a later one.
var errors_info: Dictionary = _debugger_plugin.recent_editor_errors_since(
int(game_status.get("editor_log_cursor", 0)), true)
if str(errors_info.get("scope", "none")) != "run":
return
var errors: Array = errors_info.get("errors", [])
if errors.is_empty():
return
data["editor_errors_count"] = errors.size()
data["editor_errors_hint"] = (
"%d editor-side error%s from this run (first: %s) missing from the game log — boot-time parse/load errors occur before the game helper's logger attaches. Read logs_read(source='editor', include_details=true)."
% [errors.size(), "s" if errors.size() != 1 else "", _format_editor_error_summary(errors[0])]
)
func _format_editor_error_summary(entry: Dictionary) -> String:
return McpSurfacedErrorTracker.format_editor_error_summary(entry)
func _get_editor_logs(count: int, offset: int, include_details: bool, has_since_cursor: bool = false, since_cursor: int = 0) -> Dictionary:
@@ -211,21 +285,24 @@ func _get_all_logs(count: int, offset: int, include_details: bool) -> Dictionary
combined.append({"source": "plugin", "level": "info", "text": line})
for entry in _collect_editor_log_entries():
combined.append(entry)
var run_id := ""
var current_run_id := ""
var dropped := 0
if _game_log_buffer != null:
for entry in _game_log_buffer.get_range(0, _game_log_buffer.total_count()):
run_id = _game_log_buffer.run_id()
current_run_id = run_id
dropped = _game_log_buffer.dropped_count()
var run_page := _game_log_buffer.get_run_page(run_id, 0, McpGameLogBuffer.MAX_LINES)
for entry in run_page.get("entries", []):
combined.append(entry)
var stop := mini(combined.size(), offset + count)
var page: Array[Dictionary] = []
for i in range(mini(offset, combined.size()), stop):
page.append(combined[i])
page = _entries_for_response(page, include_details)
var run_id := ""
var dropped := 0
if _game_log_buffer != null:
run_id = _game_log_buffer.run_id()
dropped = _game_log_buffer.dropped_count()
if _editor_log_buffer != null:
dropped += _editor_log_buffer.dropped_count()
var game_status := _current_game_status()
return {
"data": {
"source": "all",
@@ -234,7 +311,11 @@ func _get_all_logs(count: int, offset: int, include_details: bool) -> Dictionary
"returned_count": page.size(),
"offset": offset,
"run_id": run_id,
"is_running": EditorInterface.is_playing_scene(),
"current_run_id": current_run_id,
"is_running": bool(game_status.get("session_active", false)),
"helper_live": bool(game_status.get("helper_live", false)),
"session_active": bool(game_status.get("session_active", false)),
"game_status": game_status,
"dropped_count": dropped,
}
}
@@ -256,208 +337,7 @@ func _entries_for_response(entries: Array[Dictionary], include_details: bool) ->
func _collect_editor_log_entries() -> Array[Dictionary]:
var entries: Array[Dictionary] = []
if _editor_log_buffer != null:
for entry in _editor_log_buffer.get_range(0, _editor_log_buffer.total_count()):
entries.append(entry)
for entry in _read_debugger_error_entries():
if not _has_equivalent_log_entry(entries, entry):
entries.append(entry)
return entries
func _read_debugger_error_entries() -> Array[Dictionary]:
var entries: Array[Dictionary] = []
for tree in _locate_debugger_error_trees():
for entry in _entries_from_debugger_error_tree(tree):
if not _has_equivalent_log_entry(entries, entry):
entries.append(entry)
return entries
func _locate_debugger_error_trees() -> Array[Tree]:
var trees: Array[Tree] = []
if _debugger_plugin == null and _debugger_errors_root == null:
return trees
var root: Node = _debugger_errors_root
if root == null:
root = _debugger_search_root()
if root == null:
return trees
_collect_debugger_error_trees(root, trees)
return trees
func _debugger_search_root() -> Node:
## logs_read is a polling tool, so per-call discovery must not recurse the
## entire editor UI. EditorDebuggerNode is the bottom-panel container that
## owns every ScriptEditorDebugger session tab and lives for the editor's
## lifetime — find it once from the base control, then scan only its
## subtree on later calls. The error Trees themselves can't be cached:
## they are identified by their content, and an emptied tree is
## indistinguishable from any other Tree.
if is_instance_valid(_debugger_search_root_cache):
return _debugger_search_root_cache
_debugger_search_root_cache = null
var base := EditorInterface.get_base_control()
if base == null:
return null
_debugger_search_root_cache = _find_first_of_class(base, "EditorDebuggerNode")
if _debugger_search_root_cache == null:
return base
return _debugger_search_root_cache
static func _find_first_of_class(node: Node, klass: String) -> Node:
if node.get_class() == klass:
return node
for child in node.get_children():
var found := _find_first_of_class(child, klass)
if found != null:
return found
return null
static func _collect_debugger_error_trees(node: Node, out: Array[Tree]) -> void:
if node is Tree and _tree_has_debugger_errors(node as Tree):
out.append(node as Tree)
for child in node.get_children():
if child is Node:
_collect_debugger_error_trees(child as Node, out)
static func _tree_has_debugger_errors(tree: Tree) -> bool:
var root := tree.get_root()
if root == null:
return false
var item := root.get_first_child()
while item != null:
if _is_debugger_error_item(item):
return true
item = item.get_next()
return false
static func _entries_from_debugger_error_tree(tree: Tree) -> Array[Dictionary]:
var entries: Array[Dictionary] = []
var root := tree.get_root()
if root == null:
return entries
var item := root.get_first_child()
while item != null:
if _is_debugger_error_item(item):
entries.append(_entry_from_debugger_error_item(item))
item = item.get_next()
return entries
static func _entry_from_debugger_error_item(item: TreeItem) -> Dictionary:
var title := item.get_text(1)
var loc := _location_from_metadata(item.get_metadata(0))
var function := _function_from_title(title)
return {
"source": "editor",
"level": "warn" if item.has_meta("_is_warning") else "error",
"text": title,
"path": str(loc.get("path", "")),
"line": int(loc.get("line", 0)),
"function": function,
"details": _details_from_debugger_error_item(item, loc, function),
}
static func _details_from_debugger_error_item(item: TreeItem, loc: Dictionary, function: String) -> Dictionary:
var children: Array[Dictionary] = []
var child := item.get_first_child()
while child != null:
var child_loc := _location_from_metadata(child.get_metadata(0))
children.append({
"label": child.get_text(0),
"text": child.get_text(1),
"path": str(child_loc.get("path", "")),
"line": int(child_loc.get("line", 0)),
})
child = child.get_next()
return {
"debugger_tab": "Errors",
"time": item.get_text(0),
"message": item.get_text(1),
"error_type_name": "warning" if item.has_meta("_is_warning") else "error",
"source": {
"path": str(loc.get("path", "")),
"line": int(loc.get("line", 0)),
"function": function,
},
"resolved": {
"path": str(loc.get("path", "")),
"line": int(loc.get("line", 0)),
"function": function,
},
"children": children,
"frames": _frames_from_error_children(children),
}
static func _is_debugger_error_item(item: TreeItem) -> bool:
return item.has_meta("_is_warning") or item.has_meta("_is_error")
## ScriptEditorDebugger lays out an error item's children flat, in order: an
## optional "<X Error>" row, one "<X Source>" row, then one row per stack
## frame. Only frame 0 carries the "<Stack Trace>" label (TTR-translated);
## later frames have an empty label. Every frame row carries [path, line]
## metadata, but so can the Error/Source rows, so metadata alone can't
## identify frames — the frame run has to be found first.
static func _frames_from_error_children(children: Array[Dictionary]) -> Array[Dictionary]:
var start := -1
for i in children.size():
if str(children[i].label).contains("Stack Trace"):
start = i
break
if start < 0:
## Non-English editor locale: the "<Stack Trace>" label is translated.
## Frames past the first are the only rows with an empty label and a
## real location; back up one row to recover the labeled first frame
## (rows before the frame run always have a non-empty label).
for i in children.size():
if str(children[i].label).is_empty() and not str(children[i].path).is_empty():
start = maxi(i - 1, 0)
break
if start < 0:
return []
var frames: Array[Dictionary] = []
for i in range(start, children.size()):
if str(children[i].path).is_empty():
continue
frames.append({
"path": children[i].path,
"line": children[i].line,
"function": _function_from_frame_text(children[i].text),
})
return frames
static func _location_from_metadata(meta: Variant) -> Dictionary:
if meta is Array and meta.size() >= 2:
return {"path": str(meta[0]), "line": int(meta[1])}
return {"path": "", "line": 0}
static func _function_from_title(title: String) -> String:
var colon := title.find(": ")
if colon <= 0:
return ""
return title.substr(0, colon)
static func _function_from_frame_text(text: String) -> String:
var marker := text.find(" @ ")
if marker < 0:
return ""
var fn := text.substr(marker + 3).strip_edges()
if fn.ends_with("()"):
fn = fn.substr(0, fn.length() - 2)
return fn
return _surfaced_error_tracker.collect_editor_log_entries()
static func _slice_entries(entries: Array[Dictionary], offset: int, count: int) -> Array[Dictionary]:
@@ -468,23 +348,6 @@ static func _slice_entries(entries: Array[Dictionary], offset: int, count: int)
return page
static func _has_equivalent_log_entry(entries: Array[Dictionary], candidate: Dictionary) -> bool:
var key := _log_entry_key(candidate)
for entry in entries:
if _log_entry_key(entry) == key:
return true
return false
static func _log_entry_key(entry: Dictionary) -> String:
return "%s|%s|%s|%s" % [
str(entry.get("level", "")),
str(entry.get("text", "")),
str(entry.get("path", "")),
str(entry.get("line", 0)),
]
## Map of human-readable monitor names to Performance.Monitor enum values.
const MONITORS := {
"time/fps": Performance.TIME_FPS,
@@ -591,8 +454,30 @@ func take_screenshot(params: Dictionary) -> Dictionary:
return McpDispatcher.DEFERRED_RESPONSE
"cinematic":
return _take_cinematic_screenshot(max_resolution)
"viewport_2d":
viewport = EditorInterface.get_editor_viewport_2d()
if viewport == null:
return ErrorCodes.make(ErrorCodes.EDITOR_NOT_READY, "No 2D viewport available")
var scene_root_2d := EditorInterface.get_edited_scene_root()
if scene_root_2d == null:
return ErrorCodes.make(ErrorCodes.EDITOR_NOT_READY,
"No scene open — open a scene first")
if not view_target.is_empty() or coverage or custom_elevation != null or custom_azimuth != null or custom_fov != null:
return ErrorCodes.make(
ErrorCodes.INVALID_PARAMS,
"view_target, coverage, elevation, azimuth, and fov are not supported with source='viewport_2d'"
)
## Capture the 2D editor viewport directly; no view_target/coverage for 2D.
RenderingServer.force_draw(false)
var image_2d: Image = viewport.get_texture().get_image()
if image_2d == null or image_2d.is_empty():
return _empty_image_error(
"viewport_2d",
"Captured an empty image from the 2D viewport. The 2D viewport produced no output — typically headless mode or the 2D viewport has not drawn a frame yet."
)
return _finalize_image(image_2d, "viewport_2d", max_resolution)
_:
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Invalid source '%s' — use 'viewport', 'cinematic', or 'game'" % source)
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Invalid source '%s' — use 'viewport', 'viewport_2d', 'cinematic', or 'game'" % source)
## Handle view_target: temporarily reposition the editor's own camera to
## frame one or more target nodes, force a render, capture, then restore.
@@ -845,12 +730,14 @@ static func viewport_screenshot_precheck(scene_root: Node) -> Dictionary:
return {}
var root_type := scene_root.get_class()
var hint: String
if scene_root is Node2D or scene_root is Control:
var is_2d_scene := scene_root is CanvasItem
if is_2d_scene:
hint = (
"The 3D viewport is empty because the current scene is 2D (%s root) with no Node3D descendants. "
+ "Options: (a) open a 3D scene, "
+ "(b) use source=\"cinematic\" if a Camera3D exists in the scene, "
+ "(c) call scene_get_hierarchy first to inspect what's available."
+ "(c) use source=\"viewport_2d\" to capture the 2D editor viewport directly, "
+ "(d) call scene_get_hierarchy first to inspect what's available."
) % root_type
else:
hint = (
@@ -859,7 +746,10 @@ static func viewport_screenshot_precheck(scene_root: Node) -> Dictionary:
+ "(b) use source=\"cinematic\" if a Camera3D exists in the scene, "
+ "(c) call scene_get_hierarchy first to inspect what's available."
) % root_type
return _make_viewport_not_3d_error(root_type, hint)
var err := _make_viewport_not_3d_error(root_type, hint)
if is_2d_scene:
err["error"]["data"]["suggestion"] = "use source='viewport_2d' for 2D scenes"
return err
## True if scene_root is itself a Node3D or owns any Node3D descendant.
@@ -1029,37 +919,7 @@ func clear_logs(params: Dictionary) -> Dictionary:
func _clear_debugger_error_trees() -> int:
var cleared := 0
for tree in _locate_debugger_error_trees():
cleared += _entries_from_debugger_error_tree(tree).size()
if not _press_debugger_clear_button(tree):
## No Clear button near this tree (synthetic roots in tests).
## A raw clear is acceptable there; the real panel always routes
## through the button below.
tree.clear()
return cleared
## Clear via ScriptEditorDebugger's own Clear button so the engine runs
## _clear_errors_list() — clearing the Tree directly leaves error_count/
## warning_count, the "Errors (N)" tab badge, the errors_cleared signal, and
## the toolbar button states out of sync with the emptied tree. The button is
## identified by its pressed-connection target, not its (translated) label.
static func _press_debugger_clear_button(tree: Tree) -> bool:
var parent := tree.get_parent()
if parent == null:
return false
var stack: Array[Node] = [parent]
while not stack.is_empty():
var node: Node = stack.pop_back()
if node is BaseButton:
for conn in node.get_signal_connection_list("pressed"):
if str(conn.get("callable", "")).contains("_clear_errors_list"):
node.emit_signal("pressed")
return true
for child in node.get_children():
stack.push_back(child)
return false
return _surfaced_error_tracker.clear_debugger_error_trees()
func reload_plugin(_params: Dictionary) -> Dictionary:
@@ -5,6 +5,32 @@ const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd")
## Handles file read/write operations and reimport within the Godot project.
## Bounds for the deferred scan wait. `write_file`/`reimport` register single
## files with `update_file()` (cheap, no global-class rebuild); `scan_filesystem`
## is the heavier, explicit "rebuild the class registry" path agents call after
## adding `class_name` scripts headlessly (no window focus to trigger it).
## Kept under the dispatcher's "scan_filesystem" deferred timeout (30s) so we
## always send a real reply before a DEFERRED_TIMEOUT is synthesised.
const _SCAN_START_GRACE_MSEC := 750
const _SCAN_SETTLE_MAX_MSEC := 28000
## Shared single-flight latch for scan_filesystem. `is_scanning()` alone can't
## enforce single-flight: `EditorFileSystem.scan()` doesn't flip `is_scanning()`
## for a frame or two (hence _SCAN_START_GRACE_MSEC), so a second request landing
## in that window would observe `false` and stack another scan() — the exact
## stacked-worker SIGABRT this op exists to avoid (dsarno/godot#6). The latch is
## set before the first scan() and cleared when its settle coroutine finishes;
## concurrent requests coalesce onto the running scan instead of starting one.
## `static` so it's shared across handler instances; it resets on plugin reload
## (script re-parse), which self-heals any latch orphaned by a mid-await teardown.
static var _scan_in_flight := false
var _connection: McpConnection
func _init(connection: McpConnection = null) -> void:
_connection = connection
func read_file(params: Dictionary) -> Dictionary:
var path: String = params.get("path", "")
@@ -110,3 +136,108 @@ func reimport(params: Dictionary) -> Dictionary:
"reason": "Reimport is a file system operation",
}
}
## Force a full EditorFileSystem scan and wait for it to settle. This is the
## headless equivalent of the editor regaining window focus: `update_file()`
## (used by write_file/reimport/script_create) registers a single file with the
## resource pipeline but does NOT rebuild the global `class_name` table, so a
## freshly-created `class_name MyThing extends Resource` stays invisible to
## `ClassDB`/`ProjectSettings.get_global_class_list()` until a scan runs. Agents
## driving the editor without focus call this once after a batch of script
## creates to make new types instantiable/referenceable. See issue #83.
func scan_filesystem(params: Dictionary) -> Dictionary:
var efs := EditorInterface.get_resource_filesystem()
if efs == null:
return ErrorCodes.make(ErrorCodes.EDITOR_NOT_READY, "EditorFileSystem not available")
var request_id: String = params.get("_request_id", "")
# Async path: a scan can't be awaited on the calling frame without freezing
# the editor, so hand control back to the dispatcher (DEFERRED_RESPONSE) and
# push the real reply from a static coroutine once the scan settles — by
# which point new class_names are registered.
if _connection != null and not request_id.is_empty():
_finish_scan_deferred(_connection, request_id, efs)
return McpDispatcher.DEFERRED_RESPONSE
# Synchronous fallback: batch_execute (no request_id) and unit-test contexts
# (no connection) can't await, so kick a single-flight scan and return
# immediately without the settle confirmation. Respect the latch so we don't
# stack onto a deferred scan; don't set it (there's no coroutine here to
# clear it — the brief is_scanning() window covers the rest).
var already := _scan_in_flight or efs.is_scanning()
if not already:
efs.scan()
return {
"data": {
"scan_completed": false,
"scan_settle": "not_waited",
"was_already_scanning": already,
"global_class_count": ProjectSettings.get_global_class_list().size(),
# Present in both paths for a consistent response shape; the sync
# path doesn't await, so it can't measure a delta.
"global_classes_registered_delta": 0,
"undoable": false,
"reason": "Filesystem scan is an editor operation",
}
}
## `static` is load-bearing for the same reason as ScriptHandler's deferred
## finish: the coroutine must outlive the handler RefCounted, which can be freed
## mid-await (e.g. an editor_reload_plugin fired during the scan). Parameterise
## everything; reference no instance state.
static func _finish_scan_deferred(
connection: McpConnection,
request_id: String,
efs: EditorFileSystem,
) -> void:
if not is_instance_valid(connection):
return
var tree := connection.get_tree()
if tree == null:
return
var classes_before := ProjectSettings.get_global_class_list().size()
# Single-flight via the shared `_scan_in_flight` latch (NOT is_scanning(),
# which lags scan() by a frame or two — see the latch declaration). Only the
# request that sets the latch calls scan(); concurrent requests coalesce and
# just await the running scan. This is what actually prevents the stacked
# scan() SIGABRT (dsarno/godot#6), even within the start-grace window.
var was_already_scanning := _scan_in_flight or efs.is_scanning()
var we_started := not was_already_scanning
if we_started:
_scan_in_flight = true
efs.scan()
# Hand back a frame so _dispatch() registers this request as deferred before
# the coroutine can push a reply (mirrors _finish_create_script_deferred).
await tree.process_frame
var deadline_ms := Time.get_ticks_msec() + _SCAN_SETTLE_MAX_MSEC
var start_grace_ms := Time.get_ticks_msec() + _SCAN_START_GRACE_MSEC
var saw_scanning := efs.is_scanning()
while Time.get_ticks_msec() < deadline_ms:
if efs.is_scanning():
saw_scanning = true
elif saw_scanning or Time.get_ticks_msec() > start_grace_ms:
# Either the scan ran and finished, or it never flipped is_scanning()
# within the grace window (a no-op scan because nothing changed).
break
await tree.process_frame
# Clear the latch in all paths (no try/finally in GDScript): do it before the
# is_instance_valid early-return so a freed connection can't orphan it.
if we_started:
_scan_in_flight = false
if not is_instance_valid(connection):
return
var completed := not efs.is_scanning()
var classes_after := ProjectSettings.get_global_class_list().size()
connection.send_deferred_response(request_id, {
"data": {
"scan_completed": completed,
"scan_settle": "settled" if completed else "timeout",
"was_already_scanning": was_already_scanning,
"global_class_count": classes_after,
"global_classes_registered_delta": classes_after - classes_before,
"undoable": false,
"reason": "Filesystem scan is an editor operation",
}
})
+196 -14
View File
@@ -18,11 +18,13 @@ func list_actions(params: Dictionary) -> Dictionary:
## See #213.
var user_authored := _read_user_authored_actions()
var actions: Array[Dictionary] = []
var seen := {}
for action_name in InputMap.get_actions():
var name_str := str(action_name)
var is_user_action := user_authored.has(name_str)
if not include_builtin and not is_user_action:
continue
seen[name_str] = true
var events: Array[Dictionary] = []
for event in InputMap.action_get_events(action_name):
events.append(_serialize_event(event))
@@ -31,6 +33,25 @@ func list_actions(params: Dictionary) -> Dictionary:
"events": events,
"event_count": events.size(),
"is_builtin": not is_user_action,
"loaded_in_input_map": true,
})
for action_name in user_authored.keys():
var name_str := str(action_name)
if seen.has(name_str):
continue
var setting: Dictionary = user_authored.get(name_str, {})
var events: Array[Dictionary] = []
for event in setting.get("events", []):
if event is InputEvent:
events.append(_serialize_event(event))
else:
events.append({"type": type_string(typeof(event)), "string": str(event)})
actions.append({
"name": name_str,
"events": events,
"event_count": events.size(),
"is_builtin": false,
"loaded_in_input_map": false,
})
return {"data": {"actions": actions, "count": actions.size()}}
@@ -43,7 +64,8 @@ func _read_user_authored_actions() -> Dictionary:
return {}
var result: Dictionary = {}
for key in cfg.get_section_keys("input"):
result[key] = true
var value = cfg.get_value("input", key, {})
result[key] = value if value is Dictionary else {}
return result
@@ -54,9 +76,9 @@ func add_action(params: Dictionary) -> Dictionary:
if action.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: action")
if deadzone < 0.0 or deadzone > 1.0:
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE,
"deadzone must be in [0.0, 1.0] (got %s). Typical values are 0.2-0.5; default is 0.5." % deadzone)
var deadzone_error := _validate_deadzone(deadzone)
if deadzone_error.has("error"):
return deadzone_error
if InputMap.has_action(action):
return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, "Action '%s' already exists" % action)
@@ -85,28 +107,53 @@ func add_action(params: Dictionary) -> Dictionary:
}
func ensure_action(params: Dictionary) -> Dictionary:
var action: String = params.get("action", "")
var deadzone: float = params.get("deadzone", 0.5)
if action.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: action")
var deadzone_error := _validate_deadzone(deadzone)
if deadzone_error.has("error"):
return deadzone_error
var result := _ensure_action_state(action, deadzone)
if result.has("error"):
return result
return {"data": result}
func remove_action(params: Dictionary) -> Dictionary:
var action: String = params.get("action", "")
if action.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: action")
if not InputMap.has_action(action):
var key := "input/%s" % action
var was_loaded := InputMap.has_action(action)
var old_setting = ProjectSettings.get_setting(key) if ProjectSettings.has_setting(key) else null
## An action can live in the editor process's InputMap, in project.godot,
## or both. Actions persisted by a previous editor session exist only on
## disk (`loaded_in_input_map: false` in list_actions) — those must still
## be removable. #632
if not was_loaded and old_setting == null:
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Action '%s' not found" % action)
var key := "input/%s" % action
var old_setting = ProjectSettings.get_setting(key) if ProjectSettings.has_setting(key) else null
InputMap.erase_action(action)
if was_loaded:
InputMap.erase_action(action)
if old_setting != null:
ProjectSettings.clear(key)
var err := ProjectSettings.save()
if err != OK:
var dz: float = old_setting.get("deadzone", 0.5) if old_setting is Dictionary else 0.5
InputMap.add_action(action, dz)
if old_setting is Dictionary:
for ev in old_setting.get("events", []):
if ev is InputEvent:
InputMap.action_add_event(action, ev)
if was_loaded:
var dz: float = old_setting.get("deadzone", 0.5) if old_setting is Dictionary else 0.5
InputMap.add_action(action, dz)
if old_setting is Dictionary:
for ev in old_setting.get("events", []):
if ev is InputEvent:
InputMap.action_add_event(action, ev)
ProjectSettings.set_setting(key, old_setting)
return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR,
"Failed to save project settings while removing action '%s': %s (error %d)" % [action, error_string(err), err])
@@ -115,6 +162,7 @@ func remove_action(params: Dictionary) -> Dictionary:
"data": {
"action": action,
"removed": true,
"was_loaded": was_loaded,
"undoable": false,
"reason": "Input actions are saved to project.godot",
}
@@ -157,6 +205,112 @@ func bind_event(params: Dictionary) -> Dictionary:
}
func ensure_binding(params: Dictionary) -> Dictionary:
var action: String = params.get("action", "")
var event_type: String = params.get("event_type", "")
var deadzone: float = params.get("deadzone", 0.5)
if action.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: action")
if event_type.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: event_type")
var deadzone_error := _validate_deadzone(deadzone)
if deadzone_error.has("error"):
return deadzone_error
var event_or_error = _create_event(event_type, params)
if event_or_error is Dictionary:
return event_or_error
var event: InputEvent = event_or_error
var ensured := _ensure_action_state(action, deadzone)
if ensured.has("error"):
return ensured
for existing in InputMap.action_get_events(action):
if _events_match(existing, event):
return {
"data": {
"action": action,
"event": _serialize_event(existing),
"already_bound": true,
"action_created": ensured.get("created", false),
"undoable": false,
"reason": "Input binding already exists",
}
}
InputMap.action_add_event(action, event)
var err := _save_action_events(action)
if err != OK:
InputMap.action_erase_event(action, event)
return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR,
"Failed to save project settings while binding event to action '%s': %s (error %d)" % [action, error_string(err), err])
return {
"data": {
"action": action,
"event": _serialize_event(event),
"already_bound": false,
"action_created": ensured.get("created", false),
"undoable": false,
"reason": "Input bindings are saved to project.godot",
}
}
func _validate_deadzone(deadzone: float) -> Dictionary:
if deadzone < 0.0 or deadzone > 1.0:
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE,
"deadzone must be in [0.0, 1.0] (got %s). Typical values are 0.2-0.5; default is 0.5." % deadzone)
return {}
func _ensure_action_state(action: String, deadzone: float) -> Dictionary:
var key := "input/%s" % action
var user_authored := _read_user_authored_actions()
var existed_in_input_map := InputMap.has_action(action)
var existed_in_project := user_authored.has(action) or ProjectSettings.has_setting(key)
var old_setting = user_authored.get(action, null) if user_authored.has(action) else null
if old_setting == null and ProjectSettings.has_setting(key):
old_setting = ProjectSettings.get_setting(key)
if not existed_in_input_map:
var dz := deadzone
if old_setting is Dictionary:
dz = float(old_setting.get("deadzone", deadzone))
InputMap.add_action(action, dz)
if old_setting is Dictionary:
for ev in old_setting.get("events", []):
if ev is InputEvent:
InputMap.action_add_event(action, ev)
if not existed_in_project:
var err := _save_action_events(action)
if err != OK:
if not existed_in_input_map:
InputMap.erase_action(action)
return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR,
"Failed to save project settings while ensuring action '%s': %s (error %d)" % [action, error_string(err), err])
var stored_deadzone := deadzone
if ProjectSettings.has_setting(key):
var stored = ProjectSettings.get_setting(key)
if stored is Dictionary:
stored_deadzone = float(stored.get("deadzone", deadzone))
return {
"action": action,
"deadzone": stored_deadzone,
"created": not existed_in_input_map and not existed_in_project,
"already_exists": existed_in_input_map or existed_in_project,
"loaded_in_input_map": true,
"persisted": true,
"undoable": false,
"reason": "Input actions are saved to project.godot",
}
## Returns an InputEvent on success, or a Dictionary error on failure.
## Caller must check ``result is Dictionary`` before treating it as an event.
func _create_event(event_type: String, params: Dictionary):
@@ -257,6 +411,32 @@ func _serialize_event(event: InputEvent) -> Dictionary:
return {"type": event.get_class(), "string": str(event)}
func _events_match(a: InputEvent, b: InputEvent) -> bool:
if a is InputEventKey and b is InputEventKey:
return _key_events_match(a as InputEventKey, b as InputEventKey)
return _serialize_event(a) == _serialize_event(b)
func _key_events_match(a: InputEventKey, b: InputEventKey) -> bool:
if a.ctrl_pressed != b.ctrl_pressed:
return false
if a.alt_pressed != b.alt_pressed:
return false
if a.shift_pressed != b.shift_pressed:
return false
if a.meta_pressed != b.meta_pressed:
return false
var a_codes := [a.keycode, a.physical_keycode]
var b_codes := [b.keycode, b.physical_keycode]
for a_code in a_codes:
if int(a_code) == KEY_NONE:
continue
for b_code in b_codes:
if int(b_code) != KEY_NONE and int(a_code) == int(b_code):
return true
return false
func _save_action_events(action: String) -> int:
var events: Array = []
for event in InputMap.action_get_events(action):
@@ -267,6 +447,8 @@ func _save_action_events(action: String) -> int:
var deadzone: float = 0.5
if old_setting is Dictionary:
deadzone = old_setting.get("deadzone", 0.5)
elif InputMap.has_action(action):
deadzone = InputMap.action_get_deadzone(action)
ProjectSettings.set_setting(key, {
"deadzone": deadzone,
"events": events,
+137 -12
View File
@@ -239,16 +239,10 @@ func set_property(params: Dictionary) -> Dictionary:
# properties. Mirrors resource_create's inline-assign path but
# avoids a separate tool call for the common case.
var type_str: String = value.get("__class__", "")
var class_err := ResourceHandler._validate_resource_class(type_str)
if class_err != null:
return class_err
var instance := ClassDB.instantiate(type_str)
if instance == null or not (instance is Resource):
return ErrorCodes.make(
ErrorCodes.INTERNAL_ERROR,
"Failed to instantiate %s as a Resource" % type_str
)
var res: Resource = instance
var made := ResourceHandler._instantiate_resource(type_str)
if made is Dictionary:
return made
var res: Resource = made
var remaining: Dictionary = (value as Dictionary).duplicate()
remaining.erase("__class__")
if not remaining.is_empty():
@@ -528,6 +522,7 @@ func _set_owner_recursive(node: Node, owner: Node) -> void:
## is optional — the coercer defaults it to 1.0 when absent.
const VECTOR2_KEYS: Array[String] = ["x", "y"]
const VECTOR3_KEYS: Array[String] = ["x", "y", "z"]
const VECTOR4_KEYS: Array[String] = ["x", "y", "z", "w"]
const COLOR_KEYS: Array[String] = ["r", "g", "b"]
@@ -556,6 +551,8 @@ static func _check_coerced(value: Variant, target_type: int, prefix: String = ""
ok = value is PackedVector2Array
TYPE_PACKED_VECTOR3_ARRAY:
ok = value is PackedVector3Array
TYPE_PACKED_VECTOR4_ARRAY:
ok = value is PackedVector4Array
TYPE_PACKED_COLOR_ARRAY:
ok = value is PackedColorArray
TYPE_PACKED_INT32_ARRAY:
@@ -568,8 +565,31 @@ static func _check_coerced(value: Variant, target_type: int, prefix: String = ""
ok = value is PackedFloat64Array
TYPE_PACKED_STRING_ARRAY:
ok = value is PackedStringArray
TYPE_VECTOR2I: ok = value is Vector2i
TYPE_VECTOR3I: ok = value is Vector3i
TYPE_VECTOR4: ok = value is Vector4
TYPE_VECTOR4I: ok = value is Vector4i
TYPE_QUATERNION: ok = value is Quaternion
TYPE_RECT2: ok = value is Rect2
TYPE_RECT2I: ok = value is Rect2i
TYPE_AABB: ok = value is AABB
TYPE_PLANE: ok = value is Plane
TYPE_BASIS: ok = value is Basis
TYPE_TRANSFORM2D: ok = value is Transform2D
TYPE_TRANSFORM3D: ok = value is Transform3D
TYPE_PROJECTION: ok = value is Projection
_:
return null
# null / untyped-TYPE_NIL / already-correct-type are handled by
# Godot's setter; anything else would silently no-op, so error.
if value == null or target_type == TYPE_NIL or typeof(value) == target_type:
return null
var unsupported := ErrorCodes.make(
ErrorCodes.WRONG_TYPE,
"Cannot write %s to a %s property; godot-ai has no coercion for that type" % [
type_string(typeof(value)), type_string(target_type),
],
)
return ErrorCodes.prefix_message(unsupported, prefix)
if ok:
return null
var dict_err := _check_dict_coerce_failed(value, target_type)
@@ -597,6 +617,8 @@ static func _shape_hint(target_type: int) -> String:
return "[{\"x\":0,\"y\":0}, ...]"
TYPE_PACKED_VECTOR3_ARRAY:
return "[{\"x\":0,\"y\":0,\"z\":0}, ...]"
TYPE_PACKED_VECTOR4_ARRAY:
return "[{\"x\":0,\"y\":0,\"z\":0,\"w\":0}, ...]"
TYPE_PACKED_COLOR_ARRAY:
return "[{\"r\":0,\"g\":0,\"b\":0,\"a\":1}, ...]"
TYPE_PACKED_INT32_ARRAY, TYPE_PACKED_INT64_ARRAY:
@@ -605,6 +627,24 @@ static func _shape_hint(target_type: int) -> String:
return "[float, ...]"
TYPE_PACKED_STRING_ARRAY:
return "[\"...\", ...]"
TYPE_VECTOR2I:
return "{\"x\":0,\"y\":0}"
TYPE_VECTOR3I:
return "{\"x\":0,\"y\":0,\"z\":0}"
TYPE_VECTOR4, TYPE_VECTOR4I, TYPE_QUATERNION:
return "{\"x\":0,\"y\":0,\"z\":0,\"w\":0}"
TYPE_RECT2, TYPE_RECT2I, TYPE_AABB:
return "{\"position\":{...},\"size\":{...}}"
TYPE_PLANE:
return "{\"normal\":{...},\"d\":0}"
TYPE_BASIS:
return "{\"x\":{...},\"y\":{...},\"z\":{...}}"
TYPE_TRANSFORM2D:
return "{\"x\":{...},\"y\":{...},\"origin\":{...}}"
TYPE_TRANSFORM3D:
return "{\"basis\":{...},\"origin\":{...}}"
TYPE_PROJECTION:
return "{\"x\":{...},\"y\":{...},\"z\":{...},\"w\":{...}}"
var keys: Array[String] = []
match target_type:
TYPE_VECTOR2: keys = VECTOR2_KEYS
@@ -666,7 +706,15 @@ static func _coerce_value(value: Variant, target_type: int) -> Variant:
if value is Dictionary and value.has_all(COLOR_KEYS):
return Color(value["r"], value["g"], value["b"], value.get("a", 1.0))
if value is String:
return Color(value)
# Color(String) silently returns black for an unparseable string
# (e.g. "Color(1,1,1,1)" or a typo). Validate with the two-sentinel
# from_string idiom (see theme_handler/material_values); on failure
# fall through so the value stays a String and _check_coerced flags
# it as an error instead of writing black.
var col_a := Color.from_string(value, Color(0, 0, 0, 0))
var col_b := Color.from_string(value, Color(1, 1, 1, 1))
if col_a == col_b:
return col_a
TYPE_BOOL:
if value is float or value is int:
return bool(value)
@@ -717,6 +765,17 @@ static func _coerce_value(value: Variant, target_type: int) -> Variant:
else:
return value
return out
TYPE_PACKED_VECTOR4_ARRAY:
if value is Array:
var out := PackedVector4Array()
for item in value:
if item is Vector4:
out.append(item)
elif item is Dictionary and item.has_all(VECTOR4_KEYS):
out.append(Vector4(item["x"], item["y"], item["z"], item["w"]))
else:
return value
return out
TYPE_PACKED_COLOR_ARRAY:
if value is Array:
var out := PackedColorArray()
@@ -757,6 +816,72 @@ static func _coerce_value(value: Variant, target_type: int) -> Variant:
else:
return value
return out
TYPE_VECTOR2I:
if value is Dictionary and value.has_all(VECTOR2_KEYS):
return Vector2i(int(value["x"]), int(value["y"]))
TYPE_VECTOR3I:
if value is Dictionary and value.has_all(VECTOR3_KEYS):
return Vector3i(int(value["x"]), int(value["y"]), int(value["z"]))
TYPE_VECTOR4:
if value is Dictionary and value.has_all(VECTOR4_KEYS):
return Vector4(value["x"], value["y"], value["z"], value["w"])
TYPE_VECTOR4I:
if value is Dictionary and value.has_all(VECTOR4_KEYS):
return Vector4i(int(value["x"]), int(value["y"]), int(value["z"]), int(value["w"]))
TYPE_QUATERNION:
if value is Dictionary and value.has_all(VECTOR4_KEYS):
return Quaternion(value["x"], value["y"], value["z"], value["w"])
TYPE_RECT2:
if value is Dictionary and value.has("position") and value.has("size"):
var p: Variant = _coerce_value(value["position"], TYPE_VECTOR2)
var s: Variant = _coerce_value(value["size"], TYPE_VECTOR2)
if p is Vector2 and s is Vector2:
return Rect2(p, s)
TYPE_RECT2I:
if value is Dictionary and value.has("position") and value.has("size"):
var p: Variant = _coerce_value(value["position"], TYPE_VECTOR2I)
var s: Variant = _coerce_value(value["size"], TYPE_VECTOR2I)
if p is Vector2i and s is Vector2i:
return Rect2i(p, s)
TYPE_AABB:
if value is Dictionary and value.has("position") and value.has("size"):
var p: Variant = _coerce_value(value["position"], TYPE_VECTOR3)
var s: Variant = _coerce_value(value["size"], TYPE_VECTOR3)
if p is Vector3 and s is Vector3:
return AABB(p, s)
TYPE_PLANE:
if value is Dictionary and value.has("normal") and value.has("d"):
var n: Variant = _coerce_value(value["normal"], TYPE_VECTOR3)
if n is Vector3:
return Plane(n, float(value["d"]))
TYPE_BASIS:
if value is Dictionary and value.has_all(["x", "y", "z"]):
var bx: Variant = _coerce_value(value["x"], TYPE_VECTOR3)
var by: Variant = _coerce_value(value["y"], TYPE_VECTOR3)
var bz: Variant = _coerce_value(value["z"], TYPE_VECTOR3)
if bx is Vector3 and by is Vector3 and bz is Vector3:
return Basis(bx, by, bz)
TYPE_TRANSFORM2D:
if value is Dictionary and value.has_all(["x", "y", "origin"]):
var tx: Variant = _coerce_value(value["x"], TYPE_VECTOR2)
var ty: Variant = _coerce_value(value["y"], TYPE_VECTOR2)
var to_: Variant = _coerce_value(value["origin"], TYPE_VECTOR2)
if tx is Vector2 and ty is Vector2 and to_ is Vector2:
return Transform2D(tx, ty, to_)
TYPE_TRANSFORM3D:
if value is Dictionary and value.has("basis") and value.has("origin"):
var b: Variant = _coerce_value(value["basis"], TYPE_BASIS)
var o: Variant = _coerce_value(value["origin"], TYPE_VECTOR3)
if b is Basis and o is Vector3:
return Transform3D(b, o)
TYPE_PROJECTION:
if value is Dictionary and value.has_all(VECTOR4_KEYS):
var px: Variant = _coerce_value(value["x"], TYPE_VECTOR4)
var py: Variant = _coerce_value(value["y"], TYPE_VECTOR4)
var pz: Variant = _coerce_value(value["z"], TYPE_VECTOR4)
var pw: Variant = _coerce_value(value["w"], TYPE_VECTOR4)
if px is Vector4 and py is Vector4 and pz is Vector4 and pw is Vector4:
return Projection(px, py, pz, pw)
# PackedByteArray intentionally unhandled — needs design decision
# (base64 string vs. raw int list); JSON has no native byte type.
return value
+229 -20
View File
@@ -6,14 +6,17 @@ const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd")
## Handles project settings and filesystem search commands.
const NodeHandler := preload("res://addons/godot_ai/handlers/node_handler.gd")
const RUN_READY_WAIT_SEC := 3.0
var _connection: McpConnection
var _debugger_plugin
var _editor_log_buffer
func _init(connection: McpConnection = null, debugger_plugin = null) -> void:
func _init(connection: McpConnection = null, debugger_plugin = null, editor_log_buffer = null) -> void:
_connection = connection
_debugger_plugin = debugger_plugin
_editor_log_buffer = editor_log_buffer
func get_project_setting(params: Dictionary) -> Dictionary:
@@ -81,16 +84,15 @@ func run_project(params: Dictionary) -> Dictionary:
# stop-not-running case in telemetry). Surface state via was_already_running
# so a caller wanting a *different* scene can detect and stop+restart.
if EditorInterface.is_playing_scene():
return {
"data": {
"mode": mode,
"scene": params.get("scene", ""),
"autosave": autosave,
"was_already_running": true,
"undoable": false,
"reason": "Project was already running; no action taken",
}
}
return _run_project_current_liveness_response(
_run_project_base_data(
mode,
str(params.get("scene", "")),
autosave,
true,
"Project was already running; no action taken"
)
)
var validation_error: Variant = null
if mode == "custom":
@@ -125,7 +127,7 @@ func run_project(params: Dictionary) -> Dictionary:
restore_setting = true
if _debugger_plugin != null:
_debugger_plugin.begin_game_run()
_debugger_plugin.begin_game_run(_editor_log_cursor(), _game_helper_autoload_expected())
match mode:
"main":
@@ -142,18 +144,225 @@ func run_project(params: Dictionary) -> Dictionary:
if _connection:
_connection.pause_processing = false
var base_data := _run_project_base_data(
mode,
str(params.get("scene", "")),
autosave,
false,
"Play/stop is a runtime action"
)
var request_id: String = params.get("_request_id", "")
if _connection != null and _debugger_plugin != null and not request_id.is_empty():
_finish_run_project_deferred(request_id, base_data)
return McpDispatcher.DEFERRED_RESPONSE
return _run_project_current_liveness_response(base_data)
func _editor_log_cursor() -> int:
return _editor_log_buffer.appended_total() if _editor_log_buffer != null else 0
func _game_helper_autoload_expected() -> bool:
return ProjectSettings.has_setting("autoload/_mcp_game_helper")
func _run_project_base_data(
mode: String,
scene: String,
autosave: bool,
was_already_running: bool,
reason: String
) -> Dictionary:
return {
"data": {
"mode": mode,
"scene": params.get("scene", ""),
"autosave": autosave,
"was_already_running": false,
"undoable": false,
"reason": "Play/stop is a runtime action",
}
"mode": mode,
"scene": scene,
"autosave": autosave,
"was_already_running": was_already_running,
"undoable": false,
"reason": reason,
}
func _run_project_current_liveness_response(base_data: Dictionary) -> Dictionary:
if _debugger_plugin == null:
return {"data": base_data}
var status: Dictionary = _debugger_plugin.get_game_status(-1, RUN_READY_WAIT_SEC)
## One-shot read — force a Debugger-tab scan so boot errors that landed
## after the last gated scan are in this response (#641).
var errors_info: Dictionary = _debugger_plugin.recent_editor_errors_since(int(status.get("editor_log_cursor", 0)), true)
return _run_project_response(base_data, _run_project_liveness_decision(status, errors_info))
func _finish_run_project_deferred(request_id: String, base_data: Dictionary) -> void:
var tree := _connection.get_tree()
while true:
await tree.process_frame
if not is_instance_valid(_connection):
return
var pre_status: Dictionary = _debugger_plugin.get_game_status(-1, RUN_READY_WAIT_SEC)
if (
not EditorInterface.is_playing_scene()
and int(pre_status.get("elapsed_msec", 0)) > 100
and str(pre_status.get("status", "stopped")) == "launching"
):
_debugger_plugin.end_game_run()
var status: Dictionary = _debugger_plugin.get_game_status(-1, RUN_READY_WAIT_SEC)
var errors_info: Dictionary = _debugger_plugin.recent_editor_errors_since(int(status.get("editor_log_cursor", 0)))
var decision := _run_project_liveness_decision(status, errors_info)
if not bool(decision.get("resolve", false)):
continue
## #641: the loop above polls with gated (cheap) scans; boot parse
## errors can land in the Errors tab in the same frames the run goes
## live. Re-gather once with a forced scan before replying so the
## response reports them instead of leaving them to a later
## logs_read. Rebuilding the decision with strictly-more errors can
## only keep it resolved (errors never un-resolve a decision).
errors_info = _debugger_plugin.recent_editor_errors_since(int(status.get("editor_log_cursor", 0)), true)
decision = _run_project_liveness_decision(status, errors_info)
_connection.send_deferred_response(request_id, _run_project_response(base_data, decision))
return
func _run_project_response(base_data: Dictionary, decision: Dictionary) -> Dictionary:
var data := base_data.duplicate(true)
var game_status: Dictionary = decision.get("game_status", {})
data["game_status"] = game_status
data["helper_live"] = bool(game_status.get("helper_live", false))
data["session_active"] = bool(game_status.get("session_active", false))
if bool(data.get("was_already_running", false)):
data["reason"] = _run_project_already_running_message(decision)
else:
data["reason"] = decision.get("message", data.get("reason", "Play/stop is a runtime action"))
data["recent_errors"] = decision.get("recent_errors", [])
data["recent_errors_scope"] = decision.get("recent_errors_scope", "none")
data["recent_errors_may_predate_run"] = decision.get("recent_errors_may_predate_run", false)
data["recent_errors_truncated"] = decision.get("recent_errors_truncated", false)
data.merge(McpDebuggerPlugin.split_errors_by_scope(data["recent_errors"], data["recent_errors_scope"]), true)
return {"data": data}
func _run_project_already_running_message(decision: Dictionary) -> String:
var state := str(decision.get("liveness_status", "unknown"))
match state:
"live":
var live_errors: Array = decision.get("recent_errors", [])
if not live_errors.is_empty() and str(decision.get("recent_errors_scope", "none")) == "run":
return (
"Project was already running; the Godot AI game helper is live, but %d editor error%s surfaced during this run (first: %s). Check logs_read(source='editor', include_details=true)."
% [live_errors.size(), "s" if live_errors.size() != 1 else "", _format_editor_error_summary(live_errors[0])]
)
return "Project was already running; the Godot AI game helper is live."
"not_live":
var errors: Array = decision.get("recent_errors", [])
var scope := str(decision.get("recent_errors_scope", "none"))
if not errors.is_empty() and scope == "run":
return "Project was already running but failed to load before the Godot AI game helper registered: %s. Check logs_read(source='editor', include_details=true)." % _format_editor_error_summary(errors[0])
if not errors.is_empty():
return "Project was already running but is not responding. A recent editor error may be related, but may predate this run: %s. Check logs_read(source='editor', include_details=true)." % _format_editor_error_summary(errors[0])
return "Project was already running but did not become live before the helper-ready window elapsed. Check logs_read(source='editor', include_details=true) and poll editor_state."
"break":
var break_errors: Array = decision.get("recent_errors", [])
if not break_errors.is_empty() and str(decision.get("recent_errors_scope", "none")) == "run":
return "Project was already running but the game is parked at a debugger break: %s. Call project_manage(op='stop') to end the run, fix the error, and relaunch." % _format_editor_error_summary(break_errors[0])
if not break_errors.is_empty():
return "Project was already running but the game is parked at a debugger break. A recent editor error may be related, but may predate this run: %s. Call project_manage(op='stop') to end the run." % _format_editor_error_summary(break_errors[0])
return "Project was already running but the game is parked at a debugger break. Call project_manage(op='stop') to end the run; the break reason is in the editor's Debugger panel."
"no_helper":
return "Project was already running, but no _mcp_game_helper autoload is expected. Headless or custom-main-loop projects cannot confirm helper liveness."
"launching":
return "Project was already running and is still waiting for the Godot AI game helper to register. Poll editor_state shortly."
"stopped":
return "Project was already marked playing by the editor, but no active game liveness run exists."
_:
return "Project was already running; current liveness status is %s." % state
func _run_project_liveness_decision(status: Dictionary, errors_info: Dictionary = {}) -> Dictionary:
var enriched_status := McpDebuggerPlugin.with_liveness_flags(status)
var state := str(status.get("status", "stopped"))
var recent_errors: Array = errors_info.get("errors", [])
var errors_scope := str(errors_info.get("scope", "none"))
var truncated := bool(errors_info.get("truncated", false))
var correlated_error := not recent_errors.is_empty() and errors_scope == "run"
var elapsed_msec := int(status.get("elapsed_msec", 0))
var ready_wait_msec := int(status.get("ready_wait_msec", int(RUN_READY_WAIT_SEC * 1000.0)))
var decision := {
"resolve": false,
"game_status": enriched_status,
"liveness_status": state,
"recent_errors": recent_errors,
"recent_errors_scope": errors_scope,
"recent_errors_may_predate_run": errors_scope == "retained_recent",
"recent_errors_truncated": truncated,
"message": "",
}
if state == "live":
decision["resolve"] = true
if correlated_error:
## #641: "live" only means the helper autoload registered — scripts
## can still have failed to parse or load during boot (a broken
## node script does not stop the game from running). Surface those
## errors in the success message so agents don't read a clean
## launch into a run that silently lost scripts.
decision["message"] = (
"Game launched and the Godot AI game helper is live, but %d editor error%s surfaced during startup (first: %s) — likely a script that failed to parse or load. Check logs_read(source='editor', include_details=true)."
% [recent_errors.size(), "s" if recent_errors.size() != 1 else "", _format_editor_error_summary(recent_errors[0])]
)
if truncated:
decision["message"] += " Editor logs since this run may be truncated; showing retained errors."
else:
decision["message"] = "Game launched and the Godot AI game helper is live."
elif state == "break":
## #645: the game process is parked in a remote-debugger break. A
## boot-time parse error (GDScriptLanguage::debug_break_parse) produces
## no Errors-tab row, no Logger entry, and no game-log line — the
## synthesized break record is the only evidence, and it lands a
## moment after the break signal (stack frames arrive async). Wait for
## it (correlated_error) before resolving; the ready window is the
## fallback if synthesis never lands.
var break_info: Dictionary = status.get("break", {})
var break_reason := str(break_info.get("reason", ""))
if bool(break_info.get("pre_live", true)):
decision["resolve"] = correlated_error or elapsed_msec >= ready_wait_msec
var summary := break_reason
if correlated_error:
summary = _format_editor_error_summary(recent_errors[0])
if summary.is_empty():
summary = "script parse/load error (reason not captured)"
decision["message"] = "Game hit a script error during startup and is frozen at a debugger break before the Godot AI game helper registered: %s. The run cannot continue; call project_manage(op='stop'), fix the error, and relaunch. Check logs_read(source='editor', include_details=true)." % summary
else:
var reason_suffix := (": %s" % break_reason) if not break_reason.is_empty() else ""
decision["resolve"] = true
decision["message"] = "Game is paused at a debugger break%s. Resume it from the editor's Debugger panel or call project_manage(op='stop')." % reason_suffix
elif correlated_error:
decision["resolve"] = true
decision["liveness_status"] = "not_live"
decision["message"] = "Game launched but failed to load before the Godot AI game helper registered: %s. Check logs_read(source='editor', include_details=true)." % _format_editor_error_summary(recent_errors[0])
if truncated:
decision["message"] += " Editor logs since this run may be truncated; showing retained errors."
elif state == "not_live":
decision["resolve"] = true
if not recent_errors.is_empty():
decision["message"] = "Game launched but is not responding. A recent editor error may be related, but may predate this run: %s. Check logs_read(source='editor', include_details=true)." % _format_editor_error_summary(recent_errors[0])
else:
decision["message"] = "Game launched but did not become live before the helper-ready window elapsed. It may still be booting or may have failed silently; check logs_read(source='editor', include_details=true) and poll editor_state."
elif state == "no_helper":
decision["resolve"] = true
decision["message"] = "Game launched, but no _mcp_game_helper autoload is expected. Headless or custom-main-loop projects cannot confirm helper liveness; use editor_state and viewport/editor tools where applicable."
elif state == "stopped":
decision["resolve"] = true
decision["message"] = "The play session stopped, or no active game liveness run exists, before the Godot AI game helper became live."
elif state == "launching" and elapsed_msec >= ready_wait_msec:
decision["resolve"] = true
decision["message"] = "Game launched but is not yet live after %.1fs; it may still be booting. Poll editor_state and check logs_read(source='editor', include_details=true)." % (float(elapsed_msec) / 1000.0)
return decision
func _format_editor_error_summary(entry: Dictionary) -> String:
return McpSurfacedErrorTracker.format_editor_error_summary(entry)
func stop_project(params: Dictionary) -> Dictionary:
# Idempotent: a project that's already stopped satisfies the caller's intent.
# Returning INVALID_PARAMS here was the largest single source of fleet-wide
+177 -25
View File
@@ -178,19 +178,10 @@ func create_resource(params: Dictionary) -> Dictionary:
return home_err
var has_file_target := not resource_path.is_empty()
var class_err := _validate_resource_class(type_str)
if class_err != null:
return class_err
var instance := ClassDB.instantiate(type_str)
if instance == null:
return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Failed to instantiate %s" % type_str)
if not (instance is Resource):
return ErrorCodes.make(
ErrorCodes.INTERNAL_ERROR,
"Instantiated %s but result is not a Resource (got %s)" % [type_str, instance.get_class()]
)
var res: Resource = instance
var made := _instantiate_resource(type_str)
if made is Dictionary:
return made
var res: Resource = made
if not properties.is_empty():
var apply_err := _apply_resource_properties(res, properties)
@@ -226,6 +217,75 @@ static func _validate_resource_class(type_str: String) -> Variant:
return null
## Build the "Unknown resource type" error with a steer toward op="scan". A type
## that reaches here is neither an engine built-in (ClassDB) nor a registered
## project class (the global script-class registry). In an agent-driven workflow
## the most common cause is a `class_name` script just made via script_create
## that isn't registered yet — the global class table only rebuilds on a
## filesystem scan (normally an editor-focus event). Point the caller at the one
## cheap call that fixes that, so it doesn't fall back to a full plugin reload.
## See #614 for the headless scan op.
static func _unknown_resource_type_error(type_str: String) -> Dictionary:
return ErrorCodes.make(
ErrorCodes.VALUE_OUT_OF_RANGE,
(
"Unknown resource type: %s — not an engine built-in or a registered project class. "
+ "If you just created it with script_create, the global class table is stale until a "
+ "scan: call filesystem_manage(op=\"scan\"), then retry. Otherwise check the spelling."
) % type_str
)
## Resolve a resource type name to a fresh instance. Handles engine built-ins
## (ClassDB) and project `class_name` Resources (the global script-class
## registry). Returns a Resource on success, or an error dict on failure.
static func _instantiate_resource(type_str: String) -> Variant:
if ClassDB.class_exists(type_str):
var class_err: Variant = _validate_resource_class(type_str)
if class_err != null:
return class_err
var built_in := ClassDB.instantiate(type_str)
if built_in == null or not (built_in is Resource):
return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Failed to instantiate %s as a Resource" % type_str)
return built_in
for entry in ProjectSettings.get_global_class_list():
if entry.get("class", "") == type_str:
var script_path: String = entry.get("path", "")
var scr: Variant = load(script_path)
# Reject non-Resource script classes BEFORE constructing them:
# scr.new() runs _init(), and an @tool class_name extending a
# non-RefCounted type (e.g. Node) would otherwise build — and leak —
# an orphan instance this path never frees. get_instance_base_type()
# resolves to the native base, so multi-level custom Resource
# hierarchies (B extends A extends Resource) still pass.
var base_or_err: Variant = _script_base_type_or_error(scr, type_str, script_path)
if base_or_err is Dictionary:
return base_or_err
var base_type: StringName = base_or_err
if not ClassDB.is_parent_class(base_type, "Resource"):
return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "%s is not a Resource type (extends %s)" % [type_str, base_type])
if not scr.can_instantiate():
return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "%s cannot be instantiated in the editor (abstract, or a non-@tool script — add @tool to instantiate it here)" % type_str)
# Reject scripts whose _init() requires arguments BEFORE scr.new():
# scr.new() passes no args, so a required-arg _init raises and aborts
# this handler mid-call, null-cascading into a generic "malformed
# result" error instead of a clean rejection. get_script_method_list()
# reports the effective (incl. inherited) _init; required args =
# args - default_args. Statically detectable only — a _init that runs
# but throws still falls through to scr.new() and the dispatcher catch.
for method in scr.get_script_method_list():
if method.get("name", "") == "_init":
var required_args: int = (method.get("args", []) as Array).size() - (method.get("default_args", []) as Array).size()
if required_args > 0:
return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "%s cannot be instantiated: its _init() requires arguments" % type_str)
break
var made: Variant = scr.new()
if made == null or not (made is Resource):
return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Failed to instantiate %s as a Resource" % type_str)
return made
return _unknown_resource_type_error(type_str)
## Apply a dict of property values to a freshly-instantiated Resource,
## reusing NodeHandler's coercion so Vector3/Color/etc. dicts land typed.
## Returns null on success or an error dict on failure.
@@ -240,9 +300,17 @@ static func _apply_resource_properties(res: Resource, properties: Dictionary) ->
if prop.get("usage", 0) & PROPERTY_USAGE_EDITOR:
valid.append(prop.name)
valid.sort()
# Name the script's class_name (e.g. MyTestResource) rather than the
# native base (Resource) so the hint names the type the agent created,
# and point at the real MCP verb — resource_manage(op="get_info") now
# answers for project class_name Resources too.
var type_label := res.get_class()
var res_script: Variant = res.get_script()
if res_script is Script and not String(res_script.get_global_name()).is_empty():
type_label = String(res_script.get_global_name())
var err := ErrorCodes.make(
ErrorCodes.PROPERTY_NOT_ON_CLASS,
"Property '%s' not found on %s. Call resource_get_info('%s') to list available properties." % [key, res.get_class(), res.get_class()]
"Property '%s' not found on %s. Call resource_manage(op=\"get_info\", params={\"type\": \"%s\"}) to list available properties." % [key, type_label, type_label]
)
err["error"]["data"] = {"valid_properties": valid}
return err
@@ -270,16 +338,15 @@ static func _apply_resource_properties(res: Resource, properties: Dictionary) ->
# resource_create/environment_create callers can populate
# sub-resource slots (ShaderMaterial.shader, etc.) in one shot.
var sub_type: String = v.get("__class__", "")
var class_err := _validate_resource_class(sub_type)
if class_err != null:
return class_err
var sub_instance := ClassDB.instantiate(sub_type)
if sub_instance == null or not (sub_instance is Resource):
return ErrorCodes.make(
ErrorCodes.INTERNAL_ERROR,
"Failed to instantiate %s as a Resource for property '%s'" % [sub_type, key]
)
var sub_res: Resource = sub_instance
# Resolve via the shared helper so the nested shortcut accepts both
# engine built-ins (ClassDB) and project `class_name` Resources,
# exactly like the top-level resource_create path.
var sub_made := _instantiate_resource(sub_type)
if sub_made is Dictionary:
# Preserve the property-slot context the inline path used to add.
sub_made["error"]["message"] = "%s (for property '%s')" % [sub_made["error"]["message"], key]
return sub_made
var sub_res: Resource = sub_made
var remaining: Dictionary = (v as Dictionary).duplicate()
remaining.erase("__class__")
if not remaining.is_empty():
@@ -361,7 +428,13 @@ func get_resource_info(params: Dictionary) -> Dictionary:
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: type")
if not ClassDB.class_exists(type_str):
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Unknown resource type: %s" % type_str)
# Project class_name Resources aren't in ClassDB; resolve them through the
# global script-class registry so get_info answers for the same custom
# types resource_create can make. Read-only — never instantiates.
var custom_info: Variant = _custom_resource_info(type_str)
if custom_info != null:
return custom_info
return _unknown_resource_type_error(type_str)
if ClassDB.is_parent_class(type_str, "Node"):
return ErrorCodes.make(
ErrorCodes.WRONG_TYPE,
@@ -396,3 +469,82 @@ func get_resource_info(params: Dictionary) -> Dictionary:
data["concrete_subclasses"] = class_info.concrete_inheritors
return {"data": data}
## Resolve a loaded global-class script to its native base type, or an error if
## the script failed to load (not a Script) or to compile (empty base type).
## Shared by the create and get_info custom-Resource paths so both report a
## compile failure rather than a misleading "is not a Resource type (extends )".
static func _script_base_type_or_error(scr: Variant, type_str: String, script_path: String) -> Variant:
if not (scr is Script):
return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Failed to load script class %s from %s" % [type_str, script_path])
var base_type: StringName = scr.get_instance_base_type()
if String(base_type).is_empty():
return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "%s failed to compile or parse (script %s)" % [type_str, script_path])
return base_type
## get_info for a project `class_name` Resource (not in ClassDB). Returns an info
## dict, an error dict (for a class_name whose native base is not a Resource), or
## null if `type_str` is not a registered global class. Read-only: resolves
## properties from the script + its native base WITHOUT instantiating (no _init()).
static func _custom_resource_info(type_str: String) -> Variant:
for entry in ProjectSettings.get_global_class_list():
if entry.get("class", "") != type_str:
continue
var script_path: String = entry.get("path", "")
var scr: Variant = load(script_path)
var base_or_err: Variant = _script_base_type_or_error(scr, type_str, script_path)
if base_or_err is Dictionary:
return base_or_err
var base_type: StringName = base_or_err
if not ClassDB.is_parent_class(base_type, "Resource"):
return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "%s is not a Resource type (extends %s)" % [type_str, base_type])
var can_instantiate: bool = scr.can_instantiate()
# Inherited (native) properties come from the engine base via ClassDB...
var class_info := ClassIntrospection.build(String(base_type), {
"sections": ["properties"],
"include_inherited": true,
"limit": 0,
})
var props: Array = []
for native_prop in class_info.properties:
props.append(native_prop)
# ...and the script's own (and inherited script) exported properties come
# from the Script itself, so we never construct the resource. A real
# default isn't available without instantiating, so script props carry an
# explicit null — keeping one uniform key set across the array (native
# props carry their real default).
for raw_prop in scr.get_script_property_list():
var prop: Dictionary = raw_prop
var usage := int(prop.get("usage", 0))
if not (usage & PROPERTY_USAGE_EDITOR):
continue
props.append({
"name": str(prop.get("name", "")),
"type": type_string(int(prop.get("type", TYPE_NIL))),
"class_name": str(prop.get("class_name", "")),
"hint": int(prop.get("hint", PROPERTY_HINT_NONE)),
"hint_string": str(prop.get("hint_string", "")),
"usage": usage,
"default": null,
})
props.sort_custom(func(a, b): return a.name < b.name)
# parent_class is the immediate script parent when there is one (so a
# multi-level chain B -> A -> Resource reports A), else the native base.
var parent_name := String(base_type)
var base_script: Variant = scr.get_base_script()
if base_script is Script and not String(base_script.get_global_name()).is_empty():
parent_name = String(base_script.get_global_name())
return {"data": {
"type": type_str,
"parent_class": parent_name,
"can_instantiate": can_instantiate,
# is_abstract reflects real abstractness (the @abstract annotation),
# NOT editor-instantiability — a non-@tool concrete Resource has
# can_instantiate()==false in-editor but is not abstract.
"is_abstract": scr.is_abstract(),
"properties": props,
"property_count": props.size(),
}}
return null
+91 -8
View File
@@ -143,9 +143,17 @@ func create_scene(params: Dictionary) -> Dictionary:
}
## How long open_scene waits for the editor to actually switch to the
## requested scene before replying switched=false. Tab switches normally land
## within a few frames; keep this under the dispatcher's 4500 ms deferred
## default so the coroutine always answers before DEFERRED_TIMEOUT fires.
const _OPEN_SETTLE_MAX_MSEC := 3000
## Open an existing scene by file path.
func open_scene(params: Dictionary) -> Dictionary:
var path: String = params.get("path", "")
var force_reload: bool = params.get("force_reload", false)
if path.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: path")
@@ -156,16 +164,91 @@ func open_scene(params: Dictionary) -> Dictionary:
if not ResourceLoader.exists(path):
return ErrorCodes.make(ErrorCodes.RESOURCE_NOT_FOUND, "Scene not found: %s" % path)
EditorInterface.open_scene_from_path(path)
return {
"data": {
"path": path,
"undoable": false,
"reason": "Scene navigation cannot be undone via editor undo",
}
var scene_root := EditorInterface.get_edited_scene_root()
var current_path := scene_root.scene_file_path if scene_root else ""
## Instance id of the root at call time. A completed open OR reload always
## replaces the edited-scene root with a NEW instance, so this is the
## reliable completion signal — unlike scene_file_path, which is unchanged
## across a force_reload of the already-open scene (#633 review).
var prev_root_id := scene_root.get_instance_id() if scene_root else 0
var payload := {
"path": path,
"force_reload": force_reload,
"reloaded_from_disk": false,
"previous_scene_path": current_path,
"undoable": false,
"reason": "Scene navigation cannot be undone via editor undo",
}
if current_path == path and not force_reload:
## Already the edited scene — nothing switches, reply immediately.
payload["switched"] = true
payload["settle"] = "already_current"
return {"data": payload}
if force_reload and current_path == path:
EditorInterface.reload_scene_from_path(path)
payload["reloaded_from_disk"] = true
else:
EditorInterface.open_scene_from_path(path)
## The tab switch completes asynchronously; replying now lets an immediate
## follow-up write land on the PREVIOUS scene (#633 — a scene_save issued
## right after open_scene saved the old scene). Defer the reply until the
## edited scene actually is `path` AND its root is a fresh instance, so
## success means "the editor is now editing the (re)loaded scene".
var request_id: String = params.get("_request_id", "")
if _connection != null and not request_id.is_empty():
_finish_open_scene_deferred(_connection, request_id, path, prev_root_id, payload)
return McpDispatcher.DEFERRED_RESPONSE
## Synchronous fallback (batch_execute and unit-test contexts can't await):
## preserve the old reply-immediately behavior, flagged as not waited on.
payload["switched"] = false
payload["settle"] = "not_waited"
return {"data": payload}
## `static` is load-bearing (same reason as FilesystemHandler's deferred scan
## finish): the coroutine must outlive this RefCounted handler, which can be
## freed mid-await by an editor_reload_plugin. Parameterise everything;
## reference no instance state.
static func _finish_open_scene_deferred(
connection: McpConnection,
request_id: String,
path: String,
prev_root_id: int,
payload: Dictionary,
) -> void:
if not is_instance_valid(connection):
return
var tree := connection.get_tree()
if tree == null:
return
# Hand back a frame so _dispatch() registers this request as deferred
# before the coroutine can push a reply.
await tree.process_frame
var deadline_ms := Time.get_ticks_msec() + _OPEN_SETTLE_MAX_MSEC
while Time.get_ticks_msec() < deadline_ms:
var root := EditorInterface.get_edited_scene_root()
# Require BOTH the target path AND a fresh root instance: a
# force_reload keeps scene_file_path == path across the reload, so the
# instance swap is what proves the (re)load actually completed rather
# than the coroutine settling on the stale pre-reload root.
if root != null and root.scene_file_path == path and root.get_instance_id() != prev_root_id:
if not is_instance_valid(connection):
return
payload["switched"] = true
payload["settle"] = "settled"
connection.send_deferred_response(request_id, {"data": payload})
return
await tree.process_frame
if not is_instance_valid(connection):
return
payload["switched"] = false
payload["settle"] = "timeout"
connection.send_deferred_response(request_id, {"data": payload})
## Save the currently edited scene.
## Pauses WebSocket processing during save to prevent re-entrant _process()
+74 -10
View File
@@ -3,7 +3,7 @@ extends RefCounted
const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd")
const DiagnosticsCapture := preload("res://addons/godot_ai/utils/diagnostics_capture.gd")
const LoggerLoader := preload("res://addons/godot_ai/runtime/logger_loader.gd")
const ValidationLogger := preload("res://addons/godot_ai/runtime/validation_logger.gd")
## Handles script creation, reading, attaching, detaching, and symbol inspection.
@@ -63,6 +63,33 @@ func create_script(params: Dictionary) -> Dictionary:
}
_attach_gdscript_diagnostics(data, path, content)
# A freshly-declared `class_name` is NOT in the global class table until a
# filesystem scan runs — update_file() below registers the file with the
# resource pipeline but not the class registry (see the scan() comment).
# Surface that precisely (only when the class isn't already registered) so a
# headless caller knows to follow up with filesystem_manage(op="scan")
# instead of hitting a confusing "Unknown type" / "Unknown resource type" on
# the very next call. We don't scan here — a scan() per create is the exact
# SIGABRT race documented below; the explicit op is single-flight.
# Skip the hint when the script failed to parse: a scan won't register a
# class from a broken script, so pointing at op="scan" would steer the caller
# away from the real fix (the parse error already attached above).
var declared_class := _extract_class_name(content)
if (
not declared_class.is_empty()
and not _script_has_error_diagnostics(data)
and not _class_name_registered(declared_class)
):
data["class_name"] = declared_class
data["class_registration"] = "scan_required"
data["class_registration_hint"] = (
"New class_name '%s' isn't in the global class table yet. " % declared_class
+ "Call filesystem_manage(op=\"scan\") if it won't resolve on the next "
+ "call (e.g. resource_manage op=\"create\", or used as a type in another "
+ "script). The editor also registers it on its next filesystem scan or "
+ "when its window regains focus."
)
# Register just this file with the editor instead of a full recursive
# scan(). A scan() per write stacks `update_scripts_classes` /
# `update_script_paths_documentation` WorkerThreadPool tasks under concurrent
@@ -142,6 +169,48 @@ static func _finish_create_script_deferred(
connection.send_deferred_response(request_id, {"data": payload})
## Extract the `class_name` a script declares, or "" if none. A cheap line scan
## (no full parse) for create_script's "scan_required" hint. Stops at the first
## space/tab or comma so all three valid forms yield just the name:
## `class_name Foo`, `class_name Foo extends Bar`, and the icon form
## `class_name Foo, "res://icon.svg"`.
static func _extract_class_name(content: String) -> String:
for raw_line in content.split("\n"):
var line := raw_line.strip_edges()
if line.begins_with("class_name "):
var rest := line.substr(11).strip_edges()
var cut := rest.length()
for i in rest.length():
var ch := rest[i]
if ch == " " or ch == "\t" or ch == ",":
cut = i
break
return rest.substr(0, cut)
return ""
## True if create_script's diagnostics captured a parse error for this script.
## Used to suppress the "scan_required" hint when the class can't register
## anyway — see create_script.
static func _script_has_error_diagnostics(data: Dictionary) -> bool:
for diag in data.get("diagnostics", []):
if diag is Dictionary and diag.get("level", "") == "error":
return true
return false
## True if `cn` is already usable as a type — an engine built-in (ClassDB) or an
## already-registered project global class. A brand-new class_name returns false
## until a filesystem scan registers it.
static func _class_name_registered(cn: String) -> bool:
if ClassDB.class_exists(cn):
return true
for entry in ProjectSettings.get_global_class_list():
if entry.get("class", "") == cn:
return true
return false
func read_script(params: Dictionary) -> Dictionary:
var path: String = params.get("path", "")
@@ -204,21 +273,16 @@ static func _validate_gdscript_source(content: String) -> Dictionary:
}
static func _capture_gdscript_load_diagnostics(path: String) -> Dictionary:
if not (ClassDB.class_exists("Logger") and OS.has_method("add_logger") and OS.has_method("remove_logger")):
return _empty_diagnostics_capture()
var logger_script := LoggerLoader.build(LoggerLoader.VALIDATION_LOGGER_PATH)
if logger_script == null:
return _empty_diagnostics_capture()
func _capture_gdscript_load_diagnostics(path: String) -> Dictionary:
var buffer := McpEditorLogBuffer.new()
var logger = logger_script.new(buffer)
var logger := ValidationLogger.new(buffer)
var capture := DiagnosticsCapture.capture_this_file(buffer, path, func() -> Dictionary:
OS.call("add_logger", logger)
OS.add_logger(logger)
# ResourceLoader.load() reports parse failure instead of throwing, and
# a failed GDScript parse does not execute user code; remove immediately
# after the synchronous load to keep the private capture window tiny.
ResourceLoader.load(path, "", ResourceLoader.CACHE_MODE_IGNORE)
OS.call("remove_logger", logger)
OS.remove_logger(logger)
return {}
)
return capture
+12 -2
View File
@@ -152,7 +152,7 @@ func connect_signal(params: Dictionary) -> Dictionary:
return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, "Signal '%s' already connected to %s.%s" % [signal_name, params.target, method])
_undo_redo.create_action("MCP: Connect signal %s" % signal_name)
_undo_redo.add_do_method(source, "connect", signal_name, callable)
_undo_redo.add_do_method(source, "connect", signal_name, callable, Object.CONNECT_PERSIST)
_undo_redo.add_undo_method(source, "disconnect", signal_name, callable)
_undo_redo.commit_action()
@@ -174,9 +174,19 @@ func disconnect_signal(params: Dictionary) -> Dictionary:
if not source.is_connected(signal_name, callable):
return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, "Signal '%s' is not connected to %s.%s" % [signal_name, params.target, method])
# Capture the connection's current flags so undo restores it exactly as it
# was, not unconditionally as CONNECT_PERSIST. Hardcoding PERSIST here would
# silently promote a runtime-only connection into one that serializes on the
# next save. (The connection still exists at this point — checked above.)
var reconnect_flags := 0
for conn in source.get_signal_connection_list(signal_name):
if conn.get("callable", Callable()) == callable:
reconnect_flags = int(conn.get("flags", 0))
break
_undo_redo.create_action("MCP: Disconnect signal %s" % signal_name)
_undo_redo.add_do_method(source, "disconnect", signal_name, callable)
_undo_redo.add_undo_method(source, "connect", signal_name, callable)
_undo_redo.add_undo_method(source, "connect", signal_name, callable, reconnect_flags)
_undo_redo.commit_action()
return {"data": _signal_response(source, signal_name, target, method, scene_root)}
+28 -1
View File
@@ -30,7 +30,11 @@ func run_tests(params: Dictionary) -> Dictionary:
discovery.errors.size(),
", ".join(discovery.errors),
]
return {"data": {"error": msg, "total": 0, "load_errors": discovery.errors}}
var no_suites := {"error": msg, "total": 0, "load_errors": discovery.errors}
## Keep the edited_scene annotation on the no-suites error payload too,
## so the response contract is consistent across every return path.
_annotate_edited_scene(no_suites)
return {"data": no_suites}
var ctx := {
"undo_redo": _undo_redo,
@@ -40,9 +44,32 @@ func run_tests(params: Dictionary) -> Dictionary:
var results := _runner.run_suites(suites, suite_filter, test_filter, ctx, verbose, exclude_test_filter)
if not discovery.errors.is_empty():
results["load_errors"] = discovery.errors
_annotate_edited_scene(results)
return {"data": results}
## Many suites assume the project's main scene is the edited scene (they read
## /Main/... nodes directly). Running with another scene open produces a flood
## of phantom failures that look like real regressions. Surface the edited
## scene and a warning when it differs from run/main_scene so the failures are
## attributable at a glance instead of costing a debugging round (#635).
func _annotate_edited_scene(results: Dictionary) -> void:
var scene_root := EditorInterface.get_edited_scene_root()
var edited := scene_root.scene_file_path if scene_root else ""
results["edited_scene"] = edited
var main_scene := str(ProjectSettings.get_setting("application/run/main_scene", ""))
if main_scene.is_empty() or edited == main_scene:
return
if int(results.get("failed", 0)) <= 0:
return
results["scene_warning"] = (
"Edited scene is '%s' but the project main scene is '%s'. Many suites "
% [edited if not edited.is_empty() else "<none>", main_scene]
+ "assume the main scene is open and will report phantom failures "
+ "otherwise. If these failures are unexpected, scene_open('%s') and re-run." % main_scene
)
func get_test_results(params: Dictionary) -> Dictionary:
var verbose: bool = params.get("verbose", false)
return {"data": _runner.get_results(verbose)}
+163
View File
@@ -0,0 +1,163 @@
@tool
extends RefCounted
## TileMap / TileMapLayer authoring — set, fill, clear, and read tile cells
## directly in the editor scene with full undo/redo support.
##
## All ops target TileMapLayer nodes in the currently edited scene by
## scene-relative path (e.g. "/LavaLake20x20/Ground").
const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd")
const MAX_RECT_FILL_CELLS := 4096
var _undo_redo: EditorUndoRedoManager
func _init(undo_redo: EditorUndoRedoManager) -> void:
_undo_redo = undo_redo
## Set a single tile cell.
## params: {path, source_id, atlas_col, atlas_row, map_x, map_y}
## Returns: {map_x, map_y, source_id, atlas_col, atlas_row}
func set_cell(params: Dictionary) -> Dictionary:
var layer := _resolve_layer(params)
if layer.has("error"): return layer
var node: TileMapLayer = layer.node
var pos := Vector2i(params.get("map_x", 0), params.get("map_y", 0))
var src := int(params.get("source_id", 0))
var atlas := Vector2i(params.get("atlas_col", 0), params.get("atlas_row", 0))
var prev := _capture_cell_state(node, pos)
_undo_redo.create_action("MCP: TileMap set_cell")
_undo_redo.add_do_method(node, "set_cell", pos, src, atlas)
_undo_redo.add_undo_method(self, "_restore_cell_state", node, pos, prev)
_undo_redo.commit_action()
return {"data": {"map_x": pos.x, "map_y": pos.y, "source_id": src,
"atlas_col": atlas.x, "atlas_row": atlas.y, "undoable": true}}
## Fill a rectangular region with one tile type in a single undo action.
## params: {path, source_id, atlas_col, atlas_row, rect_x, rect_y, rect_w, rect_h}
## Returns: {cells_filled, rect: {x, y, w, h}}
func set_cells_rect(params: Dictionary) -> Dictionary:
var layer := _resolve_layer(params)
if layer.has("error"): return layer
var node: TileMapLayer = layer.node
var src := int(params.get("source_id", 0))
var atlas := Vector2i(params.get("atlas_col", 0), params.get("atlas_row", 0))
var rx := int(params.get("rect_x", 0)); var ry := int(params.get("rect_y", 0))
var rw := int(params.get("rect_w", 1)); var rh := int(params.get("rect_h", 1))
if rw <= 0 or rh <= 0:
return ErrorCodes.make(
ErrorCodes.VALUE_OUT_OF_RANGE,
"rect_w and rect_h must be > 0 (got %d x %d)" % [rw, rh]
)
var cell_count := rw * rh
if cell_count > MAX_RECT_FILL_CELLS:
return ErrorCodes.make(
ErrorCodes.VALUE_OUT_OF_RANGE,
"Rect too large: %d cells exceeds max %d" % [cell_count, MAX_RECT_FILL_CELLS]
)
var cells: Array[Vector2i] = []
var snapshot: Array[Dictionary] = []
for x in range(rx, rx + rw):
for y in range(ry, ry + rh):
var pos := Vector2i(x, y)
cells.append(pos)
snapshot.append({"pos": pos, "state": _capture_cell_state(node, pos)})
_undo_redo.create_action("MCP: TileMap set_cells_rect %dx%d" % [rw, rh])
for pos in cells:
_undo_redo.add_do_method(node, "set_cell", pos, src, atlas)
_undo_redo.add_undo_method(self, "_restore_rect_snapshot", node, snapshot)
_undo_redo.commit_action()
return {"data": {"cells_filled": cells.size(),
"rect": {"x": rx, "y": ry, "w": rw, "h": rh}, "undoable": true}}
## Remove all tiles from a TileMapLayer.
## params: {path}
## Returns: {cleared: true}
func clear_layer(params: Dictionary) -> Dictionary:
var layer := _resolve_layer(params)
if layer.has("error"): return layer
var node: TileMapLayer = layer.node
var snapshot := _capture_used_cells_snapshot(node)
_undo_redo.create_action("MCP: TileMap clear")
_undo_redo.add_do_method(node, "clear")
_undo_redo.add_undo_method(self, "_restore_cells_snapshot", node, snapshot)
_undo_redo.commit_action()
return {"data": {"cleared": true, "undoable": true}}
## Return all used cell coordinates.
## params: {path}
## Returns: {cells: [{x, y}, ...], count: int}
func get_used_cells(params: Dictionary) -> Dictionary:
var layer := _resolve_layer(params)
if layer.has("error"): return layer
var node: TileMapLayer = layer.node
var cells := node.get_used_cells()
var result: Array = []
for c in cells:
result.append({"x": c.x, "y": c.y})
return {"data": {"cells": result, "count": result.size()}}
## Resolve a TileMapLayer node from params["path"] in the currently edited
## scene. Returns {"node": TileMapLayer} on success, or an error dict.
func _resolve_layer(params: Dictionary) -> Dictionary:
var path: String = params.get("path", "")
var scene_file: String = params.get("scene_file", "")
var resolved := McpNodeValidator.resolve_or_error(path, "path", scene_file)
if resolved.has("error"):
return resolved
var node: Node = resolved.node
if not node is TileMapLayer:
return ErrorCodes.make(ErrorCodes.WRONG_TYPE,
"Node is not a TileMapLayer: %s" % path)
return {"node": node}
func _capture_cell_state(node: TileMapLayer, pos: Vector2i) -> Dictionary:
var source_id := node.get_cell_source_id(pos)
if source_id == -1:
return {"has_tile": false}
var atlas: Vector2i = node.get_cell_atlas_coords(pos)
var alternative := node.get_cell_alternative_tile(pos)
return {
"has_tile": true,
"source_id": source_id,
"atlas_col": atlas.x,
"atlas_row": atlas.y,
"alternative": alternative,
}
func _capture_used_cells_snapshot(node: TileMapLayer) -> Array[Dictionary]:
var snapshot: Array[Dictionary] = []
for pos in node.get_used_cells():
snapshot.append({"pos": pos, "state": _capture_cell_state(node, pos)})
return snapshot
func _restore_cells_snapshot(node: TileMapLayer, snapshot: Array[Dictionary]) -> void:
node.clear()
for entry in snapshot:
_restore_cell_state(node, entry.pos, entry.state)
func _restore_rect_snapshot(node: TileMapLayer, snapshot: Array[Dictionary]) -> void:
for entry in snapshot:
_restore_cell_state(node, entry.pos, entry.state)
func _restore_cell_state(node: TileMapLayer, pos: Vector2i, state: Dictionary) -> void:
if not state.get("has_tile", false):
node.erase_cell(pos)
return
node.set_cell(
pos,
int(state.get("source_id", -1)),
Vector2i(int(state.get("atlas_col", -1)), int(state.get("atlas_row", -1))),
int(state.get("alternative", 0))
)
@@ -0,0 +1 @@
uid://cm8s7a0ey2q6k
+174
View File
@@ -0,0 +1,174 @@
@tool
extends RefCounted
## TileSet management — atlas inspection helpers.
const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd")
func _init() -> void:
pass
## Query all occupied atlas tile positions for a single source.
##
## params:
## tileset_path — res:// path to the TileSet resource (required, non-empty)
## source_id — raw TileSet source id (required)
##
## Returns:
## {"data": {"tiles": [{"col": int, "row": int}, ...], "count": int}}
## on success (including empty sources, where tiles=[] and count=0)
## ErrorCodes.make(code, message) on any validation or load failure
##
## Error codes:
## MISSING_REQUIRED_PARAM — tileset_path absent/empty, or source_id absent
## RESOURCE_NOT_FOUND — ResourceLoader.exists(tileset_path) is false
## WRONG_TYPE — loaded resource is not a TileSet, or source is
## not a TileSetAtlasSource
## VALUE_OUT_OF_RANGE — source_id not present in TileSet
##
## This method is read-only: it never calls ResourceSaver or modifies any resource.
func get_atlas_tiles(params: Dictionary) -> Dictionary:
var resolved := _resolve_atlas_source(params)
if resolved.has("error"):
return resolved
var src: TileSetAtlasSource = resolved.src
var tiles: Array = []
for i in range(src.get_tiles_count()):
var v: Vector2i = src.get_tile_id(i)
tiles.append({"col": v.x, "row": v.y})
return {"data": {"tiles": tiles, "count": tiles.size()}}
## Return the atlas texture of a TileSetAtlasSource as a Base64-encoded PNG.
##
## params:
## tileset_path — res:// path to the TileSet resource (required, non-empty)
## source_id — raw TileSet source id (required)
## max_size — optional int; if > 0, the image is scaled so its longest
## edge is at most max_size pixels (default 0 = full res)
##
## Returns:
## {"data": {"image_base64": String, "width": int, "height": int,
## "original_width": int, "original_height": int, "format": "png"}}
## on success
## ErrorCodes.make(code, message) on any validation or load failure
##
## Error codes:
## MISSING_REQUIRED_PARAM — tileset_path absent/empty, or source_id absent
## RESOURCE_NOT_FOUND — ResourceLoader.exists(tileset_path) is false
## WRONG_TYPE — loaded resource is not a TileSet, or source is
## not a TileSetAtlasSource, or texture is null
## VALUE_OUT_OF_RANGE — source_id not present in TileSet
##
## This method is read-only: it never calls ResourceSaver or modifies anything.
func get_atlas_image(params: Dictionary) -> Dictionary:
var resolved := _resolve_atlas_source(params)
if resolved.has("error"):
return resolved
var source_id: int = resolved.source_id
var src: TileSetAtlasSource = resolved.src
var tex: Texture2D = src.texture
if tex == null:
return ErrorCodes.make(
ErrorCodes.WRONG_TYPE,
"Source %d has no texture assigned" % source_id
)
var img: Image = tex.get_image()
if img == null:
return ErrorCodes.make(
ErrorCodes.WRONG_TYPE,
"Could not retrieve image data from texture of source %d" % source_id
)
if img.is_compressed():
var decompress_err := img.decompress()
if decompress_err != OK:
return ErrorCodes.make(
ErrorCodes.INTERNAL_ERROR,
"Could not decompress texture of source %d: %s" % [source_id, error_string(decompress_err)]
)
var original_width: int = img.get_width()
var original_height: int = img.get_height()
var max_size: int = params.get("max_size", 0)
if max_size > 0:
var longest_edge: int = max(original_width, original_height)
if longest_edge > max_size:
var scale: float = float(max_size) / float(longest_edge)
var new_w: int = max(1, int(original_width * scale))
var new_h: int = max(1, int(original_height * scale))
img.resize(new_w, new_h, Image.INTERPOLATE_LANCZOS)
var png_bytes: PackedByteArray = img.save_png_to_buffer()
if png_bytes.is_empty():
return ErrorCodes.make(
ErrorCodes.INTERNAL_ERROR,
"PNG encoding produced empty output for source %d" % source_id
)
var b64: String = Marshalls.raw_to_base64(png_bytes)
return {
"data": {
"image_base64": b64,
"width": img.get_width(),
"height": img.get_height(),
"original_width": original_width,
"original_height": original_height,
"format": "png",
}
}
func _resolve_atlas_source(params: Dictionary) -> Dictionary:
var tileset_path: String = params.get("tileset_path", "")
if tileset_path.is_empty():
return ErrorCodes.make(
ErrorCodes.MISSING_REQUIRED_PARAM,
"'tileset_path' parameter is required and must not be empty"
)
if not params.has("source_id"):
return ErrorCodes.make(
ErrorCodes.MISSING_REQUIRED_PARAM,
"'source_id' parameter is required"
)
if not ResourceLoader.exists(tileset_path):
return ErrorCodes.make(
ErrorCodes.RESOURCE_NOT_FOUND,
"TileSet resource not found: %s" % tileset_path
)
var ts = load(tileset_path)
if not ts is TileSet:
var loaded_type := "null" if ts == null else ts.get_class()
return ErrorCodes.make(
ErrorCodes.WRONG_TYPE,
"Resource at '%s' is not a TileSet (got %s)" % [tileset_path, loaded_type]
)
var source_id: int = int(params.get("source_id", -999))
if source_id < 0 or not ts.has_source(source_id):
return ErrorCodes.make(
ErrorCodes.VALUE_OUT_OF_RANGE,
"source_id %d does not exist in TileSet" % source_id
)
var src = ts.get_source(source_id)
if not src is TileSetAtlasSource:
var source_type: String = "null" if src == null else src.get_class()
return ErrorCodes.make(
ErrorCodes.WRONG_TYPE,
"Source %d is not a TileSetAtlasSource (got %s)" % [source_id, source_type]
)
return {
"source_id": source_id,
"src": src,
}
@@ -0,0 +1 @@
uid://de3v4m1pnk7tr
+301 -54
View File
@@ -40,8 +40,17 @@ const LogViewerScript := preload("res://addons/godot_ai/dock_panels/log_viewer.g
const PortPickerPanelScript := preload("res://addons/godot_ai/dock_panels/port_picker_panel.gd")
const DEV_MODE_SETTING := "godot_ai/dev_mode"
## "Change the port + reconfigure your clients" guide. Surfaced from the crash
## panel when a foreign process holds the HTTP port — the one piece of recovery
## (per-client config rewrite) that doesn't fit in the inline crash body.
## Resolved against the installed plugin version at click time (see
## `_port_conflict_docs_url`) so a shipped build opens the guide as it shipped,
## not tip-of-main, which may have drifted from that build's UI.
const PORT_CONFLICT_DOCS_PATH := "docs/port-conflicts.md"
const REPO_BLOB_BASE := "https://github.com/hi-godot/godot-ai/blob"
const CLIENT_STATUS_REFRESH_COOLDOWN_MSEC := 15 * 1000
const CLIENT_STATUS_REFRESH_TIMEOUT_MSEC := 30 * 1000
const CLIENT_ACTION_TIMEOUT_MSEC := 30 * 1000
static var COLOR_MUTED := Color(0.7, 0.7, 0.7)
static var COLOR_HEADER := Color(0.95, 0.95, 0.95)
## Used for "in-progress" / "stale, action needed" UI: the startup-grace
@@ -60,15 +69,16 @@ var _status_icon: ColorRect
var _status_label: Label
var _client_grid: VBoxContainer
var _client_configure_all_btn: Button
var _client_empty_cta_btn: Button
var _clients_summary_label: Label
var _clients_window: Window
var _dev_mode_toggle: CheckButton
var _install_label: Label
# Settings tab (secondary window, Tab 2) — domain-exclusion UI for clients
# Tools tab (secondary window, Tab 2) — domain-exclusion UI for clients
# that cap total tool count (Antigravity: 100). Pending set is mutated by
# checkbox clicks; saved set reflects what the spawned server actually
# sees. `Apply & Restart Server` writes pending → setting and triggers a
# sees. `Apply and Restart Server` writes pending → setting and triggers a
# plugin reload so the new server comes up with the trimmed list.
var _tools_pending_excluded: PackedStringArray = PackedStringArray()
var _tools_saved_excluded: PackedStringArray = PackedStringArray()
@@ -147,23 +157,24 @@ static var _orphaned_client_status_refresh_threads: Array[Thread] = []
## Per-row worker state for Configure / Remove. Issue #239: shelling out
## to a hung CLI on main hangs the editor. We dispatch each click to its
## own thread (one slot per client) and apply the result via call_deferred
## once the subprocess returns or the wall-clock budget in McpCliExec
## kicks in. The buttons stay disabled while the slot is busy so the user
## can't queue a re-click on the same row.
## own thread (one slot per client), then `_process` reaps completed workers
## and applies returned payloads on main. The buttons stay disabled while
## the slot is busy so the user can't queue a re-click on the same row.
##
## Per-client (not single-slot) so Configure-all can fan out — the
## workers are independent, only the row UI is shared, and McpCliExec
## bounds the wall-clock for each.
##
## No orphan-thread list (unlike the refresh worker): action threads
## never get abandoned mid-flight. McpCliExec's wall-clock budget caps
## the worst case at ~10s, so the `_exit_tree` / `McpUpdateManager`
## install-time drain blocks briefly and finishes — there's no path that
## "gives up" on an action thread the way `_abandon_client_status_refresh_thread`
## does for the refresh worker.
## A watchdog can abandon a slot when a worker fails to report completion.
## The thread object is retained in `_orphaned_client_action_threads` until
## it finishes so GDScript does not destroy a live Thread object.
var _client_action_threads: Dictionary = {}
var _client_action_generations: Dictionary = {}
var _client_action_started_msec: Dictionary = {}
var _client_action_names: Dictionary = {}
## Timed-out Configure/Remove workers are abandoned but retained here until
## they finish, so GDScript does not destroy a live Thread object.
static var _orphaned_client_action_threads: Array[Thread] = []
# Dev-mode only
var _dev_section: VBoxContainer
@@ -193,6 +204,11 @@ var _crash_panel: VBoxContainer
var _crash_output: RichTextLabel
var _crash_restart_btn: Button
var _crash_reload_btn: Button
## Help link — visible only for the genuinely-foreign-occupant INCOMPATIBLE
## case (no `can_recover_incompatible` proof). The inline body names a free
## port; this button carries the per-client reconfigure steps that don't fit
## inline. See `PORT_CONFLICT_DOCS` and `_update_crash_panel`.
var _crash_docs_btn: Button
## Port-picker escape hatch — visible inside the crash panel when the root
## cause is port contention (PORT_EXCLUDED or FOREIGN_PORT). The dock writes
## the EditorSetting and reloads the plugin in response to the panel's
@@ -238,10 +254,14 @@ func _ready() -> void:
func _process(_delta: float) -> void:
_prune_orphaned_client_status_refresh_threads()
_prune_orphaned_client_action_threads()
_poll_completed_client_status_refresh_thread()
_poll_completed_client_action_threads()
_check_client_status_refresh_timeout()
_check_client_action_timeouts()
if _connection == null:
return
_prune_orphaned_client_status_refresh_threads()
_check_client_status_refresh_timeout()
_retry_deferred_client_status_refresh()
_update_status()
if _log_viewer != null and _log_viewer.visible:
@@ -275,6 +295,8 @@ func _exit_tree() -> void:
## drains directly because it has additional state-machine work
## (SHUTTING_DOWN sticky-set) that the install-time path must NOT inherit.
func prepare_for_self_update_drain() -> void:
_poll_completed_client_status_refresh_thread()
_poll_completed_client_action_threads()
_drain_client_status_refresh_workers()
_drain_client_action_workers()
@@ -309,13 +331,13 @@ func _drain_client_action_workers() -> void:
## plugin disable / install-update path reloads our script class, so any
## live Thread must finish before its slot is GC'd or we hit
## `~Thread … destroyed without its completion having been realized` →
## VM corruption. Bounded by `McpCliExec` wall-clock budgets, so the
## worst case is a ~10s blocking drain, vs. an unbounded SIGSEGV.
## VM corruption. Normal UI recovery is handled by the per-row watchdog;
## teardown still blocks because GDScript's Thread API has no kill/timeout
## primitive and destroying a live Thread corrupts the VM.
##
## Generation-bumped per-row so any pending `call_deferred(
## "_apply_client_action_result")` from a worker that finished after we
## started draining detects the generation mismatch and short-circuits
## without touching freed UI state.
## Generation-bumped per-row so any result from a worker that finished
## after we started draining detects the generation mismatch and
## short-circuits without touching freed UI state.
##
## After draining, restore the row UI for any in-flight rows: bare
## `_client_action_threads.clear()` would leave the dock stuck showing
@@ -328,6 +350,8 @@ func _drain_client_action_workers() -> void:
if t != null:
t.wait_to_finish()
_client_action_generations[client_id] = int(_client_action_generations.get(client_id, 0)) + 1
_client_action_started_msec.erase(client_id)
_client_action_names.erase(client_id)
_finalize_action_buttons(String(client_id))
var row: Dictionary = _client_rows.get(String(client_id), {})
if not row.is_empty():
@@ -337,6 +361,71 @@ func _drain_client_action_workers() -> void:
""
)
_client_action_threads.clear()
for thread in _orphaned_client_action_threads:
if thread != null:
thread.wait_to_finish()
_orphaned_client_action_threads.clear()
_client_action_started_msec.clear()
_client_action_names.clear()
func _check_client_action_timeouts() -> void:
var now := Time.get_ticks_msec()
for client_id in _client_action_threads.keys():
if not _client_action_started_msec.has(client_id):
continue
var started := int(_client_action_started_msec.get(client_id, 0))
if now - started >= CLIENT_ACTION_TIMEOUT_MSEC:
_abandon_client_action_thread(String(client_id))
func _abandon_client_action_thread(client_id: String) -> void:
if not _client_action_threads.has(client_id):
return
var thread: Thread = _client_action_threads[client_id]
var elapsed := Time.get_ticks_msec() - int(_client_action_started_msec.get(client_id, Time.get_ticks_msec()))
var worker_alive := thread != null and thread.is_alive()
if thread != null:
_orphaned_client_action_threads.append(thread)
_client_action_threads.erase(client_id)
_client_action_started_msec.erase(client_id)
var action := str(_client_action_names.get(client_id, "configure"))
_client_action_names.erase(client_id)
_client_action_generations[client_id] = int(_client_action_generations.get(client_id, 0)) + 1
_finalize_action_buttons(client_id)
print("MCP | client action timed out: client=%s action=%s elapsed_ms=%d worker_alive=%s" % [
client_id,
action,
elapsed,
str(worker_alive),
])
var label := "Remove" if action == "remove" else "Configure"
_apply_row_status(
client_id,
Client.Status.ERROR,
"%s did not report completion in time; refreshing current status." % label
)
_refresh_clients_summary()
if is_inside_tree():
_request_client_status_refresh(true)
func _prune_orphaned_client_action_threads() -> void:
var completed_orphan := false
for i in range(_orphaned_client_action_threads.size() - 1, -1, -1):
var thread := _orphaned_client_action_threads[i]
if thread == null:
_orphaned_client_action_threads.remove_at(i)
elif not thread.is_alive():
thread.wait_to_finish()
_orphaned_client_action_threads.remove_at(i)
completed_orphan = true
if completed_orphan and is_inside_tree():
_request_client_action_completion_refresh()
func _request_client_action_completion_refresh() -> void:
_request_client_status_refresh(true)
func _notification(what: int) -> void:
@@ -473,6 +562,13 @@ func _build_ui() -> void:
_crash_reload_btn.pressed.connect(_on_reload_plugin)
_crash_panel.add_child(_crash_reload_btn)
_crash_docs_btn = Button.new()
_crash_docs_btn.text = "How to change the port"
_crash_docs_btn.tooltip_text = "Open the guide: change godot_ai/http_port and reconfigure your MCP clients"
_crash_docs_btn.visible = false
_crash_docs_btn.pressed.connect(func(): OS.shell_open(_port_conflict_docs_url()))
_crash_panel.add_child(_crash_docs_btn)
_crash_panel.add_child(HSeparator.new())
add_child(_crash_panel)
@@ -487,7 +583,7 @@ func _build_ui() -> void:
_update_label = Label.new()
_update_label.add_theme_font_size_override("font_size", 15)
_update_label.add_theme_color_override("font_color", Color(1.0, 0.85, 0.3))
## Wrap long banner text (e.g. the < 4.4 manual-update guidance) instead
## Wrap long banner text (e.g. the < 4.5 support-floor guidance) instead
## of letting a single line stretch the whole dock wide. The dock is a
## fixed-width side panel, so constrain horizontally and wrap.
_update_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
@@ -558,30 +654,45 @@ func _build_ui() -> void:
add_child(HSeparator.new())
# --- Clients ---
var clients_row := HBoxContainer.new()
clients_row.add_theme_constant_override("separation", 8)
var clients_header_row := HBoxContainer.new()
clients_header_row.add_theme_constant_override("separation", 8)
var clients_header := _make_header("Clients")
clients_row.add_child(clients_header)
clients_header_row.add_child(clients_header)
_clients_summary_label = Label.new()
_clients_summary_label.add_theme_color_override("font_color", COLOR_MUTED)
_clients_summary_label.clip_text = true
_clients_summary_label.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS
_clients_summary_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
clients_row.add_child(_clients_summary_label)
clients_header_row.add_child(_clients_summary_label)
var clients_actions := HFlowContainer.new()
clients_actions.add_theme_constant_override("h_separation", 8)
clients_actions.add_theme_constant_override("v_separation", 4)
var clients_refresh_btn := Button.new()
clients_refresh_btn.text = "Refresh"
clients_refresh_btn.tooltip_text = "Refresh client status in the background. Cached status stays visible while checks run."
clients_refresh_btn.pressed.connect(_on_refresh_clients_pressed)
clients_row.add_child(clients_refresh_btn)
clients_actions.add_child(clients_refresh_btn)
var clients_open_btn := Button.new()
clients_open_btn.text = "Clients & Settings"
clients_open_btn.tooltip_text = "Open the MCP settings window — configure AI clients, choose telemetry preferences, or disable tool domains to fit under a client's hard tool-count cap (e.g. Antigravity's 100)."
clients_open_btn.text = "Clients & Tools"
clients_open_btn.tooltip_text = "Open the Clients & Tools window — configure AI clients, choose telemetry preferences, or disable tool domains to fit under a client's hard tool-count cap (e.g. Antigravity's 100)."
clients_open_btn.pressed.connect(_on_open_clients_window)
clients_row.add_child(clients_open_btn)
clients_actions.add_child(clients_open_btn)
add_child(clients_row)
add_child(clients_header_row)
add_child(clients_actions)
_client_empty_cta_btn = Button.new()
_client_empty_cta_btn.text = "Configure an AI client ->"
_client_empty_cta_btn.tooltip_text = "Open the Clients tab to configure an AI coding client for this Godot AI server."
_client_empty_cta_btn.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_client_empty_cta_btn.visible = false
_client_empty_cta_btn.pressed.connect(_on_open_clients_window)
add_child(_client_empty_cta_btn)
# Drift banner — hidden until a sweep finds at least one mismatched client.
_drift_banner = VBoxContainer.new()
@@ -600,7 +711,7 @@ func _build_ui() -> void:
add_child(_drift_banner)
_clients_window = Window.new()
_clients_window.title = "MCP Clients & Settings"
_clients_window.title = "Godot AI"
## `Vector2i * float` yields Vector2; wrap the result back to Vector2i.
_clients_window.min_size = Vector2i(Vector2(560, 460) * EditorInterface.get_editor_scale())
_clients_window.visible = false
@@ -758,7 +869,7 @@ func _build_client_row(client_id: String) -> void:
# --- Status updates ---
func _update_status() -> void:
var connected: bool = _connection.is_connected
var connected: bool = _connection != null and _connection.is_connected
## During plugin self-update there's a brief window where this dock
## script is already the new version (Godot hot-reloads scripts on
## file change) but `_plugin` is still the old `EditorPlugin` instance
@@ -785,7 +896,7 @@ func _update_status() -> void:
status_text = "Restarting server..."
status_color = COLOR_AMBER
elif connected:
status_text = "Connected"
status_text = _connected_status_text()
status_color = Color.GREEN
elif state == ServerStateScript.CRASHED:
var exit_ms: int = server_status.get("exit_ms", 0)
@@ -858,6 +969,15 @@ func _update_crash_panel(server_status: Dictionary) -> void:
not show_recovery_restart
and state != ServerStateScript.INCOMPATIBLE
)
## Docs link only for the genuinely-foreign occupant: a recoverable
## (older godot-ai) server gets Restart Server instead, and the inline
## body already names a free port — the link carries the per-client
## reconfigure steps that don't fit inline.
if _crash_docs_btn != null:
_crash_docs_btn.visible = (
state == ServerStateScript.INCOMPATIBLE
and not bool(server_status.get("can_recover_incompatible", false))
)
var port_picker_visible := (
state == ServerStateScript.PORT_EXCLUDED
@@ -887,9 +1007,16 @@ static func _crash_body_for_state(state: int, server_status: Dictionary = {}) ->
if not message.is_empty():
return "%s Click Restart Server below to replace it with godot-ai v%s." % [message, expected]
return "Port %d is occupied by an older godot-ai server. Click Restart Server below to replace it with godot-ai v%s." % [port, expected]
## Genuinely foreign occupant (no recovery proof). Name a concrete
## free port so the user doesn't have to hunt for one, and let the
## crash panel's "How to change the port" link carry the per-client
## reconfigure steps. `suggest_free_port` already routes through the
## Windows reservation table, so the named port won't itself fail
## with WinError 10013.
var hint := _free_port_hint(port)
if not message.is_empty():
return message
return "Port %d is occupied by an incompatible server. Stop it or change both HTTP and WS ports." % port
return "%s %s" % [message, hint]
return "Port %d is occupied by an incompatible server. %s" % [port, hint]
ServerStateScript.FOREIGN_PORT:
return "Another process is already bound to port %d. Pick a free port or stop the other process." % port
ServerStateScript.CRASHED:
@@ -907,6 +1034,31 @@ static func _crash_body_for_state(state: int, server_status: Dictionary = {}) ->
return ""
## One sentence naming concrete free ports for the user to switch to. Names
## BOTH http and ws: this branch also fires for an incompatible godot-ai
## server we can't prove we own, which commonly holds both ports — moving only
## http would then leave the new server unable to bind ws. Both suggestions are
## routed through `suggest_free_port` so they clear Windows' winnat reservation
## table (no point suggesting a port that 10013s on bind). Only the http port
## reaches client configs; the ws port is server↔plugin, hence the wording.
## The per-client reconfigure steps live behind the crash panel's docs link.
static func _free_port_hint(port: int) -> String:
var free_http := ClientConfigurator.suggest_free_port(port + 1)
var free_ws := ClientConfigurator.suggest_free_port(ClientConfigurator.ws_port() + 1)
return "Ports %d (HTTP) and %d (WS) are free — set `godot_ai/http_port` and `godot_ai/ws_port` in Editor Settings, then update your client config with the new HTTP port (How to change the port, below)." % [free_http, free_ws]
## URL for the port-conflict guide, pinned to the release tag that matches the
## installed plugin version (releases are tagged `v<version>`). The crash-panel
## button only exists in builds that ship `docs/port-conflicts.md`, so the
## versioned ref always resolves — and a shipped build never points users at a
## tip-of-main guide that has drifted from its own UI.
static func _port_conflict_docs_url() -> String:
var version := ClientConfigurator.get_plugin_version()
var git_ref := ("v%s" % version) if not version.is_empty() else "main"
return "%s/%s/%s" % [REPO_BLOB_BASE, git_ref, PORT_CONFLICT_DOCS_PATH]
## Build the mixed-state banner. Hidden until `_refresh_mixed_state_banner`
## confirms `*.update_backup` files exist in the addons tree. Mirrors the
## issue #354 fix shape: structured, agent-readable diagnostic that survives
@@ -981,10 +1133,17 @@ func _apply_mixed_state_banner_diagnostic(diag: Dictionary) -> void:
## Signal handler for the extracted LogViewer — the panel owns its own
## display visibility, the dock owns dispatcher logging routing.
## display visibility, the dock owns logging routing. Routes to BOTH the
## dispatcher (gates [recv]/[send] recording) and the log buffer's console
## echo — the connection logs [event]/[defer] lines directly to the buffer,
## bypassing the dispatcher, so gating only `mcp_logging` left the console
## spamming with the toggle off (#626). Ring recording is unaffected, so
## the dock's log panel keeps working while the console stays quiet.
func _on_log_logging_enabled_changed(enabled: bool) -> void:
if _connection and _connection.dispatcher:
_connection.dispatcher.mcp_logging = enabled
if _log_buffer != null:
_log_buffer.enabled = enabled
## Signal handler for the extracted PortPickerPanel — the panel range-validates
@@ -1297,7 +1456,9 @@ func _refresh_setup_status() -> void:
# User mode — check for uv
var uv_version := ClientConfigurator.check_uv_version()
if not uv_version.is_empty():
_setup_container.add_child(_make_status_row("uv", uv_version, Color.GREEN))
var compact_uv_version := _compact_uv_version_text(uv_version)
var uv_tooltip := uv_version if compact_uv_version != uv_version else ""
_setup_container.add_child(_make_status_row("uv", compact_uv_version, Color.GREEN, uv_tooltip))
## Build the Server row with a placeholder label we can update every
## frame. `_refresh_server_version_label` replaces the text + color
## once `McpConnection.server_version` lands via `handshake_ack`, and
@@ -1358,19 +1519,39 @@ func _resolve_plugin_symlink_target() -> String:
return target
func _make_status_row(label_text: String, value_text: String, value_color: Color) -> HBoxContainer:
static func _compact_uv_version_text(uv_version: String) -> String:
var text := uv_version.strip_edges()
if text.ends_with(")"):
var metadata_start := text.rfind(" (")
if metadata_start >= 0:
return text.substr(0, metadata_start).strip_edges()
return text
func _make_status_row(
label_text: String,
value_text: String,
value_color: Color,
tooltip_text: String = ""
) -> HBoxContainer:
var row := HBoxContainer.new()
row.add_theme_constant_override("separation", 6)
if not tooltip_text.is_empty():
row.tooltip_text = tooltip_text
var label := Label.new()
label.text = label_text
label.add_theme_color_override("font_color", COLOR_MUTED)
label.custom_minimum_size.x = 60
if not tooltip_text.is_empty():
label.tooltip_text = tooltip_text
row.add_child(label)
var value := Label.new()
value.text = value_text
value.add_theme_color_override("font_color", value_color)
if not tooltip_text.is_empty():
value.tooltip_text = tooltip_text
row.add_child(value)
return row
@@ -1480,6 +1661,14 @@ func _update_dev_section_buttons() -> void:
_dev_stop_btn.tooltip_text = stop_state["tooltip"]
func _client_status_refresh_has_completed() -> bool:
return _last_client_status_refresh_completed_msec > 0
func _connected_status_text() -> String:
return "Server connected"
func _on_install_uv() -> void:
match OS.get_name():
"Windows":
@@ -1548,28 +1737,65 @@ func _dispatch_client_action(client_id: String, action: String) -> void:
_client_action_generations[client_id] = generation
var thread := Thread.new()
_client_action_threads[client_id] = thread
_client_action_started_msec[client_id] = Time.get_ticks_msec()
_client_action_names[client_id] = action
var err := thread.start(
Callable(self, "_run_client_action_worker").bind(client_id, action, server_url, generation)
)
if err != OK:
_client_action_threads.erase(client_id)
_client_action_started_msec.erase(client_id)
_client_action_names.erase(client_id)
_finalize_action_buttons(client_id)
_apply_row_status(client_id, Client.Status.ERROR, "couldn't start worker thread")
_refresh_clients_summary()
func _run_client_action_worker(client_id: String, action: String, server_url: String, generation: int) -> void:
func _run_client_action_worker(client_id: String, action: String, server_url: String, generation: int) -> Dictionary:
var result: Dictionary
if action == "remove":
result = ClientConfigurator.remove(client_id, server_url)
else:
result = ClientConfigurator.configure(client_id, server_url)
if _refresh_state != ClientRefreshStateScript.SHUTTING_DOWN:
call_deferred("_apply_client_action_result", client_id, action, result, generation)
return {
"client_id": client_id,
"action": action,
"result": result,
"generation": generation,
}
func _poll_completed_client_action_threads() -> void:
for client_id in _client_action_threads.keys():
var thread: Thread = _client_action_threads[client_id]
if thread == null or thread.is_alive():
continue
var payload: Variant = thread.wait_to_finish()
_client_action_threads[client_id] = null
if payload is Dictionary:
var data := payload as Dictionary
var result: Dictionary = data.get("result", {})
_apply_client_action_result(
String(data.get("client_id", client_id)),
String(data.get("action", _client_action_names.get(client_id, "configure"))),
result,
int(data.get("generation", _client_action_generations.get(client_id, 0)))
)
else:
_apply_client_action_result(
String(client_id),
String(_client_action_names.get(client_id, "configure")),
{"status": "error", "message": "worker returned no result"},
int(_client_action_generations.get(client_id, 0))
)
func _apply_client_action_result(client_id: String, action: String, result: Dictionary, generation: int) -> void:
if int(_client_action_generations.get(client_id, 0)) != generation:
if _client_action_threads.get(client_id, null) == null:
_client_action_threads.erase(client_id)
_client_action_started_msec.erase(client_id)
_client_action_names.erase(client_id)
return
if _refresh_state == ClientRefreshStateScript.SHUTTING_DOWN:
return
@@ -1577,7 +1803,9 @@ func _apply_client_action_result(client_id: String, action: String, result: Dict
var t: Thread = _client_action_threads[client_id]
if t != null:
t.wait_to_finish()
_client_action_threads.erase(client_id)
_client_action_threads.erase(client_id)
_client_action_started_msec.erase(client_id)
_client_action_names.erase(client_id)
_finalize_action_buttons(client_id)
if _server_blocks_client_health():
_apply_row_status(client_id, Client.Status.ERROR, _server_blocked_client_message())
@@ -1645,7 +1873,7 @@ func _on_configure_all_clients() -> void:
_apply_row_status(String(client_id), Client.Status.ERROR, _server_blocked_client_message())
_refresh_clients_summary()
return
if ClientRefreshStateScript.has_worker_alive(_refresh_state):
if ClientRefreshStateScript.should_disable_client_actions(_refresh_state):
return
for client_id in _client_rows:
var status: Client.Status = _client_rows[client_id].get("status", Client.Status.NOT_CONFIGURED)
@@ -1700,7 +1928,7 @@ func _build_tools_tab(tabs: TabContainer) -> void:
var tools_tab := VBoxContainer.new()
tools_tab.add_theme_constant_override("separation", 8)
var tools_margin := _build_margin_container()
tools_margin.name = "Settings"
tools_margin.name = "Tools"
tools_margin.add_child(tools_tab)
tabs.add_child(tools_margin)
@@ -1793,7 +2021,7 @@ func _build_tools_tab(tabs: TabContainer) -> void:
footer.add_theme_constant_override("separation", 8)
_tools_apply_btn = Button.new()
_tools_apply_btn.text = "Apply && Restart Server"
_tools_apply_btn.text = "Apply and Restart Server"
_tools_apply_btn.tooltip_text = "Save the excluded list to Editor Settings and reload the plugin so the server respawns with --exclude-domains."
_tools_apply_btn.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_tools_apply_btn.pressed.connect(_on_tools_apply)
@@ -1976,8 +2204,11 @@ func _refresh_clients_summary() -> void:
)
_clients_summary_label.text = text
if _client_configure_all_btn != null:
_client_configure_all_btn.disabled = ClientRefreshStateScript.has_worker_alive(_refresh_state)
_client_configure_all_btn.disabled = ClientRefreshStateScript.should_disable_client_actions(_refresh_state)
if _client_empty_cta_btn != null:
_client_empty_cta_btn.visible = configured == 0 and _client_status_refresh_has_completed()
_refresh_drift_banner(mismatched_ids)
_update_status()
func _show_manual_command_for(client_id: String) -> void:
@@ -2167,8 +2398,8 @@ func _warm_strategy_bytecode() -> void:
func _begin_client_status_refresh_run() -> int:
## Marks a refresh as starting and returns the new generation token.
## Generation is bumped here (not at completion) so that a worker callback
## arriving after `_abandon_client_status_refresh_thread` or `_exit_tree`
## Generation is bumped here (not at completion) so that a worker result
## reaped after `_abandon_client_status_refresh_thread` or `_exit_tree`
## fires can be detected as stale via generation mismatch.
_refresh_state = ClientRefreshStateScript.RUNNING
_client_status_refresh_pending = false
@@ -2181,7 +2412,7 @@ func _begin_client_status_refresh_run() -> int:
func _finalize_completed_refresh() -> void:
## Stamps cooldown and clears in-flight state. Called at the end of every
## refresh that successfully applied results — the worker callback path
## refresh that successfully applied results — the worker reaping path
## and the no-CLI fast path in `_perform_initial_client_status_refresh`.
_last_client_status_refresh_completed_msec = Time.get_ticks_msec()
if _refresh_state != ClientRefreshStateScript.SHUTTING_DOWN:
@@ -2308,7 +2539,7 @@ func _retry_deferred_client_status_refresh() -> void:
_request_client_status_refresh(force)
func _run_client_status_refresh_worker(client_probes: Array[Dictionary], server_url: String, generation: int) -> void:
func _run_client_status_refresh_worker(client_probes: Array[Dictionary], server_url: String, generation: int) -> Dictionary:
var results: Dictionary = {}
for probe in client_probes:
var client_id := String(probe.get("id", ""))
@@ -2325,8 +2556,25 @@ func _run_client_status_refresh_worker(client_probes: Array[Dictionary], server_
"installed": installed,
"error_msg": details.get("error_msg", ""),
}
if _refresh_state != ClientRefreshStateScript.SHUTTING_DOWN:
call_deferred("_apply_client_status_refresh_results", results, generation)
return {"results": results, "generation": generation}
func _poll_completed_client_status_refresh_thread() -> void:
if _client_status_refresh_thread == null:
return
if _client_status_refresh_thread.is_alive():
return
var payload: Variant = _client_status_refresh_thread.wait_to_finish()
_client_status_refresh_thread = null
if payload is Dictionary:
var data := payload as Dictionary
var results: Dictionary = data.get("results", {})
_apply_client_status_refresh_results(
results,
int(data.get("generation", _client_status_refresh_generation))
)
else:
_apply_client_status_refresh_results({}, _client_status_refresh_generation)
func _apply_client_status_refresh_results(results: Dictionary, generation: int) -> void:
@@ -2491,6 +2739,5 @@ func _on_install_state_changed(state: Dictionary) -> void:
if state.has("banner_visible") and _update_banner != null:
_update_banner.visible = bool(state["banner_visible"])
if String(state.get("outcome", "")) == "success" and _update_label != null:
## Visual confirmation for the pre-4.4 "Updated! Restart the editor."
## terminal state — the only outcome the manager paints green for.
## Visual confirmation for successful terminal update states.
_update_label.add_theme_color_override("font_color", Color.GREEN)
+1 -1
View File
@@ -3,5 +3,5 @@
name="Godot AI"
description="MCP server and AI tools for Godot"
author="Godot AI"
version="2.7.6"
version="2.9.1"
script="plugin.gd"
+75 -49
View File
@@ -6,13 +6,8 @@ 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")
## `logs_read(source="editor")`.
const EditorLogger := preload("res://addons/godot_ai/runtime/editor_logger.gd")
## EditorSettings keys used to remember which server process the plugin
## spawned — survives editor restarts, lets a later editor session adopt
@@ -48,6 +43,7 @@ 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 SurfacedErrorTracker := preload("res://addons/godot_ai/utils/surfaced_error_tracker.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")
@@ -83,6 +79,8 @@ const EnvironmentHandler := preload("res://addons/godot_ai/handlers/environment_
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")
const TilemapHandler := preload("res://addons/godot_ai/handlers/tilemap_handler.gd")
const TilesetHandler := preload("res://addons/godot_ai/handlers/tileset_handler.gd")
## The Python server writes its own PID here on startup (passed as
## `--pid-file`) and unlinks on clean exit. Deterministic replacement
@@ -144,19 +142,14 @@ const STARTUP_TRACE_COUNTER_NAMES := [
##
## `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 _surfaced_error_tracker
var _editor_logger: Logger
var _dock
var _handlers: Array = [] # prevent GC of RefCounted handlers
var _debugger_plugin
@@ -197,11 +190,9 @@ func _enter_tree() -> void:
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).
## Self-update extracts over the live addon and doesn't prune files that
## disappeared from the new ZIP. Remove obsolete Logger-loader quarantine
## files/folders once so upgraders match a fresh install.
_cleanup_legacy_logger_scripts()
## Register port overrides before spawn so `http_port()` / `ws_port()`
@@ -211,17 +202,24 @@ func _enter_tree() -> void:
_startup_trace_phase("settings_registered")
_log_buffer = LogBuffer.new()
## Apply the persisted dock "Log" toggle before anything logs through the
## buffer. Without this the choice only took effect after a manual toggle
## and reset to noisy on every editor restart (#626).
_log_buffer.enabled = McpSettings.mcp_logging_enabled()
_start_server()
_startup_trace_phase("server_start")
_game_log_buffer = GameLogBuffer.new()
_editor_log_buffer = EditorLogBuffer.new()
_surfaced_error_tracker = SurfacedErrorTracker.new(_editor_log_buffer, _game_log_buffer)
_attach_editor_logger()
_dispatcher = Dispatcher.new(_log_buffer)
_dispatcher = Dispatcher.new(_log_buffer, _surfaced_error_tracker)
_dispatcher.mcp_logging = _log_buffer.enabled
_startup_trace_phase("core_objects")
_connection = Connection.new()
_connection.log_buffer = _log_buffer
_connection.surfaced_error_tracker = _surfaced_error_tracker
_connection.ws_port = _resolved_ws_port
_connection.connect_blocked = _lifecycle.is_connection_blocked()
_connection.connect_block_reason = _lifecycle.get_status_dict().get("message", "")
@@ -233,19 +231,20 @@ func _enter_tree() -> void:
_telemetry = Telemetry.new(_connection)
_debugger_plugin = DebuggerPlugin.new(_log_buffer, _game_log_buffer)
_debugger_plugin = DebuggerPlugin.new(_log_buffer, _game_log_buffer, _editor_log_buffer, _surfaced_error_tracker)
add_debugger_plugin(_debugger_plugin)
_connection.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 editor_handler := EditorHandler.new(_log_buffer, _connection, _debugger_plugin, _game_log_buffer, _editor_log_buffer, null, _surfaced_error_tracker)
var scene_handler := SceneHandler.new(_connection)
var node_handler := NodeHandler.new(get_undo_redo())
var project_handler := ProjectHandler.new(_connection, _debugger_plugin)
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 filesystem_handler := FilesystemHandler.new(_connection)
var signal_handler := SignalHandler.new(get_undo_redo())
var autoload_handler := AutoloadHandler.new()
var input_handler := InputHandler.new()
@@ -263,7 +262,9 @@ func _enter_tree() -> void:
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]
var tilemap_handler := TilemapHandler.new(get_undo_redo())
var tileset_handler := TilesetHandler.new()
_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, tilemap_handler, tileset_handler]
_dispatcher.register("get_editor_state", editor_handler.get_editor_state)
_dispatcher.register("get_scene_tree", scene_handler.get_scene_tree)
@@ -318,6 +319,7 @@ func _enter_tree() -> void:
_dispatcher.register("read_file", filesystem_handler.read_file)
_dispatcher.register("write_file", filesystem_handler.write_file)
_dispatcher.register("reimport", filesystem_handler.reimport)
_dispatcher.register("scan_filesystem", filesystem_handler.scan_filesystem)
_dispatcher.register("list_signals", signal_handler.list_signals)
_dispatcher.register("connect_signal", signal_handler.connect_signal)
_dispatcher.register("disconnect_signal", signal_handler.disconnect_signal)
@@ -326,8 +328,10 @@ func _enter_tree() -> void:
_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("ensure_action", input_handler.ensure_action)
_dispatcher.register("remove_action", input_handler.remove_action)
_dispatcher.register("bind_event", input_handler.bind_event)
_dispatcher.register("ensure_binding", input_handler.ensure_binding)
_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)
@@ -393,6 +397,12 @@ func _enter_tree() -> void:
_dispatcher.register(
"control_draw_recipe", control_draw_recipe_handler.control_draw_recipe
)
_dispatcher.register("tilemap_set_cell", tilemap_handler.set_cell)
_dispatcher.register("tilemap_set_cells_rect", tilemap_handler.set_cells_rect)
_dispatcher.register("tilemap_clear", tilemap_handler.clear_layer)
_dispatcher.register("tilemap_get_cells", tilemap_handler.get_used_cells)
_dispatcher.register("tileset_get_atlas_tiles", tileset_handler.get_atlas_tiles)
_dispatcher.register("tileset_get_atlas_image", tileset_handler.get_atlas_image)
_connection.dispatcher = _dispatcher
add_child(_connection)
@@ -489,6 +499,7 @@ func _exit_tree() -> void:
_log_buffer = null
_game_log_buffer = null
_editor_log_buffer = null
_surfaced_error_tracker = null
_stop_server()
## Symmetric with prepare_for_update_reload: the static guard persists
@@ -504,9 +515,7 @@ func _exit_tree() -> void:
## 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).
## logs_read(source="editor").
##
## Limitation called out in the issue: parse errors fired *before* the
## plugin's _enter_tree (e.g. during the editor's initial filesystem
@@ -516,36 +525,53 @@ func _exit_tree() -> void:
## 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)
_editor_logger = EditorLogger.new(_editor_log_buffer)
OS.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.
## Remove old Logger-quarantine artifacts left by extract-over-live
## self-update. 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",
var legacy_files := [
"res://addons/godot_ai/runtime/logger_loader.gd",
"res://addons/godot_ai/runtime/logger_loader.gd.uid",
"res://addons/godot_ai/testing/script_error_capture_loader.gd",
"res://addons/godot_ai/testing/script_error_capture_loader.gd.uid",
]
for res_path in legacy:
for res_path in legacy_files:
if FileAccess.file_exists(res_path):
DirAccess.remove_absolute(ProjectSettings.globalize_path(res_path))
var legacy_dirs := [
"res://addons/godot_ai/runtime/loggers",
"res://addons/godot_ai/testing/loggers",
]
for res_path in legacy_dirs:
var absolute := ProjectSettings.globalize_path(res_path)
if DirAccess.dir_exists_absolute(absolute):
_remove_dir_recursive_absolute(absolute)
static func _remove_dir_recursive_absolute(path: String) -> void:
var dir := DirAccess.open(path)
if dir == null:
return
dir.list_dir_begin()
var name := dir.get_next()
while not name.is_empty():
var child := path.path_join(name)
if dir.current_is_dir():
_remove_dir_recursive_absolute(child)
else:
DirAccess.remove_absolute(child)
name = dir.get_next()
dir.list_dir_end()
DirAccess.remove_absolute(path)
func _detach_editor_logger() -> void:
if _editor_logger != null and OS.has_method("remove_logger"):
OS.call("remove_logger", _editor_logger)
if _editor_logger != null:
OS.remove_logger(_editor_logger)
_editor_logger = null
@@ -3,20 +3,12 @@ extends Logger
## Editor-process Logger subclass.
##
## NOTE: deliberately no `class_name` — `extends Logger` requires the Logger
## class which Godot only exposes from 4.5+. This file lives in the
## `.gdignore`'d `runtime/loggers/` folder so Godot's editor filesystem scan
## skips it entirely — on Godot < 4.5 it is never parsed, so it emits no
## "Could not find base class Logger" error (it used to, before #475's
## follow-up). plugin.gd builds it from source at runtime via
## `logger_loader.gd` and only calls OS.add_logger() after gating on
## ClassDB.class_exists("Logger"), so the `extends Logger` parse only ever
## happens on 4.5+ where it resolves. Registered from plugin.gd::_enter_tree
## NOTE: deliberately no `class_name`. Registered from plugin.gd::_enter_tree
## so we can intercept editor-process script errors — parse errors, @tool
## runtime errors, EditorPlugin errors, push_error/push_warning — and
## surface them via `logs_read(source="editor")`. Without this, the LLM
## sees nothing in `logs_read` while the same errors show in red lines in
## Godot's Output panel.
## runtime errors, EditorPlugin errors, push_error/push_warning — and surface
## them via `logs_read(source="editor")`. Without this, the LLM sees nothing
## in `logs_read` while the same errors show in red lines in Godot's Output
## panel.
##
## Why only `_log_error` and not `_log_message`:
## `_log_message(msg, error)` covers print() and printerr(), which is the
@@ -34,17 +26,13 @@ extends Logger
const ADDON_PATH_MARKER := "/addons/godot_ai/"
## Resolve McpLogBacktrace by path, not by the `McpLogBacktrace` class_name.
## This script is compiled from source at runtime by logger_loader.gd; a bare
## class_name reference depends on the global class-name table being populated
## A bare class_name reference depends on the global class-name table being populated
## at compile time, which isn't guaranteed on a cold editor enable mid-scan.
## `const preload` resolves at compile time independent of the registry —
## matches game_logger.gd's deliberate choice for the same reason.
const _LogBacktrace := preload("res://addons/godot_ai/utils/log_backtrace.gd")
## McpEditorLogBuffer — untyped because this script is loaded dynamically and
## McpEditorLogBuffer's class_name isn't yet registered on the parser at the
## time `extends Logger` resolves. Constructor-injected so the hot path
## doesn't need a per-call null check.
## Constructor-injected so the hot path doesn't need a per-call null check.
var _buffer
@@ -0,0 +1 @@
uid://cxfregddqj5b8
+99 -27
View File
@@ -23,13 +23,25 @@ const CAPTURE_PREFIX := "mcp"
## Cap per-frame flush so a runaway print loop can't blow the debugger's
## packet budget in a single send. Surplus stays queued for the next frame.
const FLUSH_BATCH_LIMIT := 200
## How long take_screenshot waits for the game's first real presentation
## before reading the viewport texture back. The "mcp" capture registers in
## this autoload's _ready(), which runs BEFORE the main scene enters the tree
## and before the renderer has presented anything — so a request arriving
## right after mcp:hello would otherwise read back the clear-color
## framebuffer (observed as a uniform RGB(77,77,77) PNG on GitHub's
## GPU-less paravirtualized macOS runners, where the first present lags
## seconds behind boot). MUST stay below the editor-side reply timer
## (DEFAULT_TIMEOUT_SEC = 8.0 in debugger/mcp_debugger_plugin.gd) so a
## game that genuinely can't render falls through to the existing
## texture/image error replies before the editor gives up with its
## generic timeout.
const FIRST_FRAME_WAIT_SEC := 6.0
const LoggerLoader := preload("res://addons/godot_ai/runtime/logger_loader.gd")
const GameLogger := preload("res://addons/godot_ai/runtime/game_logger.gd")
var _registered := false
## Untyped because the McpGameLogger script is loaded dynamically (it
## extends Logger, which only exists in Godot 4.5+).
var _logger
## Captures game-process print, warning, and error output for the editor.
var _logger: Logger
var _logger_attached := false
## Entries drained from the logger but not yet sent over the debugger
## channel. Holds the tail of one drain() so we can bleed it out across
@@ -64,17 +76,10 @@ func _ready() -> void:
_registered = true
## Capture print() / printerr() / push_error() / push_warning() and
## ferry them to the editor in mcp:log_batch messages flushed from
## _process. Logger subclassing was added in Godot 4.5 — gate on
## ClassDB so the rest of the helper still loads on older engines.
## game_logger.gd lives in the `.gdignore`'d runtime/loggers/ folder so
## it never parse-errors during a < 4.5 editor scan; LoggerLoader
## compiles it from source at runtime, only past this gate.
if ClassDB.class_exists("Logger") and OS.has_method("add_logger"):
var logger_script := LoggerLoader.build(LoggerLoader.GAME_LOGGER_PATH)
if logger_script != null:
_logger = logger_script.new()
OS.call("add_logger", _logger)
_logger_attached = true
## _process.
_logger = GameLogger.new()
OS.add_logger(_logger)
_logger_attached = true
## Routed to the editor's Output panel via Godot's remote-stdout
## forwarder — handy when diagnosing why capture timed out.
print("[godot_ai game_helper] registered mcp capture (debugger active=%s, logger=%s)"
@@ -109,16 +114,16 @@ func _exit_tree() -> void:
if _registered:
EngineDebugger.unregister_message_capture(CAPTURE_PREFIX)
_registered = false
if _logger_attached and _logger != null and OS.has_method("remove_logger"):
OS.call("remove_logger", _logger)
if _logger_attached and _logger != null:
OS.remove_logger(_logger)
_logger_attached = false
_logger = null
## Dispatched for messages prefixed "mcp:" on the debugger channel.
## Different Godot versions pass either the tail ("take_screenshot") or the
## full message ("mcp:take_screenshot") to the capture callable — accept
## both forms so this works across 4.2/4.3/4.4/4.5.
## Godot passes the full message ("mcp:take_screenshot") to the capture
## callable; trim defensively so tests can still call the helper with either
## form.
func _on_debug_message(message: String, data: Array) -> bool:
var action := message.trim_prefix("mcp:")
match action:
@@ -141,11 +146,25 @@ func _handle_take_screenshot(data: Array) -> void:
var request_id: String = data[0] if data.size() > 0 else ""
var max_resolution: int = int(data[1]) if data.size() > 1 else 0
var viewport := get_tree().root
var tree := get_tree()
var viewport := tree.root if tree != null else null
if viewport == null:
_reply_error(request_id, "No game root viewport available")
return
## Wait (bounded — see FIRST_FRAME_WAIT_SEC) until the main scene is in
## the tree and at least one frame has been drawn after this request, so
## the readback never precedes the first real present. Past the deadline,
## fall through anyway: current_scene stays null under a custom main
## loop, and frames_drawn never advances in a render-less game — both
## are handled by the texture/image error replies below.
var deadline := Time.get_ticks_msec() + int(FIRST_FRAME_WAIT_SEC * 1000.0)
while tree.current_scene == null and Time.get_ticks_msec() < deadline:
await tree.process_frame
var frames_at_request := Engine.get_frames_drawn()
while Engine.get_frames_drawn() <= frames_at_request and Time.get_ticks_msec() < deadline:
await tree.process_frame
var texture := viewport.get_texture()
if texture == null:
_reply_error(request_id, "Root viewport has no texture (headless?)")
@@ -217,6 +236,8 @@ func _handle_game_command(data: Array) -> void:
result = _game_input_mouse(json.data)
"input_gamepad":
result = _game_input_gamepad(json.data)
"input_action":
result = _game_input_action(json.data)
"input_state":
result = _game_input_state(json.data)
_:
@@ -458,7 +479,10 @@ func _game_input_key(params: Dictionary) -> Dictionary:
func _game_input_mouse(params: Dictionary) -> Dictionary:
var event := str(params.get("event", "button"))
var pos := _dict_to_vector2(params.get("position", {}))
var pos_result := _resolve_mouse_position(params.get("position"))
if pos_result.has("error"):
return {"sent": false, "event": event, "error": pos_result.error}
var pos: Vector2 = pos_result.position
match event:
"motion":
var motion := InputEventMouseMotion.new()
@@ -504,6 +528,27 @@ func _game_input_gamepad(params: Dictionary) -> Dictionary:
return {"sent": false, "error": "Invalid gamepad control: %s" % control}
func _game_input_action(params: Dictionary) -> Dictionary:
var action := str(params.get("action", ""))
if action.is_empty():
return {"sent": false, "error": "Missing action"}
if not InputMap.has_action(action):
return {"sent": false, "action": action, "error": "Unknown action: %s" % action}
var pressed := bool(params.get("pressed", true))
var strength := clampf(float(params.get("strength", 1.0)), 0.0, 1.0)
if pressed:
Input.action_press(action, strength)
else:
Input.action_release(action)
return {
"sent": true,
"action": action,
"pressed": pressed,
"strength": strength,
"delivery": "action_state",
}
func _game_input_state(params: Dictionary) -> Dictionary:
var actions: Array = params.get("actions", [])
if actions.is_empty():
@@ -515,14 +560,41 @@ func _game_input_state(params: Dictionary) -> Dictionary:
return {"actions": states}
func _dict_to_vector2(value: Variant) -> Vector2:
## Resolve a mouse-position param. Absent (null, or an empty {}) falls back to
## the live cursor position — a deliberate default. A present but wrong-shaped
## value is rejected instead of silently substituting the cursor, which
## previously hid caller bugs (#635). Accepts a {x, y} dict or an [x, y] array;
## returns {position: Vector2} or {error: String}.
func _resolve_mouse_position(value: Variant) -> Dictionary:
var viewport := get_viewport()
var fallback := viewport.get_mouse_position() if viewport != null else Vector2.ZERO
if value == null:
return {"position": fallback}
if value is Dictionary:
if value.is_empty() or (not value.has("x") and not value.has("y")):
return fallback
return Vector2(float(value.get("x", fallback.x)), float(value.get("y", fallback.y)))
return fallback
var dict: Dictionary = value
if dict.is_empty():
return {"position": fallback}
# A non-empty dict that carries neither coordinate is a caller mistake,
# not "use the default" — reject rather than silently substitute.
if not dict.has("x") and not dict.has("y"):
return {"error": "position object must have an 'x' and/or 'y' key (got keys %s)" % str(dict.keys())}
var x_val: Variant = dict.get("x", fallback.x)
var y_val: Variant = dict.get("y", fallback.y)
if not _is_number(x_val) or not _is_number(y_val):
return {"error": "position x/y must be numbers (got x=%s, y=%s)" % [type_string(typeof(x_val)), type_string(typeof(y_val))]}
return {"position": Vector2(float(x_val), float(y_val))}
if value is Array:
var arr: Array = value
if arr.size() != 2:
return {"error": "position array must be [x, y] (got %d elements)" % arr.size()}
if not _is_number(arr[0]) or not _is_number(arr[1]):
return {"error": "position array elements must be numbers (got [%s, %s])" % [type_string(typeof(arr[0])), type_string(typeof(arr[1]))]}
return {"position": Vector2(float(arr[0]), float(arr[1]))}
return {"error": "position must be a {x, y} object or [x, y] array (got %s)" % type_string(typeof(value))}
func _is_number(v: Variant) -> bool:
return typeof(v) == TYPE_INT or typeof(v) == TYPE_FLOAT
func _mouse_button_index(name: String) -> int:
@@ -3,17 +3,10 @@ extends Logger
## Game-process Logger subclass.
##
## NOTE: deliberately no `class_name` — `extends Logger` requires the Logger
## class which Godot only exposes from 4.5+. This file lives in the
## `.gdignore`'d `runtime/loggers/` folder so Godot's editor filesystem scan
## skips it entirelyon Godot < 4.5 it is never parsed, so it emits no
## "Could not find base class Logger" error (it used to, before #475's
## follow-up). game_helper.gd builds it from source at runtime via
## `logger_loader.gd` and only calls OS.add_logger() after gating on
## ClassDB.class_exists("Logger"). Registered from inside the running game
## so we can intercept print(), printerr(), push_error(), and
## push_warning() and ferry them back to the editor over the
## EngineDebugger channel — the same bridge PR #76 uses for screenshots.
## NOTE: deliberately no `class_name`. Registered from inside the running
## game so we can intercept print(), printerr(), push_error(), and
## push_warning() and ferry them back to the editor over the EngineDebugger
## channel — the same bridge PR #76 uses for screenshots.
##
## Logger virtuals can be called from any thread (e.g. async loaders push
## errors off the main thread). We accumulate into _pending under a Mutex
@@ -81,9 +74,9 @@ func _log_error(
## Collect every function name in the first non-empty backtrace so
## game_helper can match its eval's uniquely named wrapper function.
var funcs := PackedStringArray()
for bt in script_backtraces:
for bt: RefCounted in script_backtraces:
if bt != null and bt.get_frame_count() > 0:
for i in bt.get_frame_count():
for i: int in bt.get_frame_count():
funcs.append(bt.get_frame_function(i))
break
_mutex.lock()
@@ -0,0 +1 @@
uid://dwcs6d1y7vhqi
-57
View File
@@ -1,57 +0,0 @@
@tool
extends RefCounted
## Runtime builder for the `extends Logger` scripts in `runtime/loggers/`.
##
## `Logger` is a Godot 4.5+ class. A `.gd` file that statically declares
## `extends Logger` is rejected by the parser on Godot < 4.5 — and Godot's
## editor filesystem scan parses *every* `.gd` under the project, so just
## shipping `editor_logger.gd` / `game_logger.gd` printed two
## `Parse Error: Could not find base class "Logger"` lines on every 4.3/4.4
## editor startup (#475 follow-up). They were functionally harmless (the
## scripts are only ever instanced behind a `ClassDB.class_exists("Logger")`
## gate) but they were real red error text we shouldn't ship.
##
## Fix: the two logger scripts live in `runtime/loggers/`, which carries a
## `.gdignore` so the editor scan skips the folder entirely — no parse, no
## error, on any engine. This loader reads the source off disk with
## `FileAccess` (unaffected by `.gdignore`, which only governs the resource
## importer) and compiles it at runtime via `GDScript.new()`. Callers gate
## on `ClassDB.class_exists("Logger")` first, so `build()` only ever runs on
## 4.5+, where `extends Logger` resolves cleanly.
##
## This script itself does NOT extend Logger, so it parses on every engine
## and is safe to `preload` from `plugin.gd` and `game_helper.gd`.
const EDITOR_LOGGER_PATH := "res://addons/godot_ai/runtime/loggers/editor_logger.gd"
const GAME_LOGGER_PATH := "res://addons/godot_ai/runtime/loggers/game_logger.gd"
const VALIDATION_LOGGER_PATH := "res://addons/godot_ai/runtime/loggers/validation_logger.gd"
## Compile a `.gdignore`'d logger script from its on-disk source. Returns the
## ready-to-instance GDScript, or null if the file is missing (e.g. excluded
## from an exported game) or fails to compile. Callers must already have
## confirmed `ClassDB.class_exists("Logger")` — building an `extends Logger`
## script on an engine without the class will fail the reload() and return
## null, which the gated callers treat as "logging unavailable".
static func build(path: String) -> GDScript:
if not FileAccess.file_exists(path):
return null
var source := FileAccess.get_file_as_string(path)
if source.is_empty():
return null
var script := GDScript.new()
script.source_code = source
## Deliberately do NOT set `script.resource_path`: this builds a fresh
## anonymous GDScript every call, and a reload cycle (editor_reload_plugin,
## self-update disable→enable) calls build() again for the same path. Two
## live Resources sharing one non-empty resource_path trips Godot's
## "Another resource is loaded from path ..." error and leaves the new
## script with an empty path anyway — re-introducing red console text on
## every reload, the exact thing this folder's .gdignore set out to remove.
## game_helper.gd::_handle_eval compiles from source the same way and also
## omits resource_path. The script still resolves its absolute preloads /
## class_names fine without a path.
if script.reload() != OK:
return null
return script
@@ -1 +0,0 @@
uid://d3plpedkpvec6
@@ -0,0 +1 @@
uid://b2ff3aot6t2l4
@@ -3,9 +3,7 @@ extends Logger
## Captures GDScript runtime errors emitted while a test is running.
##
## Deliberately no class_name: this script is compiled dynamically by
## script_error_capture_loader.gd only when Logger exists. It lives in a
## `.gdignore`d folder so older Godot editor scans never parse `extends Logger`.
## Deliberately no class_name: this is an internal test helper.
##
## Only ERROR_TYPE_SCRIPT is captured. push_error(), push_warning(), and
## engine-internal ERR_FAIL_* checks are often valid negative-path assertions and
@@ -0,0 +1 @@
uid://crk8w2nei087v
@@ -1,26 +0,0 @@
@tool
extends RefCounted
## Builds the Logger-based test script-error capture lazily.
##
## Logger exists only on newer Godot versions. The capture implementation lives
## under a `.gdignore`d folder and is compiled from source only after the runner
## verifies the Logger API is present, so older editor scans do not parse an
## `extends Logger` file and emit red startup errors.
const SCRIPT_ERROR_CAPTURE_PATH := "res://addons/godot_ai/testing/loggers/script_error_capture.gd"
static func build() -> Object:
if not ClassDB.class_exists("Logger") or not OS.has_method("add_logger"):
return null
if not FileAccess.file_exists(SCRIPT_ERROR_CAPTURE_PATH):
return null
var source := FileAccess.get_file_as_string(SCRIPT_ERROR_CAPTURE_PATH)
if source.is_empty():
return null
var script := GDScript.new()
script.source_code = source
if script.reload() != OK:
return null
return script.new()
@@ -1 +0,0 @@
uid://pmtc07go8ty4
+8 -8
View File
@@ -5,17 +5,17 @@ extends RefCounted
## Lightweight test runner for MCP plugin tests. Discovers test_* methods
## on McpTestSuite instances, runs them, and collects structured results.
const ScriptErrorCaptureLoader := preload("res://addons/godot_ai/testing/script_error_capture_loader.gd")
const ScriptErrorCapture := preload("res://addons/godot_ai/testing/script_error_capture.gd")
var _results: Array[Dictionary] = []
var _last_run_ms: int = 0
var _script_error_capture: Object = null
var _script_error_capture: ScriptErrorCapture = null
var _capture_registered := false
func _notification(what: int) -> void:
if what == NOTIFICATION_PREDELETE and _capture_registered and _script_error_capture != null:
OS.call("remove_logger", _script_error_capture)
OS.remove_logger(_script_error_capture)
_capture_registered = false
@@ -173,10 +173,10 @@ func _register_capture() -> void:
if _capture_registered:
return
if _script_error_capture == null:
_script_error_capture = ScriptErrorCaptureLoader.build()
_script_error_capture = ScriptErrorCapture.new()
if _script_error_capture == null:
return
OS.call("add_logger", _script_error_capture)
OS.add_logger(_script_error_capture)
_capture_registered = true
@@ -186,19 +186,19 @@ func _unregister_capture() -> void:
if _script_error_capture == null:
_capture_registered = false
return
OS.call("remove_logger", _script_error_capture)
OS.remove_logger(_script_error_capture)
_capture_registered = false
func _begin_script_error_capture() -> void:
if _script_error_capture != null and _capture_registered:
_script_error_capture.call("begin_capture")
_script_error_capture.begin_capture()
func _end_script_error_capture() -> PackedStringArray:
if _script_error_capture == null or not _capture_registered:
return PackedStringArray()
return _script_error_capture.call("end_capture") as PackedStringArray
return _script_error_capture.end_capture()
static func _edited_scene_root() -> Node:
+3 -3
View File
@@ -124,14 +124,14 @@ func skip_suite(reason: String) -> void:
## Mark the current test as skipped when the running Godot is older than
## `min_version` (a "major.minor" string like "4.4"). Use for tests that
## `min_version` (a "major.minor" string like "4.6"). Use for tests that
## exercise an engine API or behavior that only exists on newer Godot.
## Returns true when the test was skipped, so callers can `return` from
## the test body.
##
## Example:
## func test_uses_44_only_api() -> void:
## if skip_on_godot_lt("4.4", "Engine.capture_script_backtraces is 4.4+"):
## func test_uses_46_only_api() -> void:
## if skip_on_godot_lt("4.6", "example API requires Godot 4.6+"):
## return
## ...
func skip_on_godot_lt(min_version: String, reason: String = "") -> bool:
+2
View File
@@ -50,6 +50,8 @@ const DOMAINS := [
{"id": "signal", "label": "signal", "count": 1, "tools": ["signal_manage"]},
{"id": "testing", "label": "testing", "count": 2, "tools": ["test_manage", "test_run"]},
{"id": "theme", "label": "theme", "count": 1, "tools": ["theme_manage"]},
{"id": "tilemap", "label": "tilemap", "count": 1, "tools": ["tilemap_manage"]},
{"id": "tileset", "label": "tileset", "count": 1, "tools": ["tileset_manage"]},
{"id": "ui", "label": "ui", "count": 1, "tools": ["ui_manage"]},
]
+13 -1
View File
@@ -26,6 +26,7 @@ extends McpStructuredLogRing
const MAX_LINES := 500
var _mutex := Mutex.new()
var _error_appended_total := 0
func _init() -> void:
@@ -33,9 +34,10 @@ func _init() -> void:
func append(level: String, text: String, path: String = "", line: int = 0, function: String = "", details: Dictionary = {}) -> void:
var coerced_level := _coerce_level(level)
var entry := {
"source": "editor",
"level": _coerce_level(level),
"level": coerced_level,
"text": text,
"path": path,
"line": line,
@@ -45,6 +47,8 @@ func append(level: String, text: String, path: String = "", line: int = 0, funct
entry["details"] = details.duplicate(true)
_mutex.lock()
_append_entry(entry)
if coerced_level == "error":
_error_appended_total += 1
_mutex.unlock()
@@ -96,9 +100,17 @@ func appended_total() -> int:
return n
func error_appended_total() -> int:
_mutex.lock()
var n := _error_appended_total
_mutex.unlock()
return n
func clear() -> int:
_mutex.lock()
var n := _total_count_unlocked()
_clear_storage()
_error_appended_total = 0
_mutex.unlock()
return n
+64 -12
View File
@@ -6,9 +6,8 @@ extends McpStructuredLogRing
## ferried back from the playing game over the EngineDebugger channel.
##
## Larger cap than McpEditorLogBuffer because games can be noisy. `run_id`
## rotates each time clear_for_new_run() fires (called on the game's
## mcp:hello boot beacon), giving agents a stable cursor for "lines since
## this play started".
## rotates at play-start, giving agents a stable cursor for "lines from
## this run" even when the game never reaches the mcp:hello boot beacon.
##
## Single-threaded — game_helper.gd drains its logger from `_process` and
## calls `append` from the main thread, so this subclass can use the base
@@ -17,6 +16,9 @@ extends McpStructuredLogRing
const MAX_LINES := 2000
var _run_id := ""
var _run_seq := 0
var _error_warn_total := 0
var _error_total := 0
func _init() -> void:
@@ -24,18 +26,30 @@ func _init() -> void:
func append(level: String, text: String, details: Dictionary = {}) -> void:
var entry := {"source": "game", "level": _coerce_level(level), "text": text}
var coerced_level := _coerce_level(level)
var entry := {
"source": "game",
"level": coerced_level,
"text": text,
"run_id": _run_id,
}
if not details.is_empty():
entry["details"] = details.duplicate(true)
_append_entry(entry)
if coerced_level in ["warn", "error"]:
_error_warn_total += 1
if coerced_level == "error":
_error_total += 1
## Rotate the run identifier and drop all buffered entries. Called when the
## game-side autoload sends its mcp:hello beacon, marking a fresh play cycle.
## Returns the new run_id.
## Rotate the run identifier without dropping buffered entries. Called at
## play-start so even no-hello parse failures get a fresh current-run identity.
## Historical lines stay tagged with their original run_id and can still be
## queried explicitly.
func clear_for_new_run() -> String:
_clear_storage()
_run_id = _generate_run_id()
_error_warn_total = 0
_error_total = 0
return _run_id
@@ -43,8 +57,46 @@ func run_id() -> String:
return _run_id
static func _generate_run_id() -> String:
func error_warn_total() -> int:
return _error_warn_total
func error_total() -> int:
return _error_total
func get_run_range(run_id: String, offset: int, count: int) -> Array[Dictionary]:
return get_run_page(run_id, offset, count).entries
func run_total_count(run_id: String) -> int:
return int(get_run_page(run_id, 0, 0).total_count)
func get_run_page(run_id: String, offset: int, count: int) -> Dictionary:
var entries := _entries_for_run(run_id)
var start := mini(maxi(0, offset), entries.size())
var stop := mini(entries.size(), start + maxi(0, count))
var out: Array[Dictionary] = []
for i in range(start, stop):
out.append(entries[i])
return {
"entries": out,
"total_count": entries.size(),
}
func _entries_for_run(run_id: String) -> Array[Dictionary]:
var out: Array[Dictionary] = []
for entry in get_range(0, total_count()):
if str(entry.get("run_id", "")) == run_id:
out.append(entry)
return out
func _generate_run_id() -> String:
## Opaque to agents — they only check equality. Time-based is plenty
## unique within a single editor session and avoids the RNG-seed
## reproducibility footgun.
return "r%d" % Time.get_ticks_msec()
## unique within a single editor session; the local sequence protects
## fast back-to-back test runs within the same millisecond.
_run_seq += 1
return "r%d-%d" % [Time.get_ticks_msec(), _run_seq]
+6 -2
View File
@@ -26,9 +26,13 @@ var _total_logged: int = 0
var enabled := true
func log(msg: String) -> void:
## `echo=false` records the line into the ring (so the dock's log panel shows
## it) without printing to the Godot console. Used for high-frequency
## machine-driven lines like readiness flips, which spammed the console of
## every install (#626) — filesystem scans toggle readiness on each import.
func log(msg: String, echo: bool = true) -> void:
var line := "MCP | %s" % msg
if enabled and console_echo:
if enabled and console_echo and echo:
print(line)
_lines.append(line)
if _lines.size() > MAX_LINES:
@@ -57,6 +57,14 @@ static func has_worker_alive(state: int) -> bool:
return state == RUNNING or state == RUNNING_TIMED_OUT
## True while the status worker is still within its healthy budget. Once a
## refresh has timed out, the dock keeps the warning badge but must let users
## retry Configure / Configure all instead of stranding the controls behind an
## orphaned, uninterruptible worker.
static func should_disable_client_actions(state: int) -> bool:
return state == RUNNING
## True when the dock should reject new refresh spawns. Used by the
## focus-in / manual button / cooldown-timer entrypoints.
static func is_blocked_for_spawn(state: int) -> bool:
@@ -300,6 +300,15 @@ func _set_incompatible_server(live: Dictionary, expected_version: String, port:
var proof_name := str(proof.get("proof", ""))
_can_recover_incompatible = not proof_name.is_empty()
print("MCP | proof: %s" % (proof_name if _can_recover_incompatible else "(none)"))
if not _can_recover_incompatible:
## Non-recoverable: a foreign / unprovable occupant holds the port and
## we have no ownership proof, so we must NOT kill it — surface a
## concrete free port the user can switch to instead (the same hint
## the dock crash body renders). Logging it to the editor output also
## lets `ci-stale-server-smoke --mode foreign` assert this upstream
## classification from CI. Reservation-aware on Windows.
var suggested := ClientConfigurator.suggest_free_port(port + 1)
print("MCP | port %d occupant not recoverable (no ownership proof); suggested free port %d (set godot_ai/http_port)" % [port, suggested])
_host._refresh_dock_client_statuses()
+21
View File
@@ -13,6 +13,9 @@ const SETTING_HTTP_PORT := "godot_ai/http_port"
## Comma-separated list of tool domains excluded from the server at spawn time.
const SETTING_EXCLUDED_DOMAINS := "godot_ai/excluded_domains"
const SETTING_TELEMETRY_ENABLED := "godot_ai/telemetry_enabled"
## Whether MCP log lines echo to the Godot console (dock "Log" toggle).
## The dock's ring-buffer log panel keeps recording regardless.
const SETTING_MCP_LOGGING := "godot_ai/mcp_logging"
## Returns true if the string value is truthy
@@ -37,3 +40,21 @@ static func telemetry_enabled() -> bool:
if es != null and es.has_setting(SETTING_TELEMETRY_ENABLED):
return bool(es.get_setting(SETTING_TELEMETRY_ENABLED))
return true
## Returns whether MCP log lines should echo to the Godot console. Read at
## plugin startup (to apply the persisted choice to the log buffer and
## dispatcher) and by the dock's LogViewer toggle for its initial state.
## Defaults to true when the user has never touched the toggle.
static func mcp_logging_enabled() -> bool:
var es := EditorInterface.get_editor_settings()
if es != null and es.has_setting(SETTING_MCP_LOGGING):
return bool(es.get_setting(SETTING_MCP_LOGGING))
return true
## Persist the dock "Log" toggle so the choice survives editor restarts (#626).
static func set_mcp_logging_enabled(enabled: bool) -> void:
var es := EditorInterface.get_editor_settings()
if es != null:
es.set_setting(SETTING_MCP_LOGGING, enabled)
@@ -0,0 +1,540 @@
@tool
class_name McpSurfacedErrorTracker
extends RefCounted
## Central source for "errors the agent should know exist".
##
## Editor log cursors only cover McpEditorLogBuffer. Runtime errors from the
## game subprocess can land solely in the Debugger Errors tab, so this tracker
## promotes visible Debugger-tab rows into a monotonic sequence before the
## dispatcher stamps a watermark on each response envelope.
const MAX_PROMOTED_DEBUGGER_ENTRIES := 500
const MAX_PROMOTED_DEBUGGER_KEYS := 5000
const DEBUGGER_REFRESH_MIN_INTERVAL_MS := 250
const DEBUGGER_SCAN_AFTER_STOP_MS := 5000
## #641: delays for the self-scheduled forced scans armed on run stop (and on
## game-helper hello, via McpDebuggerPlugin). Two ticks: an early one for rows
## the remote debugger delivers right around the event, and a late one past
## Godot's per-frame Errors-tab insertion throttle for error floods.
const DEFERRED_SCAN_DELAYS_SEC: Array[float] = [1.0, 5.0]
var _editor_log_buffer
var _game_log_buffer
var _debugger_errors_root: Node
var _debugger_search_root_cache: Node
var _promoted_debugger_keys: Dictionary = {}
var _promoted_debugger_key_order: Array[String] = []
var _promoted_debugger_entries: Array[Dictionary] = []
var _debugger_promoted_total := 0
var _run_seq := 0
var _oldest_retained_debugger_sequence := 1
var _last_debugger_refresh_msec := -DEBUGGER_REFRESH_MIN_INTERVAL_MS
var _debugger_scan_active := false
var _debugger_scan_until_msec := 0
var _deferred_scans_scheduled_total := 0
func _init(editor_log_buffer = null, game_log_buffer = null, debugger_errors_root: Node = null) -> void:
_editor_log_buffer = editor_log_buffer
_game_log_buffer = game_log_buffer
_debugger_errors_root = debugger_errors_root
func note_game_run_started(sticky_scan: bool = true) -> void:
_run_seq += 1
_debugger_scan_active = sticky_scan
_debugger_scan_until_msec = 0
if not sticky_scan:
_debugger_scan_until_msec = Time.get_ticks_msec() + DEBUGGER_SCAN_AFTER_STOP_MS
refresh_debugger_errors(true)
func note_game_run_stopped() -> void:
_debugger_scan_active = false
_debugger_scan_until_msec = Time.get_ticks_msec() + DEBUGGER_SCAN_AFTER_STOP_MS
schedule_deferred_scans()
## #641: promotion into the watermark used to depend on a tool call arriving
## while the scan gate was open (run active, or within DEBUGGER_SCAN_AFTER_STOP_MS
## of stop). Boot parse errors that landed in the Errors tab with no tool call
## in that window were never promoted, so the agent never got the
## new_errors_since_last_call hint. These editor-side timers force a scan
## regardless of tool-call cadence; the next stamped response then carries the
## already-promoted count even after the gate closes. Scans are content-keyed
## and idempotent, so a timer firing after an unrelated new run is harmless.
func schedule_deferred_scans(delays: Array = DEFERRED_SCAN_DELAYS_SEC) -> void:
var tree := Engine.get_main_loop() as SceneTree
if tree == null:
return
for delay in delays:
var timer := tree.create_timer(maxf(0.05, float(delay)))
timer.timeout.connect(_on_deferred_scan_timeout)
_deferred_scans_scheduled_total += 1
func deferred_scans_scheduled_total() -> int:
return _deferred_scans_scheduled_total
func _on_deferred_scan_timeout() -> void:
refresh_debugger_errors(true)
func refresh_debugger_errors(force: bool = true) -> void:
var now := Time.get_ticks_msec()
if not force and not _should_scan_debugger_for_cached_watermark(now):
return
_last_debugger_refresh_msec = now
var current_by_key: Dictionary = {}
for entry in _raw_debugger_error_entries():
if str(entry.get("level", "")) != "error":
continue
var key := _log_entry_key(entry)
var info: Dictionary = current_by_key.get(key, {"count": 0, "entry": entry})
info["count"] = int(info.get("count", 0)) + 1
current_by_key[key] = info
for key in _promoted_debugger_keys.keys():
if not current_by_key.has(key):
_promoted_debugger_keys[key] = 0
for key in current_by_key.keys():
var info: Dictionary = current_by_key[key]
var current := int(info.get("count", 0))
var stored := int(_promoted_debugger_keys.get(key, 0))
if current < stored:
_promoted_debugger_keys[key] = current
continue
if current == stored:
continue
if not _promoted_debugger_keys.has(key):
_promoted_debugger_key_order.append(key)
_promoted_debugger_keys[key] = current
_debugger_promoted_total += current - stored
var source_entry: Dictionary = info.get("entry", {})
var promoted := source_entry.duplicate(true)
promoted["_debugger_key"] = key
promoted["_debugger_occurrences"] = current
promoted["_debugger_sequence"] = _debugger_promoted_total
_remove_promoted_debugger_entry(key)
_promoted_debugger_entries.append(promoted)
_trim_promoted_debugger_entries()
_trim_promoted_debugger_key_counts()
## #645: promote an error record that has no Errors-tab row to scrape — e.g. a
## boot-time parse error that parked the game in a remote-debugger break before
## any surface got a record. The entry joins the same promoted sequence as
## scraped Debugger rows, so run-scoping (editor_entries_since), the retained
## fallback, and the response watermark all see it with no extra plumbing.
## Re-recording the same key later (the same script still broken on the next
## run) re-promotes it with a fresh sequence, mirroring how re-appearing
## Errors-tab rows behave; scan reconciliation zeroes the key's count once the
## break ends since the row never exists in the live tab.
func record_synthetic_error(entry: Dictionary) -> void:
var key := _log_entry_key(entry)
var occurrences := int(_promoted_debugger_keys.get(key, 0)) + 1
if not _promoted_debugger_keys.has(key):
_promoted_debugger_key_order.append(key)
_promoted_debugger_keys[key] = occurrences
_debugger_promoted_total += 1
var promoted := entry.duplicate(true)
promoted["_debugger_key"] = key
promoted["_debugger_occurrences"] = occurrences
promoted["_debugger_sequence"] = _debugger_promoted_total
promoted["_debugger_synthetic"] = true
_remove_promoted_debugger_entry(key)
_promoted_debugger_entries.append(promoted)
_trim_promoted_debugger_entries()
_trim_promoted_debugger_key_counts()
func watermark(force_debugger_scan: bool = false) -> Dictionary:
refresh_debugger_errors(force_debugger_scan)
return {
"run_seq": _run_seq,
"editor_ring": _error_appended_total(),
"debugger_promoted": _debugger_promoted_total,
"game_error_warn": _game_error_total(),
}
static func stamp_watermark(response: Dictionary, tracker) -> void:
if tracker == null:
return
if not tracker.has_method("watermark"):
return
response["error_watermark"] = tracker.watermark()
func debugger_promoted_total(force_debugger_scan: bool = true) -> int:
refresh_debugger_errors(force_debugger_scan)
return _debugger_promoted_total
func collect_editor_log_entries() -> Array[Dictionary]:
refresh_debugger_errors(true)
var entries: Array[Dictionary] = []
var seen_keys: Dictionary = {}
if _editor_log_buffer != null:
for entry in _editor_log_buffer.get_range(0, _editor_log_buffer.total_count()):
seen_keys[_log_entry_key(entry)] = true
entries.append(entry)
for entry in read_debugger_error_entries():
var key := _log_entry_key(entry)
if seen_keys.has(key):
continue
seen_keys[key] = true
entries.append(entry)
## #645: synthesized break records have no live Errors-tab row to scrape —
## merge them from the promoted list so logs_read(source="editor") shows
## the record that run/game responses point at.
for entry in _promoted_debugger_entries:
if not bool(entry.get("_debugger_synthetic", false)):
continue
var key := _log_entry_key(entry)
if seen_keys.has(key):
continue
seen_keys[key] = true
entries.append(_strip_promotion_bookkeeping(entry))
return entries
static func _strip_promotion_bookkeeping(entry: Dictionary) -> Dictionary:
var clean := entry.duplicate(true)
for key in ["_debugger_key", "_debugger_occurrences", "_debugger_sequence", "_debugger_synthetic"]:
clean.erase(key)
return clean
func editor_entries_since(editor_cursor: int, debugger_cursor: int, force_debugger_scan: bool = true) -> Dictionary:
refresh_debugger_errors(force_debugger_scan)
var entries: Array[Dictionary] = []
var seen_keys: Dictionary = {}
var truncated := false
if _editor_log_buffer != null:
var captured: Dictionary = _editor_log_buffer.get_since(maxi(0, editor_cursor), -1)
truncated = bool(captured.get("truncated", false))
for entry in captured.get("entries", []):
seen_keys[_log_entry_key(entry)] = true
entries.append(entry)
if debugger_cursor < _oldest_retained_debugger_sequence - 1:
truncated = true
for entry in _promoted_debugger_entries:
if int(entry.get("_debugger_sequence", 0)) <= debugger_cursor:
continue
var key := _log_entry_key(entry)
if seen_keys.has(key):
continue
seen_keys[key] = true
entries.append(entry)
return {
"entries": entries,
"truncated": truncated,
}
func retained_recent_editor_entries() -> Array[Dictionary]:
## There is no shared timestamp across the editor logger ring and Godot's
## Debugger Errors tree. Preserve the pre-PR fallback contract: newest
## buffered editor entries first, then debugger-only rows that were not in
## the ring, so stale Debugger rows cannot outrank newer ring entries.
var entries: Array[Dictionary] = []
var seen_keys: Dictionary = {}
if _editor_log_buffer != null:
entries = _editor_log_buffer.get_recent(_editor_log_buffer.total_count())
entries.reverse()
for entry in entries:
seen_keys[_log_entry_key(entry)] = true
for entry in collect_editor_log_entries():
var key := _log_entry_key(entry)
if seen_keys.has(key):
continue
seen_keys[key] = true
entries.append(entry)
return entries
func read_debugger_error_entries() -> Array[Dictionary]:
var entries: Array[Dictionary] = []
var seen_keys: Dictionary = {}
for entry in _raw_debugger_error_entries():
var key := _log_entry_key(entry)
if seen_keys.has(key):
continue
seen_keys[key] = true
entries.append(entry)
return entries
func locate_debugger_error_trees() -> Array[Tree]:
var trees: Array[Tree] = []
var root: Node = _debugger_errors_root
## #641: a deferred-scan timer can outlive an injected root (tests,
## teardown). A freed root must not fall through to the live editor UI —
## that would promote unrelated real errors into a tracker scoped to the
## dead root — so treat it as "nothing to scan".
if root != null and not is_instance_valid(root):
return trees
if root == null:
root = _debugger_search_root()
if root == null:
return trees
_collect_debugger_error_trees(root, trees)
return trees
func clear_debugger_error_trees() -> int:
var cleared := 0
for tree in locate_debugger_error_trees():
cleared += entries_from_debugger_error_tree(tree).size()
if not _press_debugger_clear_button(tree):
## Synthetic roots in tests do not have Godot's Clear button.
tree.clear()
return cleared
func _debugger_search_root() -> Node:
if is_instance_valid(_debugger_search_root_cache):
return _debugger_search_root_cache
_debugger_search_root_cache = null
var base := EditorInterface.get_base_control()
if base == null:
return null
_debugger_search_root_cache = _find_first_of_class(base, "EditorDebuggerNode")
if _debugger_search_root_cache == null:
return base
return _debugger_search_root_cache
static func _find_first_of_class(node: Node, klass: String) -> Node:
if node.get_class() == klass:
return node
for child in node.get_children():
var found := _find_first_of_class(child, klass)
if found != null:
return found
return null
static func _collect_debugger_error_trees(node: Node, out: Array[Tree]) -> void:
if node is Tree and _tree_has_debugger_errors(node as Tree):
out.append(node as Tree)
for child in node.get_children():
if child is Node:
_collect_debugger_error_trees(child as Node, out)
static func _tree_has_debugger_errors(tree: Tree) -> bool:
var root := tree.get_root()
if root == null:
return false
var item := root.get_first_child()
while item != null:
if _is_debugger_error_item(item):
return true
item = item.get_next()
return false
static func _press_debugger_clear_button(tree: Tree) -> bool:
var parent := tree.get_parent()
if parent == null:
return false
var stack: Array[Node] = [parent]
while not stack.is_empty():
var node: Node = stack.pop_back()
if node is BaseButton:
for conn in node.get_signal_connection_list("pressed"):
if str(conn.get("callable", "")).contains("_clear_errors_list"):
node.emit_signal("pressed")
return true
for child in node.get_children():
stack.push_back(child)
return false
static func entries_from_debugger_error_tree(tree: Tree) -> Array[Dictionary]:
var entries: Array[Dictionary] = []
var root := tree.get_root()
if root == null:
return entries
var item := root.get_first_child()
while item != null:
if _is_debugger_error_item(item):
entries.append(_entry_from_debugger_error_item(item))
item = item.get_next()
return entries
static func _entry_from_debugger_error_item(item: TreeItem) -> Dictionary:
var title := item.get_text(1)
var loc := _location_from_metadata(item.get_metadata(0))
var function := _function_from_title(title)
return {
"source": "editor",
"level": "warn" if item.has_meta("_is_warning") else "error",
"text": title,
"path": str(loc.get("path", "")),
"line": int(loc.get("line", 0)),
"function": function,
"details": _details_from_debugger_error_item(item, loc, function),
}
static func _details_from_debugger_error_item(item: TreeItem, loc: Dictionary, function: String) -> Dictionary:
var children: Array[Dictionary] = []
var child := item.get_first_child()
while child != null:
var child_loc := _location_from_metadata(child.get_metadata(0))
children.append({
"label": child.get_text(0),
"text": child.get_text(1),
"path": str(child_loc.get("path", "")),
"line": int(child_loc.get("line", 0)),
})
child = child.get_next()
return {
"debugger_tab": "Errors",
"time": item.get_text(0),
"message": item.get_text(1),
"error_type_name": "warning" if item.has_meta("_is_warning") else "error",
"source": {
"path": str(loc.get("path", "")),
"line": int(loc.get("line", 0)),
"function": function,
},
"resolved": {
"path": str(loc.get("path", "")),
"line": int(loc.get("line", 0)),
"function": function,
},
"children": children,
"frames": _frames_from_error_children(children),
}
static func _is_debugger_error_item(item: TreeItem) -> bool:
return item.has_meta("_is_warning") or item.has_meta("_is_error")
static func _frames_from_error_children(children: Array[Dictionary]) -> Array[Dictionary]:
var start := -1
for i in children.size():
if str(children[i].label).contains("Stack Trace"):
start = i
break
if start < 0:
for i in children.size():
if str(children[i].label).is_empty() and not str(children[i].path).is_empty():
start = maxi(i - 1, 0)
break
if start < 0:
return []
var frames: Array[Dictionary] = []
for i in range(start, children.size()):
if str(children[i].path).is_empty():
continue
frames.append({
"path": children[i].path,
"line": children[i].line,
"function": _function_from_frame_text(children[i].text),
})
return frames
static func _location_from_metadata(meta: Variant) -> Dictionary:
if meta is Array and meta.size() >= 2:
return {"path": str(meta[0]), "line": int(meta[1])}
return {"path": "", "line": 0}
static func _function_from_title(title: String) -> String:
var colon := title.find(": ")
if colon <= 0:
return ""
return title.substr(0, colon)
static func _function_from_frame_text(text: String) -> String:
var marker := text.find(" @ ")
if marker < 0:
return ""
var fn := text.substr(marker + 3).strip_edges()
if fn.ends_with("()"):
fn = fn.substr(0, fn.length() - 2)
return fn
## Shared one-line rendering of a compact editor-error entry for messages and
## hints ("text (path:line)"). Single home so the debugger plugin, project
## handler, and editor handler can't drift apart.
static func format_editor_error_summary(entry: Dictionary) -> String:
var text := str(entry.get("text", "editor error"))
var path := str(entry.get("path", ""))
var line := int(entry.get("line", 0))
if not path.is_empty() and line > 0:
return "%s (%s:%d)" % [text, path, line]
if not path.is_empty():
return "%s (%s)" % [text, path]
return text
static func _log_entry_key(entry: Dictionary) -> String:
return "%s|%s|%s|%s" % [
str(entry.get("level", "")),
str(entry.get("text", "")),
str(entry.get("path", "")),
str(entry.get("line", 0)),
]
func _error_appended_total() -> int:
if _editor_log_buffer == null:
return 0
if _editor_log_buffer.has_method("error_appended_total"):
return int(_editor_log_buffer.call("error_appended_total"))
return 0
func _game_error_total() -> int:
if _game_log_buffer == null:
return 0
if _game_log_buffer.has_method("error_total"):
return int(_game_log_buffer.call("error_total"))
return 0
func _should_scan_debugger_for_cached_watermark(now_msec: int) -> bool:
if not _debugger_scan_active and now_msec > _debugger_scan_until_msec:
return false
return now_msec - _last_debugger_refresh_msec >= DEBUGGER_REFRESH_MIN_INTERVAL_MS
func _trim_promoted_debugger_entries() -> void:
while _promoted_debugger_entries.size() > MAX_PROMOTED_DEBUGGER_ENTRIES:
_promoted_debugger_entries.pop_front()
if _promoted_debugger_entries.is_empty():
_oldest_retained_debugger_sequence = _debugger_promoted_total + 1
else:
_oldest_retained_debugger_sequence = int(_promoted_debugger_entries[0].get("_debugger_sequence", 1))
func _trim_promoted_debugger_key_counts() -> void:
while _promoted_debugger_key_order.size() > MAX_PROMOTED_DEBUGGER_KEYS:
var key := _promoted_debugger_key_order.pop_front()
_promoted_debugger_keys.erase(key)
func _remove_promoted_debugger_entry(key: String) -> void:
for i in range(_promoted_debugger_entries.size() - 1, -1, -1):
if str(_promoted_debugger_entries[i].get("_debugger_key", "")) == key:
_promoted_debugger_entries.remove_at(i)
return
func _raw_debugger_error_entries() -> Array[Dictionary]:
var entries: Array[Dictionary] = []
for tree in locate_debugger_error_trees():
entries.append_array(entries_from_debugger_error_tree(tree))
return entries
@@ -0,0 +1 @@
uid://o0ulahkt83re
+42 -190
View File
@@ -124,13 +124,9 @@ func clear_pending_download() -> void:
_latest_checksum_url = ""
## True when the running Godot can self-update in place. Godot < 4.4 takes
## the `_install_zip_inline` extract-then-restart path, and that engine's
## stricter `GDScript::reload()` (`!p_keep_state && has_instances` ->
## `ERR_ALREADY_IN_USE`) turns the extract-over-live-scripts into a reload
## error flood plus a SIGSEGV in `EditorDockManager::remove_dock` /
## `SceneTree::finalize` on the restart/quit (#475). So on < 4.4 we don't
## run the in-editor pipeline at all — the user updates manually.
## True when the running Godot is within the supported self-update floor.
## Godot < 4.5 must not be offered a one-click update to a release whose
## always-loaded scripts depend on 4.5 APIs/classes.
## Guards `major` too so a future Godot 5.x (minor 0) isn't misclassified.
func _can_self_update() -> bool:
var v := Engine.get_version_info()
@@ -138,49 +134,36 @@ func _can_self_update() -> bool:
## Pure version predicate, split out so it's testable without faking the
## running engine. In-editor self-update needs Godot >= 4.4.
## running engine. In-editor self-update needs Godot >= 4.5.
static func _version_can_self_update(major: int, minor: int) -> bool:
return major > 4 or (major == 4 and minor >= 4)
return major > 4 or (major == 4 and minor >= 5)
## Banner guidance for the gated (< 4.4) path. Shown up-front at check time
## (with the available version) and again on click, so the user understands
## the manual-update flow before they press anything. Single source of truth
## so check-time and click-time text never drift.
## Banner guidance for engines below the support floor. Shown up-front at
## check time so those users do not install an incompatible latest release.
static func _manual_update_label(version: String) -> String:
var prefix := "Update available"
var release_noun := "release"
var suffix := ""
if not version.is_empty():
prefix = "Update v%s available" % version
release_noun = "version"
suffix = " (latest: v%s)" % version
return (
prefix
+ " — in-editor update needs Godot 4.4+. Open the download page, then "
+ "replace addons/godot_ai/ manually and relaunch."
"This is the last Godot AI %s for this Godot%s. " % [release_noun, suffix]
+ "Upgrade to Godot 4.5+ to keep receiving updates."
)
## Driven by the dock's Update button. On Godot < 4.4 (see `_can_self_update`)
## the in-editor install is disabled — we open the release page for a manual
## download instead, never entering the extract pipeline that crashes those
## engines. With no resolved download URL — either the check never completed,
## or the release didn't ship a matching asset — also falls back to opening
## the release page. Otherwise kicks off the download → extract → reload
## pipeline.
## Driven by the dock's Update button. On Godot < 4.5 (see _can_self_update)
## the in-editor install is disabled so users cannot install an incompatible
## latest release. With no resolved download URL, falls back to opening the
## release page. Otherwise kicks off the download -> extract -> reload pipeline.
func start_install() -> void:
if not _can_self_update():
## Only claim success + lock the button if the browser actually opened.
## On failure (no handler, headless) keep the button enabled so the
## user can retry. Either way, leave the version-bearing guidance label
## from check time in place — don't re-emit label_text.
if OS.shell_open(RELEASES_PAGE) == OK:
install_state_changed.emit({
"button_text": "Opened download page",
"button_disabled": true,
})
else:
install_state_changed.emit({
"button_text": "Couldn't open browser — retry",
"button_disabled": false,
})
install_state_changed.emit({
"button_text": "Upgrade Godot",
"button_disabled": true,
"label_text": _manual_update_label(""),
"banner_visible": true,
})
return
if _latest_download_url.is_empty():
@@ -232,7 +215,6 @@ func start_install() -> void:
"button_disabled": false,
})
## Consulted by the dock's spawn paths (focus-in refresh, manual button,
## deferred initial refresh) — true while plugin scripts are being
## overwritten. A worker mid-`GDScriptFunction::call` into a half-
@@ -314,6 +296,8 @@ static func _is_trusted_download_url(url: String) -> bool:
const SCHEME := "https://"
if not url.begins_with(SCHEME):
return false
if url.find("\\") >= 0:
return false
var rest := url.substr(SCHEME.length())
var authority := rest
var slash := rest.find("/")
@@ -353,17 +337,17 @@ func _on_update_check_completed(
var parsed := parse_releases_response(result, response_code, body)
if not bool(parsed.get("has_update", false)):
return
if not _can_self_update():
install_state_changed.emit({
"button_text": "Upgrade Godot",
"button_disabled": true,
"label_text": _manual_update_label(String(parsed.get("version", ""))),
"banner_visible": true,
})
return
_latest_download_url = String(parsed.get("download_url", ""))
_latest_checksum_url = String(parsed.get("checksum_url", ""))
update_check_completed.emit(parsed)
## On engines that can't self-update (Godot < 4.4, #475), surface the
## full manual-update guidance AND relabel the button up-front — before
## any click — so the user knows what the button does and why.
if not _can_self_update():
install_state_changed.emit({
"button_text": "Open download page",
"label_text": _manual_update_label(String(parsed.get("version", ""))),
})
func _on_download_completed(
@@ -457,7 +441,7 @@ func _on_checksum_completed(
print("MCP | self-update checksum verified (sha256 %s)" % actual)
install_state_changed.emit({"button_text": "Installing..."})
_install_zip()
_install_zip.call_deferred()
## Surface an integrity-check failure and drop the staged zip so the bad
@@ -483,8 +467,9 @@ static func _parse_sha256_digest(text: String) -> String:
if trimmed.is_empty():
return ""
## First whitespace-delimited token; `sha256sum` separates digest and
## filename with two spaces, so allow_empty=false collapses the run.
var tokens := trimmed.split(" ", false)
## filename with two spaces, but some tools use tabs.
var normalized := trimmed.replace("\t", " ").replace("\n", " ").replace("\r", " ")
var tokens := normalized.split(" ", false)
if tokens.is_empty():
return ""
var digest := String(tokens[0]).strip_edges().to_lower()
@@ -512,167 +497,34 @@ func _install_zip() -> void:
return
## Drain in-flight workers + block new ones BEFORE any disk write.
## Without this, focus-in landing in the extractreload window spawns
## Without this, focus-in landing in the extract -> reload window spawns
## a worker that walks into a partially-overwritten script and
## SIGABRTs in `GDScriptFunction::call`.
_install_in_flight = true
_drain_dock_workers()
var version := Engine.get_version_info()
var has_runner: bool = (
_plugin != null
and _plugin.has_method("install_downloaded_update")
)
## Same major-aware predicate as the _can_self_update() gate, so a future
## Godot 5.x (minor 0) takes the runner path the gate promised — not the
## pre-4.4 inline extract. A bare `minor >= 4` here would route 5.0 to the
## crash-prone inline path even though the gate let it in.
if _version_can_self_update(int(version.get("major", 0)), int(version.get("minor", 0))) and has_runner:
if has_runner:
install_state_changed.emit({"button_text": "Reloading..."})
## Runner takes over: plugin tears down, runner extracts + scans +
## re-enables. `install_downloaded_update` calls
## `prepare_for_update_reload()` internally (kills the server,
## resets the spawn guard) see plugin.gd::install_downloaded_update.
## resets the spawn guard) - see plugin.gd::install_downloaded_update.
_plugin.install_downloaded_update(UPDATE_TEMP_ZIP, UPDATE_TEMP_DIR, _dock)
return
_install_zip_inline(version)
func _install_zip_inline(version: Dictionary) -> void:
## Pre-4.4 fallback. EditorInterface.set_plugin_enabled off/on is
## re-entry-unsafe on older Godot; we extract in-process and ask the
## user to restart.
var zip_path := ProjectSettings.globalize_path(UPDATE_TEMP_ZIP)
var install_base := ProjectSettings.globalize_path("res://")
var reader := ZIPReader.new()
if reader.open(zip_path) != OK:
_install_in_flight = false
install_state_changed.emit({
"button_text": "Extract failed",
"button_disabled": false,
})
return
var files := reader.get_files()
for file_path in files:
if not file_path.begins_with("addons/godot_ai/"):
continue
## Skip zip dir entries; parent dirs are created from each validated
## file's base dir below — the same shape the runner uses. Creating a
## dir from an unvalidated entry would itself be a traversal hole.
if file_path.ends_with("/"):
continue
## Reject path-traversal / absolute / backslash entries BEFORE any
## path_join + write. The modern runner enforces this via
## `update_reload_runner.gd::_is_safe_zip_addon_file`; the pre-4.4
## inline path used to gate only on the `addons/godot_ai/` prefix, so
## `addons/godot_ai/../../evil.gd` escaped the addon dir. This guard
## closes that gap so the weaker path runs the same checks. See #522.
if not _is_safe_zip_addon_file(file_path):
_abort_inline_install(reader, "unsafe zip path: %s" % file_path)
return
var dir := file_path.get_base_dir()
DirAccess.make_dir_recursive_absolute(install_base.path_join(dir))
var content := reader.read_file(file_path)
var target := install_base.path_join(file_path)
var f := FileAccess.open(target, FileAccess.WRITE)
## Unlike the runner (tmp+rename+per-file backup+rollback), this pre-4.4
## path writes directly over live files and can't roll back. It used to
## skip a null open and ignore store_buffer errors silently, leaving a
## partially-overwritten addons tree while still telling the user to
## restart onto it. Check both error surfaces and abort loudly instead.
## See #524.
if f == null:
_abort_inline_install(
reader,
"could not open %s for write (error %d)" % [target, FileAccess.get_open_error()],
)
return
f.store_buffer(content)
var write_error := f.get_error()
f.close()
if write_error != OK:
_abort_inline_install(reader, "write error %d for %s" % [write_error, target])
return
reader.close()
DirAccess.remove_absolute(zip_path)
DirAccess.remove_absolute(ProjectSettings.globalize_path(UPDATE_TEMP_ZIP))
DirAccess.remove_absolute(ProjectSettings.globalize_path(UPDATE_TEMP_DIR))
## Kill the old server before the reload so the re-enabled plugin spawns
## a fresh one against the new plugin version (#132).
if _plugin != null and _plugin.has_method("prepare_for_update_reload"):
_plugin.prepare_for_update_reload()
if _version_can_self_update(int(version.get("major", 0)), int(version.get("minor", 0))):
install_state_changed.emit({"button_text": "Scanning..."})
## Filesystem scan must complete before plugin reload — otherwise
## plugin.gd re-parses against a ClassDB that hasn't seen the new
## files yet, parse errors, dock tears down silently. See #127.
var fs := EditorInterface.get_resource_filesystem()
if fs != null:
fs.filesystem_changed.connect(
_on_filesystem_scanned_for_update, CONNECT_ONE_SHOT
)
fs.scan()
else:
_reload_after_update.call_deferred()
else:
## Pre-4.4: no plugin reload; refreshes resume on the old dock
## instance until the user restarts.
_install_in_flight = false
install_state_changed.emit({
"button_text": "Restart editor to apply",
"button_disabled": true,
"label_text": "Updated! Restart the editor.",
"outcome": "success",
})
## Abort the inline (pre-4.4) extract on a path-safety or write failure.
## Closes the ZIP reader, drops the in-flight gate so dock spawn paths
## un-block, and surfaces the failure loudly: this path has no rollback, so
## the addons tree may be partially overwritten and the user must reinstall
## from the download page rather than relaunch onto a half-written plugin.
## See #522 / #524.
func _abort_inline_install(reader: ZIPReader, reason: String) -> void:
reader.close()
_install_in_flight = false
push_error(
"MCP | self-update extract failed: %s. addons/godot_ai/ may be"
% reason
+ " partially updated — reinstall the plugin from the download page"
+ " before relaunching."
)
print("MCP | self-update extract aborted: %s" % reason)
install_state_changed.emit({
"button_text": "Extract failed — reinstall",
"button_text": "Reload runner missing",
"button_disabled": false,
})
## Mirror of `update_reload_runner.gd::_is_safe_zip_addon_file`. Rejects any
## entry that could escape `addons/godot_ai/` — absolute paths, backslashes,
## and `.`/`..`/empty path segments — before it reaches a `path_join` + write
## on the inline (pre-4.4) extract path, which has no rollback. Static so the
## guard is unit-testable without instancing the manager. See #522.
static func _is_safe_zip_addon_file(file_path: String) -> bool:
if file_path.is_absolute_path() or file_path.contains("\\"):
return false
if not file_path.begins_with("addons/godot_ai/"):
return false
var rel_path := file_path.trim_prefix("addons/godot_ai/")
if rel_path.is_empty() or rel_path.ends_with("/"):
return false
for segment in rel_path.split("/", true):
if segment.is_empty() or segment == "." or segment == "..":
return false
return true
func _on_filesystem_scanned_for_update() -> void:
install_state_changed.emit({"button_text": "Reloading..."})
_reload_after_update.call_deferred()