@tool class_name McpDebuggerPlugin extends EditorDebuggerPlugin const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd") ## Editor-side half of the game-process capture bridge. ## ## The game-side counterpart (`plugin/addons/godot_ai/runtime/game_helper.gd`, ## registered as autoload `_mcp_game_helper`) listens on EngineDebugger's ## message channel. This plugin sends "mcp:take_screenshot" requests and ## routes the replies back through the WebSocket McpConnection using the ## request_id the MCP dispatcher threaded through params. ## ## Why this exists: the game always runs as a separate OS process. Even ## "Embed Game Mode" on Windows/Linux (and macOS 4.5+) just reparents the ## game's window into the editor — the game's framebuffer is never reachable ## from the editor's Viewport. The debugger channel is the engine's own ## supported IPC and works identically regardless of embed mode. const CAPTURE_PREFIX := "mcp" ## CI runners under xvfb can be slow to spin up the game subprocess and ## register the autoload's capture. 8s keeps the message responsive for ## interactive users while still covering slow-CI startup. const DEFAULT_TIMEOUT_SEC := 8.0 ## How long to wait for the game-side autoload to beacon mcp:hello ## before sending the screenshot request. Godot's debugger drops ## messages whose prefix has no registered capture, so sending ## take_screenshot before the game registers its "mcp" capture is a ## silent black hole. On CI the game subprocess has been observed ## taking ~15s to boot + register. const GAME_READY_WAIT_SEC := 20.0 ## #500: how long to wait for the game-side autoload to beacon mcp:hello before ## issuing a game_eval. This is deliberately MUCH shorter than the 20s ## screenshot wait above: the eval path's total editor-side budget is this wait ## plus the 10s eval backstop (request_game_eval's timeout_sec), and that total ## MUST stay below the 15s game_eval timeout enforced at two layers: the Python ## server's send_command budget (src/godot_ai/handlers/editor.py::game_eval) and ## this plugin's own deferred budget (dispatcher.gd's 15000ms game_eval entry, ## editor/plugin-side — not server-side). Either firing produces the opaque tail. ## With the 20s screenshot wait, a not-yet-ready game made the editor poll past ## the 15s deadline, so the server gave up first with an opaque ## ~15s TimeoutError instead of the actionable "Is the game actually running?" ## error below ever reaching the client (#500's residual TimeoutError bucket). ## 3s wait + 10s backstop = 13s, comfortably under the 15s server timeout, so ## the actionable error always wins. A game launched moments before the eval ## still has the 3s grace to register; if it needs longer, the user gets a fast, ## clear "is it running?" rather than a 15s hang. const EVAL_READY_WAIT_SEC := 3.0 ## #490: how long to wait for the game's mcp:eval_compiled beacon before ## concluding the eval source failed to compile. A parse error aborts the ## game-side handler before it can reply, so without this we'd wait the ## full eval timeout for a syntax mistake. reload() of valid source is ## sub-millisecond, so 3s is comfortably clear of false positives. const EVAL_COMPILE_GRACE_SEC := 3.0 ## #490: once an eval compiles, the editor polls the game every this many ## seconds with mcp:eval_check. A backgrounded play-in-editor game has a ## frozen idle loop (no _process / SceneTreeTimer ticks) so it can't ## self-report a runtime error that aborted the eval — but its debugger ## capture callback still answers a probe. The editor's own loop keeps ## ticking, so it drives the poll. 0.35s keeps detection well under a second ## without flooding the channel; most evals reply before the first probe. const EVAL_PROBE_INTERVAL_SEC := 0.35 var _log_buffer: McpLogBuffer var _game_log_buffer: McpGameLogBuffer ## Pending request_id -> {connection, timer, timeout_callable}. ## We retain the bound timeout lambda so `_clear_pending` can disconnect ## it on success/error; otherwise the SceneTreeTimer pins the captured ## request_id until `timeout_sec` elapses (8s default). var _pending: Dictionary = {} ## Flipped true when the game-side autoload sends its "mcp:hello" boot ## beacon for the current project_run. Reset as soon as a new run is ## requested, before Godot has attached the fresh debugger session, so ## editor_state cannot leak readiness from the previous game process. var _game_ready := false var _game_run_token := 0 var _ready_run_token := -1 var _game_session_id := -1 var _game_run_active := false signal game_ready func _init(log_buffer: McpLogBuffer = null, game_log_buffer: McpGameLogBuffer = null) -> void: _log_buffer = log_buffer _game_log_buffer = game_log_buffer func _has_capture(prefix: String) -> bool: return prefix == CAPTURE_PREFIX ## Fires when a debugger session attaches — once for the editor's own ## self-session at startup, and again each time the user hits Play and a ## new game subprocess connects. Reset _game_ready so the next capture ## request waits for the (new) game's mcp:hello beacon before sending, ## avoiding stale-flag timeouts across Play→Stop→Play cycles. ## ## Do NOT log here: add_debugger_plugin() triggers this virtual before ## 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 _game_session_id = session_id func begin_game_run() -> void: _game_run_token += 1 _game_run_active = true _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) func end_game_run() -> void: _game_run_active = false _game_ready = false _ready_run_token = -1 _game_session_id = -1 func is_game_capture_ready() -> bool: return _game_run_active and _game_ready and _ready_run_token == _game_run_token func _capture(message: String, data: Array, session_id: int) -> bool: ## Godot passes the full "prefix:tail" string as `message`. match message: "mcp:screenshot_response": _on_screenshot_response(data) return true "mcp:screenshot_error": _on_screenshot_error(data) return true "mcp:log_batch": _on_log_batch(data) return true "mcp:hello": if not _game_run_active: if _log_buffer: _log_buffer.log("[debug] ignored mcp:hello with no active game run") return true if _game_session_id != -1 and session_id != _game_session_id: if _log_buffer: _log_buffer.log("[debug] ignored stale mcp:hello from debugger session %d (current %d)" % [session_id, _game_session_id]) return true ## 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. _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") return true "mcp:eval_response": _on_eval_response(data) return true "mcp:eval_error": _on_eval_error(data) return true "mcp:eval_ack": _on_eval_ack(data) return true "mcp:eval_compiled": _on_eval_compiled(data) return true "mcp:eval_runtime_error": _on_eval_runtime_error(data) return true "mcp:game_command_response": _on_game_command_response(data) return true "mcp:game_command_error": _on_game_command_error(data) return true return false func _on_log_batch(data: Array) -> void: if _game_log_buffer == null: return ## data layout: [[[level, text, details?], ...]] if data.is_empty() or not (data[0] is Array): return var entries: Array = data[0] for entry in entries: if entry is Dictionary: var dict_details: Dictionary = {} var raw_dict_details = entry.get("details", {}) if raw_dict_details is Dictionary: dict_details = raw_dict_details _game_log_buffer.append(str(entry.get("level", "info")), str(entry.get("text", "")), dict_details) continue if not (entry is Array) or entry.size() < 2: continue var details: Dictionary = {} if entry.size() > 2 and entry[2] is Dictionary: details = entry[2] _game_log_buffer.append(str(entry[0]), str(entry[1]), details) ## Request a game-process framebuffer capture over the debugger channel. ## Reply is pushed back through `connection` out-of-band because the MCP ## dispatcher has already returned a deferred-response marker for this ## request_id. Synchronous from the caller's perspective — if the ## game-side autoload hasn't beaconed yet, the wait + send run as a ## fire-and-forget coroutine kicked off from here. Structured this way ## so the call site in EditorHandler stays a plain non-await invocation. func request_game_screenshot( request_id: String, max_resolution: int, connection: McpConnection, timeout_sec: float = DEFAULT_TIMEOUT_SEC, ) -> void: if request_id.is_empty(): push_warning("MCP debugger: screenshot request missing request_id") return var tree := Engine.get_main_loop() as SceneTree if tree == null: _send_error(connection, request_id, ErrorCodes.INTERNAL_ERROR, "Editor main loop is not a SceneTree — cannot schedule capture") return if is_game_capture_ready(): _send_take_screenshot(tree, request_id, max_resolution, connection, timeout_sec) return ## Not ready yet — run the wait-then-send flow as a detached ## coroutine. It keeps itself alive via the signal subscription on ## tree.process_frame; the caller doesn't need to (and shouldn't) ## await this entrypoint. if _log_buffer: _log_buffer.log("[debug] waiting for game_helper hello (%s)" % request_id) _wait_then_send(tree, request_id, max_resolution, connection, timeout_sec) ## Coroutine: poll each editor frame until the mcp:hello beacon arrives ## (flipping _game_ready true) or the deadline elapses. Once resolved, ## either dispatch the capture or return an actionable timeout error. func _wait_then_send( tree: SceneTree, request_id: String, max_resolution: int, connection: McpConnection, 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: 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)) return _send_take_screenshot(tree, request_id, max_resolution, connection, timeout_sec) ## Send the mcp:take_screenshot message and arm the reply timeout. ## Assumes _game_ready is true. func _send_take_screenshot( tree: SceneTree, request_id: String, max_resolution: int, connection: McpConnection, timeout_sec: float, ) -> void: var session: EditorDebuggerSession = _first_active_session() if session == null: _send_error(connection, request_id, ErrorCodes.INTERNAL_ERROR, "No active debugger session — is the game actually running and started from this editor?") return var timer: SceneTreeTimer = tree.create_timer(timeout_sec) var timeout_callable := func() -> void: _on_timeout(request_id) timer.timeout.connect(timeout_callable) _pending[request_id] = { "connection": connection, "timer": timer, "timeout_callable": timeout_callable, } session.send_message("mcp:take_screenshot", [request_id, max_resolution]) if _log_buffer: _log_buffer.log("[debug] -> mcp:take_screenshot (%s)" % request_id) func _first_active_session() -> EditorDebuggerSession: for s in get_sessions(): if s is EditorDebuggerSession and s.is_active(): return s return null func _on_screenshot_response(data: Array) -> void: if data.size() < 6: push_warning("MCP debugger: malformed screenshot response (expected 6 fields, got %d)" % data.size()) return var request_id: String = data[0] var pending = _pending.get(request_id) if pending == null: ## Timed out or unknown — silently drop. return _clear_pending(request_id) var connection: McpConnection = pending.connection if connection == null or not is_instance_valid(connection): return connection.send_deferred_response(request_id, { "data": { "source": "game", "width": int(data[2]), "height": int(data[3]), "original_width": int(data[4]), "original_height": int(data[5]), "format": "png", "image_base64": data[1], } }) if _log_buffer: _log_buffer.log("[debug] <- mcp:screenshot_response (%s)" % request_id) func _on_screenshot_error(data: Array) -> void: if data.size() < 2: return var request_id: String = data[0] var message: String = data[1] var pending = _pending.get(request_id) if pending == null: return _clear_pending(request_id) var connection: McpConnection = pending.connection if connection == null or not is_instance_valid(connection): return _send_error(connection, request_id, ErrorCodes.INTERNAL_ERROR, message) func _on_timeout(request_id: String) -> void: var pending = _pending.get(request_id) if pending == null: return _pending.erase(request_id) 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.") 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: if connection == null or not is_instance_valid(connection): return var err := ErrorCodes.make(code, message) connection.send_deferred_response(request_id, err) func _clear_pending(request_id: String) -> void: var pending: Dictionary = _pending.get(request_id, {}) var timer: SceneTreeTimer = pending.get("timer") var cb: Callable = pending.get("timeout_callable", Callable()) if timer != null and timer.timeout.is_connected(cb): timer.timeout.disconnect(cb) ## #490: eval requests also carry a compile-grace timer and a runtime probe. var grace: SceneTreeTimer = pending.get("grace_timer") var gcb: Callable = pending.get("grace_callable", Callable()) if grace != null and grace.timeout.is_connected(gcb): grace.timeout.disconnect(gcb) var probe: SceneTreeTimer = pending.get("probe_timer") var pcb: Callable = pending.get("probe_callable", Callable()) if probe != null and probe.timeout.is_connected(pcb): probe.timeout.disconnect(pcb) _pending.erase(request_id) ## --- game_eval: execute arbitrary GDScript in the running game --- ## Editor-side fallback timer for game_eval. MUST stay above the game-side ## EVAL_TIMEOUT_SEC (8.0) in runtime/game_helper.gd and below the dispatcher's ## game_eval budget (15000 ms) in dispatcher.gd — i.e. game 8s < editor 10s < ## dispatcher 15s. This timer only fires when the game never replies at all, ## and its message (the timeout_callable below) is intentionally generic. Drop ## timeout_sec at/below 8s and it pre-empts the game's actionable "Eval ## exceeded 8s" message — see the TIMEOUT ORDERING note on EVAL_TIMEOUT_SEC. ## ## #500: the *not-ready* path adds EVAL_READY_WAIT_SEC (3s) on top of this 10s ## backstop. That sum (13s) must also stay below the dispatcher/server 15s ## budget, or a not-yet-ready game makes the server time out opaquely before ## the editor's actionable error returns — which is exactly the residual ~15s ## TimeoutError bucket #500 tracked down. Keep EVAL_READY_WAIT_SEC + timeout_sec ## < 15s if you tune either. func request_game_eval( code: String, request_id: String, connection: McpConnection, timeout_sec: float = 10.0, ) -> void: if request_id.is_empty(): push_warning("MCP debugger: eval request missing request_id") return var tree := Engine.get_main_loop() as SceneTree if tree == null: _send_error(connection, request_id, ErrorCodes.INTERNAL_ERROR, "Editor main loop is not a SceneTree — cannot schedule eval") return if is_game_capture_ready(): _send_eval(tree, code, request_id, connection, timeout_sec) return if _log_buffer: _log_buffer.log("[debug] waiting for game_helper hello before eval (%s)" % request_id) _wait_then_eval(tree, code, request_id, connection, timeout_sec) func _wait_then_eval( tree: SceneTree, code: String, request_id: String, connection: McpConnection, timeout_sec: float, ) -> void: ## #500: eval uses EVAL_READY_WAIT_SEC (not the 20s GAME_READY_WAIT_SEC) so ## 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: 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)) return _send_eval(tree, code, request_id, connection, timeout_sec) func _send_eval( tree: SceneTree, code: String, request_id: String, connection: McpConnection, timeout_sec: float, ) -> void: var session: EditorDebuggerSession = _first_active_session() if session == null: ## #518: capture reported ready but the debugger session is no longer live ## (the game just stopped / is restarting) — a not-ready race, so the same ## caller-actionable EVAL_GAME_NOT_READY rather than the opaque hang bucket. _send_error(connection, request_id, ErrorCodes.EVAL_GAME_NOT_READY, "Game-side capture registered but its debugger session is no longer active — the game likely just stopped or is restarting. Confirm it's running and retry.") return var timer: SceneTreeTimer = tree.create_timer(timeout_sec) var timeout_callable := func() -> void: var pending_entry = _pending.get(request_id) if pending_entry == null: return _clear_pending(request_id) var conn: McpConnection = pending_entry.connection if conn == null or not is_instance_valid(conn): return _send_error(conn, request_id, ErrorCodes.INTERNAL_ERROR, "Game eval compiled and started running but never returned within %.0fs — the code is likely stuck in an infinite loop or awaiting a signal/timer that never fires. Check logs_read(source='game')." % timeout_sec) if _log_buffer: _log_buffer.log("[debug] !! eval timeout (%s)" % request_id) timer.timeout.connect(timeout_callable) ## #490: arm the compile-grace timer. _on_eval_grace concludes a parse error ## only when the game acked the eval (it received the message and started ## reload()) but never sent mcp:eval_compiled — see there for why a missing ## ack must NOT be read as a compile error. var grace: SceneTreeTimer = tree.create_timer(EVAL_COMPILE_GRACE_SEC) var grace_callable := func() -> void: _on_eval_grace(request_id) grace.timeout.connect(grace_callable) _pending[request_id] = { "connection": connection, "timer": timer, "timeout_callable": timeout_callable, "grace_timer": grace, "grace_callable": grace_callable, "acked": false, "compiled": false, } session.send_message("mcp:eval", [request_id, code]) if _log_buffer: _log_buffer.log("[debug] -> mcp:eval (%s)" % request_id) func _on_eval_response(data: Array) -> void: if data.size() < 2: push_warning("MCP debugger: malformed eval response (expected 2 fields, got %d)" % data.size()) return var request_id: String = data[0] var pending_entry = _pending.get(request_id) if pending_entry == null: return _clear_pending(request_id) var connection: McpConnection = pending_entry.connection if connection == null or not is_instance_valid(connection): return var result_json: String = data[1] if data.size() > 1 else "null" var json := JSON.new() var parse_err := json.parse(result_json) connection.send_deferred_response(request_id, { "data": { "result": json.data if parse_err == OK else result_json, "source": "game", } }) if _log_buffer: _log_buffer.log("[debug] <- mcp:eval_response (%s)" % request_id) func _on_eval_error(data: Array) -> void: if data.size() < 2: return var request_id: String = data[0] var message: String = data[1] var pending_entry = _pending.get(request_id) if pending_entry == null: return _clear_pending(request_id) var connection: McpConnection = pending_entry.connection if connection == null or not is_instance_valid(connection): return _send_error(connection, request_id, ErrorCodes.INTERNAL_ERROR, message) if _log_buffer: _log_buffer.log("[debug] <- mcp:eval_error (%s): %s" % [request_id, message]) ## #490: the game sends this at the top of _handle_eval, BEFORE reload() (so it ## survives a parse-error abort). It positively signals "the game received this ## eval and started compiling it" — letting _on_eval_grace tell a real parse ## error (acked, never compiled) apart from a message the game hasn't serviced ## yet (never acked — main thread blocked by a long frame/load or a CPU-bound ## prior eval). func _on_eval_ack(data: Array) -> void: if data.is_empty(): return var request_id: String = data[0] var pending_entry = _pending.get(request_id) if pending_entry == null: return pending_entry["acked"] = true if _log_buffer: _log_buffer.log("[debug] <- mcp:eval_ack (%s)" % request_id) ## #490: compile-grace timer fired. Conclude a parse error ONLY when the game ## acked the eval (started reload()) but never sent mcp:eval_compiled. If it ## never acked, the game simply hasn't serviced the message yet — NOT a parse ## error — so leave _pending intact and let the normal eval timeout handle it ## rather than false-failing a valid eval and dropping its eventual real reply. func _on_eval_grace(request_id: String) -> void: var pending_entry = _pending.get(request_id) if pending_entry == null or pending_entry.get("compiled", false): return if not pending_entry.get("acked", false): if _log_buffer: _log_buffer.log("[debug] eval grace: no ack yet, deferring to timeout (%s)" % request_id) return _clear_pending(request_id) var conn: McpConnection = pending_entry.connection if conn == null or not is_instance_valid(conn): return _send_error(conn, request_id, ErrorCodes.EVAL_COMPILE_ERROR, "Game eval failed to compile — likely a GDScript syntax/parse error. The parse error text is in the editor's Output/Debugger panel; it is not capturable from the running game. Check your eval code's syntax.") if _log_buffer: _log_buffer.log("[debug] !! eval compile error (%s)" % request_id) ## #490: the game sends this the instant reload() of the eval source ## succeeds. Flips the pending entry's `compiled` flag so the compile-grace ## timer won't fire a false EVAL_COMPILE_ERROR. func _on_eval_compiled(data: Array) -> void: if data.is_empty(): return var request_id: String = data[0] var pending_entry = _pending.get(request_id) if pending_entry == null: return pending_entry["compiled"] = true if _log_buffer: _log_buffer.log("[debug] <- mcp:eval_compiled (%s)" % request_id) ## #490: compiled OK — start polling for a runtime error that may have ## aborted execute(). A backgrounded game can't self-report it, so the ## editor probes via mcp:eval_check until the eval resolves. _arm_eval_probe(request_id) ## #490: the game reported a runtime error that aborted the eval — either ## from its _process fast path (focused game) or in answer to an editor ## eval_check probe (backgrounded game). Reply fast with the real error text ## instead of waiting for the hang timeout. func _on_eval_runtime_error(data: Array) -> void: if data.size() < 2: return var request_id: String = data[0] var message: String = data[1] var pending_entry = _pending.get(request_id) if pending_entry == null: return _clear_pending(request_id) var connection: McpConnection = pending_entry.connection if connection == null or not is_instance_valid(connection): return var msg := "Game eval raised a runtime error: %s" % message if not message.is_empty() else "Game eval raised a runtime error (no message captured). Check logs_read(source='game')." _send_error(connection, request_id, ErrorCodes.EVAL_RUNTIME_ERROR, msg) if _log_buffer: _log_buffer.log("[debug] <- mcp:eval_runtime_error (%s): %s" % [request_id, message]) ## #490: arm one probe tick for an in-flight eval. Re-arms itself each tick ## until the request resolves — eval_response / eval_runtime_error / ## eval_compile_error / hang-timeout all call _clear_pending, which erases the ## entry and stops the chain. Uses the editor's own SceneTreeTimer because the ## editor loop keeps ticking even while a backgrounded game's loop is frozen. func _arm_eval_probe(request_id: String) -> void: var pending_entry = _pending.get(request_id) if pending_entry == null: return var tree := Engine.get_main_loop() as SceneTree if tree == null: return var probe_timer: SceneTreeTimer = tree.create_timer(EVAL_PROBE_INTERVAL_SEC) var probe_callable := func() -> void: _on_eval_probe_tick(request_id) pending_entry["probe_timer"] = probe_timer pending_entry["probe_callable"] = probe_callable probe_timer.timeout.connect(probe_callable) ## #490: poke the game for a runtime-error verdict, then re-arm. The game's ## _handle_eval_check answers with mcp:eval_runtime_error if a script error ## aborted this eval, else stays silent and we poll again next interval. func _on_eval_probe_tick(request_id: String) -> void: if not _pending.has(request_id): return ## resolved — stop probing var session: EditorDebuggerSession = _first_active_session() if session != null and session.is_active(): session.send_message("mcp:eval_check", [request_id]) _arm_eval_probe(request_id) ## --- game_command: curated runtime game operations --- func request_game_command( op: String, params: Dictionary, request_id: String, connection: McpConnection, timeout_sec: float = 10.0, ) -> void: if request_id.is_empty(): push_warning("MCP debugger: game command request missing request_id") return var tree := Engine.get_main_loop() as SceneTree if tree == null: _send_error(connection, request_id, ErrorCodes.INTERNAL_ERROR, "Editor main loop is not a SceneTree — cannot schedule game command") return if is_game_capture_ready(): _send_game_command(tree, op, params, request_id, connection, timeout_sec) return if _log_buffer: _log_buffer.log("[debug] waiting for game_helper hello before game_command (%s)" % request_id) _wait_then_game_command(tree, op, params, request_id, connection, timeout_sec) func _wait_then_game_command( tree: SceneTree, op: String, params: Dictionary, request_id: String, connection: McpConnection, 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: 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)) return _send_game_command(tree, op, params, request_id, connection, timeout_sec) func _send_game_command( tree: SceneTree, op: String, params: Dictionary, request_id: String, connection: McpConnection, timeout_sec: float, ) -> void: var session: EditorDebuggerSession = _first_active_session() if session == null: _send_error(connection, request_id, ErrorCodes.INTERNAL_ERROR, "No active debugger session — is the game actually running?") return var timer: SceneTreeTimer = tree.create_timer(timeout_sec) var timeout_callable := func() -> void: var pending_entry = _pending.get(request_id) if pending_entry == null: return _pending.erase(request_id) var conn: McpConnection = pending_entry.connection if conn == null or not is_instance_valid(conn): return _send_error(conn, request_id, ErrorCodes.INTERNAL_ERROR, "Game command '%s' timed out after %.0fs" % [op, timeout_sec]) if _log_buffer: _log_buffer.log("[debug] !! game_command timeout (%s)" % request_id) timer.timeout.connect(timeout_callable) _pending[request_id] = { "connection": connection, "timer": timer, "timeout_callable": timeout_callable, } session.send_message("mcp:game_command", [request_id, op, JSON.stringify(params)]) if _log_buffer: _log_buffer.log("[debug] -> mcp:game_command %s (%s)" % [op, request_id]) func _on_game_command_response(data: Array) -> void: if data.size() < 2: push_warning("MCP debugger: malformed game_command response (expected 2 fields, got %d)" % data.size()) return var request_id: String = data[0] var pending_entry = _pending.get(request_id) if pending_entry == null: return _clear_pending(request_id) var connection: McpConnection = pending_entry.connection if connection == null or not is_instance_valid(connection): return var result_json: String = data[1] if data.size() > 1 else "{}" var json := JSON.new() var parse_err := json.parse(result_json) connection.send_deferred_response(request_id, { "data": json.data if parse_err == OK else {"source": "game", "result": result_json} }) if _log_buffer: _log_buffer.log("[debug] <- mcp:game_command_response (%s)" % request_id) func _on_game_command_error(data: Array) -> void: if data.size() < 2: return var request_id: String = data[0] var message: String = data[1] var pending_entry = _pending.get(request_id) if pending_entry == null: return _clear_pending(request_id) var connection: McpConnection = pending_entry.connection if connection == null or not is_instance_valid(connection): return _send_error(connection, request_id, ErrorCodes.INTERNAL_ERROR, message) if _log_buffer: _log_buffer.log("[debug] <- mcp:game_command_error (%s): %s" % [request_id, message])