1420 lines
61 KiB
GDScript
1420 lines
61 KiB
GDScript
@tool
|
|
extends Node
|
|
## Mode B — runtime-only WebSocket server that lets the MCP bridge reach
|
|
## into the LIVE game (not the edited scene). Registered as the
|
|
## `MCPRuntimeServer` autoload by plugin.gd.
|
|
##
|
|
## The same script file is loaded at edit time (because it's in an
|
|
## @tool-enabled plugin) AND at runtime (because it's an autoload). We
|
|
## self-destruct in two cases:
|
|
## 1. Engine.is_editor_hint() — editor process loaded us, not the game.
|
|
## Keeping a second WS listener on 6570 while editing is a bug.
|
|
## 2. not OS.has_feature("editor") — any export template (debug AND
|
|
## release). "editor" is true only under an editor launch (F5/F6) and
|
|
## false in every export, so Mode B self-protects in ALL exports
|
|
## regardless of export_strip nulling the autoload. Security-critical;
|
|
## stronger than is_debug_build() (which is true in a debug export).
|
|
|
|
# Runtime dependency closure — direct preloads of export-clean scripts only.
|
|
# Deliberately NOT core/modules.gd: it statically names EditorInterface/EditorPlugin, which
|
|
# would parse-fail this autoload in an export template (godot#91713) — GDScript
|
|
# resolves identifiers at parse time, before any runtime guard can help.
|
|
const Coerce := preload("res://addons/godot_mcp_toolkit/contract/coerce.gd")
|
|
const ExecuteHints := preload("res://addons/godot_mcp_toolkit/contract/execute_hints.gd")
|
|
const Pagination := preload("res://addons/godot_mcp_toolkit/contract/pagination.gd")
|
|
const PropertySetCheck := preload("res://addons/godot_mcp_toolkit/contract/property_set_check.gd")
|
|
const ScreenshotResponse := preload("res://addons/godot_mcp_toolkit/contract/screenshot_response.gd")
|
|
const Untrusted := preload("res://addons/godot_mcp_toolkit/security/untrusted.gd")
|
|
const MCPAuth := preload("res://addons/godot_mcp_toolkit/security/auth.gd")
|
|
const Scrubber := preload("res://addons/godot_mcp_toolkit/security/scrubber.gd")
|
|
const LogHelpers := preload("res://addons/godot_mcp_toolkit/logging/log_helpers.gd")
|
|
const RegistryClient := preload("res://addons/godot_mcp_toolkit/registry/registry_client.gd")
|
|
const LogBuffer := preload("res://addons/godot_mcp_toolkit/logging/log_buffer.gd")
|
|
const Notifier := preload("res://addons/godot_mcp_toolkit/transport/notifier.gd")
|
|
const WsTransport := preload("res://addons/godot_mcp_toolkit/transport/ws_transport.gd")
|
|
const SignalPairResolver := preload("res://addons/godot_mcp_toolkit/scene/signal_pair_resolver.gd")
|
|
const TextInputSynth := preload("res://addons/godot_mcp_toolkit/runtime/text_input_synth.gd")
|
|
# Export-clean (core APIs only) — safe in this runtime autoload's preload closure.
|
|
const PortConfig := preload("res://addons/godot_mcp_toolkit/transport/port_config.gd")
|
|
|
|
# Default runtime (Mode B) scan band (inclusive). GODOT_MCP_RUNTIME_PORT pins an
|
|
# exact port (bind-exact-or-fail); GODOT_MCP_RUNTIME_PORT_MIN/_MAX relocate the
|
|
# band. Resolved by PortConfig; the two modes are mutually exclusive.
|
|
const PORT_MIN := 6570
|
|
const PORT_MAX := 6585 # 6570..6585 inclusive
|
|
const _ENV_PIN := "GODOT_MCP_RUNTIME_PORT"
|
|
const _ENV_PORT_MIN := "GODOT_MCP_RUNTIME_PORT_MIN"
|
|
const _ENV_PORT_MAX := "GODOT_MCP_RUNTIME_PORT_MAX"
|
|
const BIND := "127.0.0.1"
|
|
const JSONRPC_VERSION := "2.0"
|
|
# Throttle re-listen retries. Mirrors mcp_server.gd's editor-side loop.
|
|
# Runtime restarts on F5 each game session, so the typical "missed bind"
|
|
# recoverable case is a stale debug session that hasn't released 6570
|
|
# yet — usually clears in 1-2 frames.
|
|
const _RELISTEN_FRAME_INTERVAL := 60
|
|
const _AUTH_TIMEOUT_MS := 2000
|
|
# debugger.get_log default entry count when the caller passes no limit.
|
|
const _DEFAULT_LOG_LIMIT := 200
|
|
# execute.code console-echo cap — long snippets are truncated in the game log.
|
|
const _EXECUTE_CODE_LOG_CAP := 256
|
|
# Bound the wait for a fresh frame before falling back to the last-drawn one, in
|
|
# seconds. Non-critical and well under the server's 30 s call timeout — a
|
|
# redraw-on-demand game that is idle (not minimized) simply never fires
|
|
# frame_post_draw, so the wait must lapse rather than hang.
|
|
const _RUNTIME_FRAME_WAIT_SECONDS := 1.0
|
|
|
|
# Owns the TCP listener + WS peers + auth-handshake framing + the bound port +
|
|
# the session token. The runtime injects only the message router (its command
|
|
# match) and the bind-publish callback; auth-ack defaults to {authed:true} and
|
|
# there are no per-authed/per-closed side-effects (a running game has none).
|
|
# Drives it inline per _process (no call_deferred — a running game has no
|
|
# EditorFileSystem reentrancy to dodge).
|
|
var _transport: WsTransport = null
|
|
|
|
|
|
func _init() -> void:
|
|
# Keep the Mode-B poll loop alive while the game tree is paused (a pause
|
|
# menu, or a gameplay-pausing quit-confirm dialog). The default
|
|
# PROCESS_MODE_PAUSABLE would halt _process -> pump() the moment the game
|
|
# sets get_tree().paused = true, freezing every runtime tool until the game
|
|
# unpaused. Pure Node API -- no editor dependency.
|
|
process_mode = Node.PROCESS_MODE_ALWAYS
|
|
|
|
|
|
func _ready() -> void:
|
|
# Do not run in the editor process.
|
|
#
|
|
# Stay in the SceneTree as an inert placeholder rather than freeing:
|
|
# the autoload tracker holds a pointer to this Node until
|
|
# `remove_autoload_singleton()` fires (on plugin disable). If we
|
|
# queue_free ourselves here, that pointer becomes dangling and plugin
|
|
# disable triggers `root.remove_child(null)` in Godot's internals.
|
|
# `set_process(false)` + no TCPServer means zero work per frame and
|
|
# no port bind — the Node's presence is purely bookkeeping.
|
|
if Engine.is_editor_hint():
|
|
set_process(false)
|
|
return
|
|
# --check-only is a parse-only pass — no runtime server needed.
|
|
if "--check-only" in OS.get_cmdline_args():
|
|
set_process(false)
|
|
return
|
|
# Export gate: shipped games (debug OR release export templates) must not
|
|
# listen on 6570. has_feature("editor") is true only under an editor launch
|
|
# (F5/F6) and false in EVERY export template — stronger than is_debug_build()
|
|
# (true in a debug export), so the runtime server self-protects in all exports
|
|
# independent of export_strip nulling the autoload. Same quiescent approach —
|
|
# an empty Node at scene-tree root is cheaper than the subtle edge cases of
|
|
# auto-freeing during SceneTree setup.
|
|
if not OS.has_feature("editor"):
|
|
set_process(false)
|
|
return
|
|
_start_server()
|
|
|
|
|
|
func _exit_tree() -> void:
|
|
_stop_server()
|
|
|
|
|
|
func _start_server() -> void:
|
|
LogBuffer.setup()
|
|
var config := PortConfig.resolve(_ENV_PIN, _ENV_PORT_MIN, _ENV_PORT_MAX, PORT_MIN, PORT_MAX)
|
|
_log_port_config(config)
|
|
if not str(config.get("error", "")).is_empty():
|
|
# Fatal config error — Mode B tools disabled this session (logged above).
|
|
# No transport is built, so stop the per-frame pump too: without this,
|
|
# _process would deref the null transport every frame.
|
|
set_process(false)
|
|
return
|
|
_transport = WsTransport.new()
|
|
var pinned: bool = config["mode"] == PortConfig.MODE_PINNED
|
|
var base: int = int(config["port"]) if pinned else int(config["port_min"])
|
|
var count: int = 1 if pinned else int(config["port_max"]) - int(config["port_min"]) + 1
|
|
# await_messages = false: the runtime fire-and-forgets each frame so a coroutine
|
|
# handler (runtime.screenshot) runs independently of the poll loop, as before.
|
|
# A pinned-but-occupied port is a loud push_error in the game console (captured by
|
|
# the console tools) — the child game process can't reach the editor dock, so the
|
|
# transport's push_warning/push_error is the runtime-side surface.
|
|
_transport.configure("[MCPRuntimeServer]", base, count, BIND,
|
|
_RELISTEN_FRAME_INTERVAL, _AUTH_TIMEOUT_MS, false,
|
|
"no free port in %d-%d; Mode B tools disabled this session", pinned,
|
|
"Free the port, or change %s (or unset it to use the scanned band). Mode B tools are disabled this session." % _ENV_PIN)
|
|
# Runtime seams: only the message router (its command match) and the
|
|
# bind-publish callback (set_runtime). No auth-ack override ({authed:true}
|
|
# default), no per-authed/per-closed side-effects, and no pin-conflict handler
|
|
# (the transport's own push_error is the loud runtime surface — no dock to reach).
|
|
_transport.set_handlers(_handle_message, Callable(), Callable(), Callable(),
|
|
_on_bound)
|
|
# Runtime uses the same token file as the editor server so the bridge can
|
|
# authenticate against both with one read. The editor server writes first
|
|
# (plugin enable runs before game launch). If the file doesn't exist yet
|
|
# (edge case: game launched standalone without plugin), generate our own
|
|
# token.
|
|
var token := ""
|
|
var token_path := MCPAuth.get_token_path()
|
|
var file := FileAccess.open(token_path, FileAccess.READ)
|
|
if file != null:
|
|
token = file.get_as_text().strip_edges()
|
|
file.close()
|
|
if token.is_empty():
|
|
token = MCPAuth.generate_token()
|
|
MCPAuth.write_token(token)
|
|
_transport.set_token(token)
|
|
_transport.ensure_listening()
|
|
|
|
|
|
# Log the resolved Mode-B listen-port config at startup (so the game console
|
|
# tells WHY this port was chosen): the port + source, a one-line note when a pin
|
|
# makes the band moot, or a loud push_error on a config error — never a silent default.
|
|
func _log_port_config(config: Dictionary) -> void:
|
|
var config_error := str(config.get("error", ""))
|
|
if not config_error.is_empty():
|
|
push_error("[MCPRuntimeServer] Invalid port config: %s - Mode B tools disabled this session." % config_error)
|
|
return
|
|
if bool(config.get("band_ignored", false)):
|
|
print("[MCPRuntimeServer] note: %s pins an exact port, so the %s/%s band is ignored." % [
|
|
_ENV_PIN, _ENV_PORT_MIN, _ENV_PORT_MAX])
|
|
match str(config.get("source", "")):
|
|
"env-pin":
|
|
print("[MCPRuntimeServer] port %d (pinned via %s)" % [int(config["port"]), _ENV_PIN])
|
|
"env-band":
|
|
print("[MCPRuntimeServer] port: scanning %d-%d (band via %s/%s)" % [
|
|
int(config["port_min"]), int(config["port_max"]), _ENV_PORT_MIN, _ENV_PORT_MAX])
|
|
_:
|
|
print("[MCPRuntimeServer] port: scanning %d-%d (default)" % [
|
|
int(config["port_min"]), int(config["port_max"])])
|
|
|
|
|
|
func _stop_server() -> void:
|
|
set_process(false)
|
|
if _transport != null:
|
|
_transport.close_all(1000, "")
|
|
_transport.shutdown_listener()
|
|
# Best-effort registry cleanup (game may be force-killed).
|
|
RegistryClient.clear_runtime()
|
|
|
|
|
|
# Fired by the transport on a fresh bind (initial or late, both modes): publish
|
|
# the port so the bridge can discover Mode B. The editor mirrors this — its
|
|
# fresh-bind seam re-publishes the registry entry via the composition root.
|
|
func _on_bound(port: int) -> void:
|
|
RegistryClient.set_runtime(port)
|
|
|
|
|
|
func _process(_delta: float) -> void:
|
|
# A config-error start leaves no transport (set_process(false) already stops
|
|
# this loop); the guard keeps a stray tick a no-op instead of a null deref —
|
|
# the twin of the editor's null guard in _poll_connections.
|
|
if _transport == null:
|
|
return
|
|
LogBuffer.poll()
|
|
# Keep the listener up across transient socket loss. Editor / release
|
|
# gating from _ready prevents _process from running where it shouldn't
|
|
# (set_process(false)), so reaching here = debug-build runtime. Inline pump
|
|
# (no call_deferred — a running game has no EditorFileSystem reentrancy).
|
|
_transport.pump()
|
|
|
|
|
|
func _handle_message(peer: WebSocketPeer, text: String) -> void:
|
|
var parser := JSON.new()
|
|
var parse_err := parser.parse(text)
|
|
if parse_err != OK:
|
|
_send_error(peer, null, -32700, "Parse error: %s" % parser.get_error_message())
|
|
return
|
|
|
|
var msg = parser.data
|
|
if typeof(msg) != TYPE_DICTIONARY:
|
|
_send_error(peer, null, -32600, "Invalid Request: top-level must be an object")
|
|
return
|
|
|
|
# Auth handshake — the transport validates, marks authed, and sends the
|
|
# default {authed:true} ack (the runtime supplies no ack override).
|
|
if not _transport.is_authed(peer):
|
|
_transport.validate_auth(peer, msg)
|
|
return
|
|
|
|
var id = msg.get("id", null)
|
|
if typeof(id) == TYPE_FLOAT and int(id) == id:
|
|
id = int(id)
|
|
var method := str(msg.get("method", ""))
|
|
var params = msg.get("params", null)
|
|
|
|
if method.is_empty():
|
|
_send_error(peer, id, -32600, "Invalid Request: missing method")
|
|
return
|
|
|
|
match method:
|
|
"echo":
|
|
_send_result(peer, id, params)
|
|
"ping":
|
|
_send_result(peer, id, {"success": true})
|
|
"runtime.screenshot":
|
|
_cmd_runtime_screenshot(peer, id, params)
|
|
"runtime.get_node_state":
|
|
_cmd_runtime_get_node_state(peer, id, params)
|
|
"runtime.set_property":
|
|
_cmd_runtime_set_property(peer, id, params)
|
|
"debugger.get_log":
|
|
_cmd_debugger_get_log(peer, id, params)
|
|
"signal.list":
|
|
_cmd_signal_list(peer, id, params)
|
|
"signal.connect":
|
|
_cmd_signal_connect(peer, id, params)
|
|
"signal.disconnect":
|
|
_cmd_signal_disconnect(peer, id, params)
|
|
"signal.emit":
|
|
_cmd_signal_emit(peer, id, params)
|
|
"input.simulate":
|
|
_cmd_input_simulate(peer, id, params)
|
|
"animation_player.control":
|
|
_cmd_animation_player_control(peer, id, params)
|
|
"runtime.get_script_vars":
|
|
_cmd_runtime_get_script_vars(peer, id, params)
|
|
"execute.code":
|
|
_cmd_execute_code(peer, id, params)
|
|
_:
|
|
_send_error(peer, id, -32601, "Method not found: %s" % method)
|
|
|
|
|
|
func _send_result(peer: WebSocketPeer, id, result) -> void:
|
|
Notifier.send_result(peer, id, result, "[MCPRuntimeServer]")
|
|
|
|
|
|
func _send_error(peer: WebSocketPeer, id, code: int, message: String) -> void:
|
|
Notifier.send_error(peer, id, code, message)
|
|
|
|
|
|
|
|
# ---- Runtime command helpers ------------------------------------------------
|
|
|
|
|
|
func _cmd_runtime_screenshot(peer: WebSocketPeer, id, params) -> void:
|
|
var viewport := get_viewport()
|
|
if viewport == null:
|
|
_send_result(peer, id, MCPToolkitError.fail("INTERNAL", "no viewport available"))
|
|
return
|
|
|
|
var params_dict: Dictionary = params if typeof(params) == TYPE_DICTIONARY else {}
|
|
|
|
# Reject a bad image_detail up front so a typo can't slip through as full-res (the
|
|
# pure dims calculator treats an unknown value as native, so the guard is here).
|
|
var detail := ScreenshotResponse.detail_of(params_dict)
|
|
if detail.is_empty():
|
|
_send_result(peer, id, MCPToolkitError.fail("INVALID_PARAMS",
|
|
"image_detail must be one of [full, mid, low] (got %s)"
|
|
% str(params_dict.get("image_detail", ""))))
|
|
return
|
|
|
|
var force_foreground := bool(params_dict.get("force_foreground_game", false))
|
|
|
|
var remediation: Array[String] = []
|
|
var foreground_hint := ""
|
|
|
|
# Honor force_foreground_game on request, regardless of the minimized read: an
|
|
# embedded game (the editor's Game view) is an owner-linked top-level window
|
|
# hard-gated to WINDOWED, never reported minimized even when the editor is, so
|
|
# gating the lever on _window_is_minimized() would silently skip it.
|
|
if force_foreground:
|
|
foreground_hint = _foreground_game_if_requested(remediation)
|
|
|
|
# Try to capture rather than short-circuit on window state: a window that still
|
|
# composites (an embedded game, or a floating game merely unfocused) yields a
|
|
# fresh frame. Bound the wait so a suspended window — or an idle redraw-on-demand
|
|
# game that never fires frame_post_draw — does not park until the server's call
|
|
# timeout.
|
|
var frame_arrived := await _await_frame_or_timeout()
|
|
|
|
# The wait lapsed AND the window cannot draw: rendering is genuinely suspended
|
|
# (a floating/top-level game minimized, or fully occluded on macOS), so no frame
|
|
# can be produced — return the diagnostic signal instead of a stale last-drawn
|
|
# frame. A lapse while the window CAN draw means an idle redraw-on-demand game,
|
|
# where the last-drawn frame IS the current state — fall through and return it.
|
|
if _should_signal_minimized(frame_arrived, not DisplayServer.window_can_draw(0)):
|
|
_send_result(peer, id, MCPToolkitError.fail("RUNTIME_WINDOW_MINIMIZED",
|
|
"the running game window can't render — it is minimized or fully occluded, so rendering is suspended",
|
|
"The running game window can't render (minimized or fully occluded), so no frame is produced — not a crash or timeout. Retry with force_foreground_game:true to raise and capture the game window, or restore/reveal it yourself."))
|
|
return
|
|
|
|
var image := viewport.get_texture().get_image()
|
|
if image == null:
|
|
_send_result(peer, id, MCPToolkitError.fail("INTERNAL", "viewport texture unavailable"))
|
|
return
|
|
|
|
# Full-res encode always — this is what a disk copy persists, unaffected by
|
|
# image_detail (the cap is inline-only).
|
|
var png_bytes := image.save_png_to_buffer()
|
|
if png_bytes.is_empty():
|
|
_send_result(peer, id, MCPToolkitError.fail("INTERNAL", "save_png_to_buffer returned empty"))
|
|
return
|
|
|
|
var inline := _downscale_inline_png(image, params_dict, detail)
|
|
|
|
# Runtime save-path allowlist is user://screenshots/ only — a shipped game has no
|
|
# res:// write access, and the editor server owns the res:// destination. The
|
|
# send-path size guard sees the final (inline) buffer, so no byte check here.
|
|
var response := ScreenshotResponse.build(
|
|
params_dict, png_bytes, image.get_width(), image.get_height(),
|
|
["user://screenshots/"],
|
|
inline["png_bytes"], int(inline["width"]), int(inline["height"]))
|
|
if response.get("success") == false:
|
|
_send_result(peer, id, response)
|
|
return
|
|
if not remediation.is_empty():
|
|
response["remediation"] = remediation
|
|
# An embedded game can't be foregrounded (owner-linked, editor-controlled), so a
|
|
# force_foreground_game request there took no effect — disclose why the window
|
|
# didn't move rather than leaving the caller with a silent no-op. Append rather
|
|
# than clobber a disk full-res hint the shaper may already have set.
|
|
if not foreground_hint.is_empty():
|
|
if response.has("hint"):
|
|
response["hint"] = "%s %s" % [response["hint"], foreground_hint]
|
|
else:
|
|
response["hint"] = foreground_hint
|
|
_send_result(peer, id, response)
|
|
|
|
|
|
# Encode the downscaled inline PNG for an image_detail cap, but only when an inline
|
|
# image is actually returned (inline/both mode) and the level shrinks the frame —
|
|
# disk-only returns the full-res file, so it needs no inline buffer. Returns
|
|
# {png_bytes, width, height}; png_bytes is empty (and dims -1) when no downscale
|
|
# applies, which ScreenshotResponse.build reads as "use the full-res buffer inline".
|
|
func _downscale_inline_png(image: Image, params_dict: Dictionary, detail: String) -> Dictionary:
|
|
var empty := {"png_bytes": PackedByteArray(), "width": -1, "height": -1}
|
|
if ScreenshotResponse.mode_of(params_dict) == "disk":
|
|
return empty
|
|
var target := ScreenshotResponse.image_detail_dims(image.get_width(), image.get_height(), detail)
|
|
if target.x == image.get_width() and target.y == image.get_height():
|
|
return empty
|
|
# duplicate() is typed Ref<Resource>; the explicit Image local coerces (loud on a
|
|
# mismatch, unlike `as`). Resize the copy so the full-res buffer stays intact for disk.
|
|
var inline_image: Image = image.duplicate()
|
|
inline_image.resize(target.x, target.y, Image.INTERPOLATE_LANCZOS)
|
|
return {
|
|
"png_bytes": inline_image.save_png_to_buffer(),
|
|
"width": inline_image.get_width(),
|
|
"height": inline_image.get_height(),
|
|
}
|
|
|
|
|
|
# Raise the game window at the caller's explicit request. An embedded game (the
|
|
# editor's Game view) is owner-linked and editor-controlled: the engine hard-gates
|
|
# it to WINDOWED and re-asserts its z-order every frame, so window_set_mode /
|
|
# window_move_to_foreground are inert there — the lever can't work, so DON'T claim
|
|
# it. A top-level game DOES respond: un-minimize (only when actually minimized, so
|
|
# a windowed game isn't needlessly re-set) + move-to-foreground + focus is a real,
|
|
# visible side effect — claim "foregrounded_game" for both an un-minimize AND a
|
|
# raise-from-background. Returns a hint (empty when none) explaining an embedded
|
|
# no-op so the caller isn't left with a silent non-effect.
|
|
func _foreground_game_if_requested(remediation: Array[String]) -> String:
|
|
if _is_embedded_in_editor():
|
|
return ("The game runs embedded in the editor's Game view and can't be independently "
|
|
+ "foregrounded — it is already composited in the Game dock. Raise or restore the "
|
|
+ "editor window to view it.")
|
|
if _window_is_minimized():
|
|
DisplayServer.window_set_mode(DisplayServer.WINDOW_MODE_WINDOWED, 0)
|
|
_ensure_game_focus()
|
|
remediation.append("foregrounded_game")
|
|
return ""
|
|
|
|
|
|
# True when the game was launched embedded in the editor's Game view. The API is
|
|
# 4.4+ (the release the embed feature landed); on the 4.2/4.3 floor the method is
|
|
# absent and embedding is impossible, so its absence correctly reads as top-level.
|
|
func _is_embedded_in_editor() -> bool:
|
|
return Engine.has_method("is_embedded_in_editor") and bool(Engine.call("is_embedded_in_editor"))
|
|
|
|
|
|
# Decide whether to return the render-suspended signal after the bounded frame-wait.
|
|
# Signals ONLY when a fresh frame could not be produced AND the window cannot draw —
|
|
# the genuine suspended case (a top-level game minimized, or fully occluded on
|
|
# macOS, where the compositor stops presenting). A frame that arrived, or a lapse
|
|
# while the window can still draw (idle redraw-on-demand, last frame is current),
|
|
# both proceed to capture instead. Pure decision, extracted so it is unit-testable
|
|
# without a live game window.
|
|
static func _should_signal_minimized(frame_arrived: bool, window_suspended: bool) -> bool:
|
|
return not frame_arrived and window_suspended
|
|
|
|
|
|
# True when the game's main window is minimized (render suspended).
|
|
func _window_is_minimized() -> bool:
|
|
return DisplayServer.window_get_mode(0) == DisplayServer.WINDOW_MODE_MINIMIZED
|
|
|
|
|
|
# Await the next frame_post_draw, but give up after _RUNTIME_FRAME_WAIT_SECONDS so
|
|
# an idle redraw-on-demand window (which never fires it) does not hang the
|
|
# handler. Returns true as soon as a frame draws, false if the deadline lapses
|
|
# first — the caller uses that to tell a fresh capture from a suspended window.
|
|
# Polls the frame flag each process frame, which itself ticks even on an idle
|
|
# window, so the deadline is always reachable.
|
|
func _await_frame_or_timeout() -> bool:
|
|
var got_frame := [false]
|
|
var on_frame := func() -> void:
|
|
got_frame[0] = true
|
|
RenderingServer.frame_post_draw.connect(on_frame, CONNECT_ONE_SHOT)
|
|
var deadline := Time.get_ticks_msec() + int(_RUNTIME_FRAME_WAIT_SECONDS * 1000.0)
|
|
while not got_frame[0] and Time.get_ticks_msec() < deadline:
|
|
await get_tree().process_frame
|
|
if not got_frame[0] and RenderingServer.frame_post_draw.is_connected(on_frame):
|
|
RenderingServer.frame_post_draw.disconnect(on_frame)
|
|
return got_frame[0]
|
|
|
|
|
|
func _cmd_runtime_get_node_state(peer: WebSocketPeer, id, params) -> void:
|
|
if typeof(params) != TYPE_DICTIONARY:
|
|
_send_result(peer, id, MCPToolkitError.fail("INVALID_PARAMS", "params must be an object"))
|
|
return
|
|
var path := str(params.get("node_path", ""))
|
|
if path.is_empty():
|
|
_send_result(peer, id, MCPToolkitError.fail("INVALID_PARAMS", "missing node_path"))
|
|
return
|
|
|
|
var tree := get_tree()
|
|
if tree == null or tree.root == null:
|
|
_send_result(peer, id, MCPToolkitError.fail("INTERNAL", "scene tree unavailable"))
|
|
return
|
|
|
|
var node := tree.root.get_node_or_null(path)
|
|
if node == null:
|
|
var hint := _build_not_found_hint(tree.root, path)
|
|
_send_result(peer, id, MCPToolkitError.fail("NOT_FOUND", "node not found: %s%s" % [path, hint]))
|
|
return
|
|
|
|
var props := {}
|
|
for prop in node.get_property_list():
|
|
var usage: int = int(prop.get("usage", 0))
|
|
# Only inspector-visible properties — avoids engine-internal state
|
|
# and category headers.
|
|
if not (usage & PROPERTY_USAGE_EDITOR):
|
|
continue
|
|
var pname := str(prop.get("name", ""))
|
|
if pname.is_empty() or pname.begins_with("_"):
|
|
continue
|
|
props[pname] = Coerce.serialize_value(node.get(pname))
|
|
|
|
_send_result(peer, id, {
|
|
"name": String(node.name),
|
|
"class": node.get_class(),
|
|
"path": path,
|
|
"properties": props,
|
|
})
|
|
|
|
|
|
func _cmd_runtime_get_script_vars(peer: WebSocketPeer, id, params) -> void:
|
|
if typeof(params) != TYPE_DICTIONARY:
|
|
_send_result(peer, id, MCPToolkitError.fail("INVALID_PARAMS", "params must be an object"))
|
|
return
|
|
var path := str(params.get("node_path", ""))
|
|
if path.is_empty():
|
|
_send_result(peer, id, MCPToolkitError.fail("INVALID_PARAMS", "missing node_path"))
|
|
return
|
|
|
|
var tree := get_tree()
|
|
if tree == null or tree.root == null:
|
|
_send_result(peer, id, MCPToolkitError.fail("INTERNAL", "scene tree unavailable"))
|
|
return
|
|
|
|
var node := tree.root.get_node_or_null(path)
|
|
if node == null:
|
|
var hint := _build_not_found_hint(tree.root, path)
|
|
_send_result(peer, id, MCPToolkitError.fail("NOT_FOUND", "node not found: %s%s" % [path, hint]))
|
|
return
|
|
|
|
var script = node.get_script()
|
|
if script == null:
|
|
_send_result(peer, id, {
|
|
"name": String(node.name),
|
|
"class": node.get_class(),
|
|
"script_path": "",
|
|
"path": path,
|
|
"variables": [],
|
|
"count": 0,
|
|
})
|
|
return
|
|
|
|
var visibility_filter := str(params.get("visibility", "all"))
|
|
if not (visibility_filter in ["public", "private", "all"]):
|
|
_send_result(peer, id, MCPToolkitError.fail("INVALID_PARAMS",
|
|
"visibility must be 'public', 'private', or 'all' (got '%s')" % visibility_filter))
|
|
return
|
|
|
|
var variables: Array = []
|
|
for prop in node.get_property_list():
|
|
var usage: int = int(prop.get("usage", 0))
|
|
if not (usage & PROPERTY_USAGE_SCRIPT_VARIABLE):
|
|
continue
|
|
var pname := str(prop.get("name", ""))
|
|
if pname.is_empty():
|
|
continue
|
|
var vis := "private" if pname.begins_with("_") else "public"
|
|
if visibility_filter != "all" and vis != visibility_filter:
|
|
continue
|
|
variables.append({
|
|
"name": pname,
|
|
"value": Coerce.serialize_value(node.get(pname)),
|
|
"visibility": vis,
|
|
})
|
|
_send_result(peer, id, {
|
|
"name": String(node.name),
|
|
"class": node.get_class(),
|
|
"script_path": script.resource_path,
|
|
"path": path,
|
|
"variables": variables,
|
|
"count": variables.size(),
|
|
})
|
|
|
|
|
|
## Walk path segments and report where resolution failed + list siblings.
|
|
func _build_not_found_hint(root: Node, path: String) -> String:
|
|
var segments := path.split("/")
|
|
var current := root
|
|
# Skip leading empty segment from absolute paths (e.g. "/root/World" → ["", "root", "World"])
|
|
var start := 1 if segments.size() > 0 and segments[0] == "" else 0
|
|
# For absolute paths, the first real segment is "root" — skip it since we start at tree.root
|
|
if start < segments.size() and segments[start] == "root":
|
|
start += 1
|
|
for i in range(start, segments.size()):
|
|
var seg: String = segments[i]
|
|
var child := current.get_node_or_null(seg)
|
|
if child == null:
|
|
var siblings: Array = []
|
|
for c in current.get_children():
|
|
siblings.append(str(c.name))
|
|
if siblings.is_empty():
|
|
return ". '%s' has no children." % str(current.get_path())
|
|
return ". '%s' has children: %s" % [str(current.get_path()), str(siblings)]
|
|
current = child
|
|
return ""
|
|
|
|
|
|
func _cmd_runtime_set_property(peer: WebSocketPeer, id, params) -> void:
|
|
if typeof(params) != TYPE_DICTIONARY:
|
|
_send_result(peer, id, MCPToolkitError.fail("INVALID_PARAMS", "params must be an object"))
|
|
return
|
|
var node_path: String = str(params.get("node_path", ""))
|
|
var property: String = str(params.get("property", ""))
|
|
var value = params.get("value")
|
|
|
|
if node_path.is_empty() or property.is_empty():
|
|
_send_result(peer, id, MCPToolkitError.fail("INVALID_PARAMS",
|
|
"node_path and property are required"))
|
|
return
|
|
|
|
var tree := get_tree()
|
|
if tree == null or tree.root == null:
|
|
_send_result(peer, id, MCPToolkitError.fail("INTERNAL", "scene tree unavailable"))
|
|
return
|
|
|
|
var node := tree.root.get_node_or_null(node_path)
|
|
if node == null:
|
|
# Walk path segments and list siblings at the failing level.
|
|
var hint := _build_not_found_hint(tree.root, node_path)
|
|
_send_result(peer, id, MCPToolkitError.fail("NOT_FOUND",
|
|
"node not found: %s%s" % [node_path, hint]))
|
|
return
|
|
|
|
# Verify property exists — for compound paths like "position:x", check
|
|
# the base property name against the node's property list.
|
|
var current = node.get(property)
|
|
if current == null:
|
|
var base_prop := property.split(":")[0] if ":" in property else property
|
|
var found := false
|
|
for p in node.get_property_list():
|
|
if str(p.get("name", "")) == base_prop:
|
|
found = true
|
|
break
|
|
if not found:
|
|
_send_result(peer, id, MCPToolkitError.fail("NOT_FOUND",
|
|
"property '%s' not found on %s" % [property, node_path]))
|
|
return
|
|
|
|
var coerced = Coerce.coerce_value(value)
|
|
if typeof(coerced) == TYPE_DICTIONARY and (coerced as Dictionary).has("_coerce_error"):
|
|
_send_result(peer, id, MCPToolkitError.fail("INVALID_PARAMS", str(coerced["_coerce_error"])))
|
|
return
|
|
# Auto-coerce string → NodePath when the property expects NodePath.
|
|
if typeof(current) == TYPE_NODE_PATH and typeof(coerced) == TYPE_STRING:
|
|
coerced = NodePath(str(coerced))
|
|
|
|
node.set(property, coerced)
|
|
|
|
# Read back to confirm
|
|
var new_value = node.get(property)
|
|
# Classify the write on scalar (non-colon) paths: DROPPED (silent wrong-type,
|
|
# Godot's set() discards an unassignable Variant) → SET_FAILED; ADJUSTED (engine
|
|
# reshaped the value, e.g. truncation 7.9→7) → success with a warning appended
|
|
# below. A colon sub-path (e.g. "position:x") isn't read back through node.get()
|
|
# (before/after both null), so it stays best-effort — mirrors the editor split.
|
|
var adjusted_warning := ""
|
|
if ":" not in property:
|
|
var outcome := PropertySetCheck.describe_set_drop(current, new_value, coerced, property)
|
|
if outcome.get("status", "") == "dropped":
|
|
# A bound setter (position/modulate) may have Variant-converted the wrong
|
|
# type to a ZERO and stored it; restore the prior value so SET_FAILED is
|
|
# non-destructive on the live node.
|
|
node.set(property, current)
|
|
_send_result(peer, id, MCPToolkitError.fail("SET_FAILED", str(outcome.get("error", ""))))
|
|
return
|
|
if outcome.get("status", "") == "adjusted":
|
|
adjusted_warning = str(outcome.get("warning", ""))
|
|
var result := {
|
|
"node_path": node_path,
|
|
"property": property,
|
|
"old_value": Coerce.serialize_value(current),
|
|
"new_value": Coerce.serialize_value(new_value),
|
|
}
|
|
|
|
# Autoload hint: direct children of /root are autoloads — they persist
|
|
# across scene transitions. Warn that changes carry forward unless the
|
|
# game's own reset logic explicitly restores the property.
|
|
if node.get_parent() == tree.root:
|
|
result["warning"] = (
|
|
"'%s' is an autoload — it persists across scene transitions. " % node.name +
|
|
"This change will carry forward through restarts/level changes " +
|
|
"unless the game explicitly resets '%s'." % property)
|
|
|
|
# Accepted-but-adjusted note (engine reshaped the value). Combine with the
|
|
# autoload warning if both apply so neither caveat is lost.
|
|
if adjusted_warning != "":
|
|
if result.has("warning"):
|
|
result["warning"] = str(result["warning"]) + " " + adjusted_warning
|
|
else:
|
|
result["warning"] = adjusted_warning
|
|
|
|
_send_result(peer, id, result)
|
|
|
|
|
|
func _cmd_debugger_get_log(peer: WebSocketPeer, id, params) -> void:
|
|
var limit := _DEFAULT_LOG_LIMIT
|
|
if typeof(params) == TYPE_DICTIONARY and params.has("limit"):
|
|
limit = max(1, int(params.get("limit", _DEFAULT_LOG_LIMIT)))
|
|
var source: String = "buffer"
|
|
if typeof(params) == TYPE_DICTIONARY and params.has("source"):
|
|
source = str(params.get("source", "buffer"))
|
|
if not (source in ["buffer", "file"]):
|
|
_send_result(peer, id, MCPToolkitError.fail("INVALID_PARAMS",
|
|
"source must be 'buffer' or 'file' (got %s)" % source))
|
|
return
|
|
|
|
var text_filter: String = ""
|
|
var text_regex: RegEx = null
|
|
if typeof(params) == TYPE_DICTIONARY and params.has("text_filter"):
|
|
text_filter = str(params.get("text_filter", ""))
|
|
if text_filter != "" and typeof(params) == TYPE_DICTIONARY:
|
|
var is_regex: bool = bool(params.get("is_regex", false))
|
|
if is_regex:
|
|
text_regex = RegEx.new()
|
|
if text_regex.compile("(?i)" + text_filter) != OK:
|
|
_send_result(peer, id, MCPToolkitError.fail("INVALID_PARAMS",
|
|
"text_filter is not a valid regex (is_regex=true). "
|
|
+ "To search for literal text, omit is_regex or set it to false. "
|
|
+ "For regex, check for unbalanced groups () [] or unescaped metacharacters."))
|
|
return
|
|
|
|
var regex_warning := _detect_double_escaped_regex(text_filter) if text_regex != null else ""
|
|
|
|
if source == "buffer":
|
|
var buf_result: Dictionary = LogBuffer.get_entries(limit, [], -1, text_filter, text_regex)
|
|
var entries: Array = buf_result["entries"]
|
|
for entry in entries:
|
|
var scrubbed := Scrubber.scrub(str(entry["message"]), "debugger.get_log")
|
|
entry["message"] = scrubbed["text"]
|
|
var has_more: bool = buf_result["has_more"]
|
|
# Capped-tail pagination (oldest lines drop first, no cursor): on more, guide the caller to raise limit / narrow text_filter.
|
|
var hint := ""
|
|
if has_more:
|
|
hint = "more lines remain — raise limit or narrow with text_filter (capped tail: the oldest lines drop first, no cursor)."
|
|
var response := Pagination.build(
|
|
{"lines": Untrusted.wrap("game_log", "buffer", JSON.stringify(entries))},
|
|
"lines", int(buf_result["total_lines"]), int(buf_result["returned"]), has_more,
|
|
"", 0, hint, {"next_id": buf_result["next_id"], "source": "buffer"})
|
|
# On 4.2-4.4, buffer uses file tailing — warn if empty and file logging is off.
|
|
if entries.is_empty() and not LogBuffer.uses_logger_api():
|
|
if not LogHelpers.is_file_logging_enabled():
|
|
response["warning"] = "On Godot 4.2-4.4 the log buffer captures output by tailing the log file. Enable debug/file_logging/enable_file_logging in ProjectSettings and restart the editor for output capture to work. Logs from before enabling will not be available."
|
|
if not regex_warning.is_empty():
|
|
response["warning"] = regex_warning
|
|
_send_result(peer, id, response)
|
|
return
|
|
|
|
# source == "file" — original log-file reader.
|
|
# Raw (un-globalized) configured path: honors a relocated
|
|
# debug/file_logging/log_path while keeping the response's path metadata
|
|
# byte-identical in the default case (FileAccess accepts a user:// path).
|
|
var log_path := LogHelpers.configured_log_path()
|
|
if not FileAccess.file_exists(log_path):
|
|
var file_logging_enabled: bool = LogHelpers.is_file_logging_enabled()
|
|
if not file_logging_enabled:
|
|
var _hint := "file logging is disabled — enable it in ProjectSettings → Debug → File Logging → Enable File Logging, then restart"
|
|
if LogBuffer.uses_logger_api():
|
|
_hint += "; alternatively use source=\"buffer\" (default) which captures all output in real-time"
|
|
else:
|
|
_hint += ". On Godot 4.2-4.4 source=\"buffer\" also depends on file logging, so both sources require this setting"
|
|
_send_result(peer, id, MCPToolkitError.fail("LOG_UNAVAILABLE", _hint,
|
|
MCPToolkitError.log_unavailable_hint(LogBuffer.uses_logger_api())))
|
|
else:
|
|
_send_result(peer, id, Pagination.build(
|
|
{"lines": []}, "lines", 0, 0, false, "", 0, "",
|
|
{
|
|
"path": log_path,
|
|
"source": "file",
|
|
"note": "log file not yet written — new game with no prints, or file flush pending",
|
|
}))
|
|
return
|
|
|
|
var file := FileAccess.open(log_path, FileAccess.READ)
|
|
if file == null:
|
|
var open_err := FileAccess.get_open_error()
|
|
if FileAccess.file_exists(log_path):
|
|
_send_result(peer, id, MCPToolkitError.fail("LOG_BUSY",
|
|
"log file exists but could not be read (err %d)" % open_err,
|
|
MCPToolkitError.log_busy_hint(LogBuffer.uses_logger_api())))
|
|
else:
|
|
var _gone_hint := "log file disappeared at %s — possible log rotation; retry" % log_path
|
|
if LogBuffer.uses_logger_api():
|
|
_gone_hint += " or use source=\"buffer\""
|
|
_send_result(peer, id, MCPToolkitError.fail("LOG_UNAVAILABLE", _gone_hint,
|
|
MCPToolkitError.log_unavailable_hint(LogBuffer.uses_logger_api())))
|
|
return
|
|
var text := file.get_as_text()
|
|
file.close()
|
|
|
|
# Filter-then-slice — uniform with the buffer source (LogBuffer.get_entries):
|
|
# strip ANSI and apply text_filter across ALL lines FIRST, then take the last
|
|
# `limit` matches. total_lines counts the matching lines (pre-cap); truncated
|
|
# means the matches exceeded limit so older matches were dropped. Capped tail, cursor-less.
|
|
var all_lines := text.split("\n", false)
|
|
var filtered: Array = []
|
|
for raw_line in all_lines:
|
|
var line: String = LogHelpers.strip_ansi(str(raw_line))
|
|
if text_filter != "":
|
|
if text_regex != null:
|
|
if not text_regex.search(line):
|
|
continue
|
|
else:
|
|
if line.findn(text_filter) < 0:
|
|
continue
|
|
filtered.append(line)
|
|
|
|
var total := filtered.size()
|
|
var file_truncated: bool = filtered.size() > limit
|
|
if file_truncated:
|
|
filtered = filtered.slice(filtered.size() - limit)
|
|
var slice: Array = filtered
|
|
|
|
var json_slice := JSON.stringify(slice)
|
|
var scrubbed := Scrubber.scrub(json_slice, "debugger.get_log")
|
|
# Capped-tail pagination (oldest lines drop first, no cursor).
|
|
var file_hint := ""
|
|
if file_truncated:
|
|
file_hint = "more lines remain — raise limit or narrow with text_filter (capped tail: the oldest lines drop first, no cursor)."
|
|
var file_response := Pagination.build(
|
|
{"lines": Untrusted.wrap("game_log", "godot", scrubbed["text"])},
|
|
"lines", total, slice.size(), file_truncated, "", 0, file_hint,
|
|
{"path": log_path, "source": "file"})
|
|
if not regex_warning.is_empty():
|
|
file_response["warning"] = regex_warning
|
|
_send_result(peer, id, file_response)
|
|
|
|
|
|
## Detect likely double-escaped regex metacharacters (same as editor_commands.gd).
|
|
func _detect_double_escaped_regex(pattern: String) -> String:
|
|
for letter in ["d", "D", "w", "W", "s", "S", "b", "B"]:
|
|
if pattern.find("\\\\" + letter) >= 0:
|
|
return (
|
|
"Pattern contains '\\\\%s' (literal backslash + '%s'). "
|
|
+ "If you meant the regex metacharacter \\%s, your backslash "
|
|
+ "is likely double-escaped. In JSON, use \"\\\\%s\" (one escaped "
|
|
+ "backslash), not \"\\\\\\\\%s\" (two)."
|
|
) % [letter, letter, letter, letter, letter]
|
|
return ""
|
|
|
|
|
|
# ---- Signal commands (Mode B mirror of editor handlers) ---------------------
|
|
|
|
|
|
# The root-resolver injected into the shared SignalPairResolver: the LIVE SceneTree
|
|
# root (the editor injects EditorInterface.get_edited_scene_root() instead). Returns
|
|
# null when the tree isn't ready, which the resolver treats as "no node".
|
|
func _runtime_root() -> Node:
|
|
var tree := get_tree()
|
|
return tree.root if tree != null else null
|
|
|
|
|
|
# Runtime equivalent of the editor's _resolve_scene_node — resolves a path against
|
|
# the LIVE SceneTree root via the shared resolver. Bare "" / "." resolves to the
|
|
# tree root so callers that just want a top-level signal don't need the full path.
|
|
# Kept as a thin wrapper so the other runtime commands that resolve a node
|
|
# (signal.emit, animation_player.control, execute.code, …) share one seam.
|
|
func _resolve_runtime_node(path: String):
|
|
return SignalPairResolver.resolve_node(path, _runtime_root())
|
|
|
|
|
|
func _cmd_signal_list(peer: WebSocketPeer, id, params) -> void:
|
|
if typeof(params) != TYPE_DICTIONARY:
|
|
_send_result(peer, id, MCPToolkitError.fail("INVALID_PARAMS", "params must be an object"))
|
|
return
|
|
var path := str(params.get("node_path", ""))
|
|
var node = _resolve_runtime_node(path)
|
|
if node == null:
|
|
_send_result(peer, id, MCPToolkitError.fail("NOT_FOUND", "node not found: %s" % path))
|
|
return
|
|
_send_result(peer, id, {"path": path, "signals": SignalPairResolver.list_signals_of(node)})
|
|
|
|
|
|
# Validates a connect/disconnect quartet against the live tree via the shared
|
|
# resolver. Returns the same shape the editor's _resolve_signal_pair does (the
|
|
# editor wraps the same resolver and layers its hint enrichment); the runtime path
|
|
# is deliberately leaner — no instanced-scene / compilation-error hints.
|
|
func _resolve_runtime_signal_pair(params) -> Dictionary:
|
|
return SignalPairResolver.resolve_pair(params, _runtime_root())
|
|
|
|
|
|
func _cmd_signal_connect(peer: WebSocketPeer, id, params) -> void:
|
|
var r := _resolve_runtime_signal_pair(params)
|
|
if r.has("error"):
|
|
_send_result(peer, id, MCPToolkitError.fail(str(r["code"]), str(r["error"])))
|
|
return
|
|
var source = r["source"]
|
|
var callable: Callable = r["callable"]
|
|
var signal_name: String = str(r["signal_name"])
|
|
var source_path: String = str(r["source_path"])
|
|
var target_path: String = str(r["target_path"])
|
|
var method_name: String = str(r["method_name"])
|
|
# Idempotency — mirror of the editor copy in SignalCommands.
|
|
if source.is_connected(signal_name, callable):
|
|
_send_result(peer, id, {
|
|
"success": true,
|
|
"status": "returned",
|
|
"source_path": source_path,
|
|
"signal": signal_name,
|
|
"target_path": target_path,
|
|
"method": method_name,
|
|
})
|
|
return
|
|
# No UndoRedo in runtime — connections are ephemeral for the game session
|
|
# and die when the player exits. Direct connect; surface failure code.
|
|
# Explicit int annotation because `source` is Variant (Dict value) — type
|
|
# inference can't reach through to Object.connect's Error return.
|
|
var err: int = source.connect(signal_name, callable)
|
|
if err != OK:
|
|
_send_result(peer, id, MCPToolkitError.fail("CONNECT_FAILED", "connect returned %d" % err))
|
|
return
|
|
_send_result(peer, id, {
|
|
"success": true,
|
|
"status": "created",
|
|
"source_path": source_path,
|
|
"signal": signal_name,
|
|
"target_path": target_path,
|
|
"method": method_name,
|
|
})
|
|
|
|
|
|
func _cmd_signal_disconnect(peer: WebSocketPeer, id, params) -> void:
|
|
var r := _resolve_runtime_signal_pair(params)
|
|
if r.has("error"):
|
|
_send_result(peer, id, MCPToolkitError.fail(str(r["code"]), str(r["error"])))
|
|
return
|
|
var source = r["source"]
|
|
var callable: Callable = r["callable"]
|
|
var signal_name: String = str(r["signal_name"])
|
|
if not source.is_connected(signal_name, callable):
|
|
_send_result(peer, id, MCPToolkitError.fail("NOT_FOUND", "no connection to disconnect"))
|
|
return
|
|
source.disconnect(signal_name, callable)
|
|
_send_result(peer, id, {"success": true})
|
|
|
|
|
|
|
|
func _cmd_signal_emit(peer: WebSocketPeer, id, params) -> void:
|
|
if typeof(params) != TYPE_DICTIONARY:
|
|
_send_result(peer, id, MCPToolkitError.fail("INVALID_PARAMS", "params must be an object"))
|
|
return
|
|
var path := str(params.get("node_path", ""))
|
|
var signal_name := str(params.get("signal_name", ""))
|
|
if signal_name.is_empty():
|
|
_send_result(peer, id, MCPToolkitError.fail("INVALID_PARAMS", "missing signal_name"))
|
|
return
|
|
var node = _resolve_runtime_node(path)
|
|
if node == null:
|
|
_send_result(peer, id, MCPToolkitError.fail("NOT_FOUND", "node not found: %s" % path))
|
|
return
|
|
if not node.has_signal(signal_name):
|
|
_send_result(peer, id, MCPToolkitError.fail("INVALID_PARAMS", "signal %s not on %s" % [signal_name, path]))
|
|
return
|
|
var raw_args = params.get("args", [])
|
|
if typeof(raw_args) != TYPE_ARRAY:
|
|
raw_args = []
|
|
var coerced: Array = [signal_name]
|
|
for a in raw_args:
|
|
var coerced_arg = Coerce.coerce_value(a)
|
|
if typeof(coerced_arg) == TYPE_DICTIONARY and (coerced_arg as Dictionary).has("_coerce_error"):
|
|
_send_result(peer, id, MCPToolkitError.fail("INVALID_PARAMS", str(coerced_arg["_coerce_error"])))
|
|
return
|
|
coerced.append(coerced_arg)
|
|
node.callv("emit_signal", coerced)
|
|
_send_result(peer, id, {"success": true})
|
|
|
|
|
|
# ---- Playtest commands ------------------------------------------------------
|
|
|
|
|
|
# input.simulate: process an array of {event_type, event_data, delay_ms?}
|
|
# sequentially with optional delays. key/action events feed the Input singleton
|
|
# via Input.parse_input_event; mouse and send_text events route through
|
|
# Viewport.push_input (driving gui_input/focus).
|
|
func _cmd_input_simulate(peer: WebSocketPeer, id, params) -> void:
|
|
if typeof(params) != TYPE_DICTIONARY:
|
|
_send_result(peer, id, MCPToolkitError.fail("INVALID_PARAMS", "params must be an object"))
|
|
return
|
|
|
|
var events = params.get("events", null)
|
|
if typeof(events) != TYPE_ARRAY or events.is_empty():
|
|
_send_result(peer, id, MCPToolkitError.fail("INVALID_PARAMS",
|
|
"events array is required and must not be empty"))
|
|
return
|
|
|
|
var summary_mode: bool = bool(params.get("summary", true))
|
|
var total: int = events.size()
|
|
var results: Array = []
|
|
var processed := 0
|
|
|
|
for i in total:
|
|
var event = events[i]
|
|
if typeof(event) != TYPE_DICTIONARY:
|
|
var err := MCPToolkitError.fail("INVALID_PARAMS", "event at index %d must be an object" % i)
|
|
err["events_processed"] = processed
|
|
if not summary_mode:
|
|
err["results"] = results
|
|
_send_result(peer, id, err)
|
|
return
|
|
var et := str(event.get("event_type", ""))
|
|
var ed: Dictionary = {}
|
|
var raw = event.get("event_data", null)
|
|
if typeof(raw) == TYPE_DICTIONARY:
|
|
ed = raw
|
|
var delay_before_ms := int(event.get("delay_before_ms", 0))
|
|
var delay_after_ms := int(event.get("delay_after_ms", 0))
|
|
if delay_before_ms > 0:
|
|
await get_tree().create_timer(delay_before_ms / 1000.0).timeout
|
|
var event_result := {"index": i, "total": total, "type": et, "dispatched": true}
|
|
# Diagnostic: gui_has_focus = a GUI Control has focus (not OS window focus).
|
|
var vp := get_viewport()
|
|
if vp != null:
|
|
event_result["gui_has_focus"] = vp.gui_get_focus_owner() != null
|
|
event_result["tree_paused"] = get_tree().paused if get_tree() != null else false
|
|
if et == "click":
|
|
var click_delay := int(ed.get("click_delay_ms", 50))
|
|
await _dispatch_click(ed)
|
|
event_result["click_delay_ms"] = click_delay
|
|
elif et == "click_node":
|
|
var click_result := _dispatch_click_node(ed)
|
|
event_result.merge(click_result, true)
|
|
elif et == "action":
|
|
# Hybrid dispatch: action_press/release for sustained
|
|
# Input.is_action_pressed() state (movement), PLUS
|
|
# parse_input_event(InputEventAction) to set the frame-
|
|
# transition tracking that is_action_just_pressed() needs (jump).
|
|
var action_name := StringName(str(ed.get("action", "")))
|
|
# Reject an action not registered in the InputMap: dispatching it matches
|
|
# nothing (a silent no-op that would otherwise report success:true, dispatched:true).
|
|
# Aborts the batch like a malformed event, naming the unknown action. Only
|
|
# the action path uses the InputMap — key/text/send_text modes don't.
|
|
if not InputMap.has_action(action_name):
|
|
var action_err := MCPToolkitError.fail("INVALID_PARAMS",
|
|
"input action '%s' is not registered in the InputMap" % str(action_name),
|
|
"Register it in Project Settings → Input Map (or check the name — "
|
|
+ "action strings are case-sensitive). List actions with InputMap.get_actions().")
|
|
action_err["events_processed"] = processed
|
|
if not summary_mode:
|
|
action_err["results"] = results
|
|
_send_result(peer, id, action_err)
|
|
return
|
|
var is_pressed := bool(ed.get("pressed", true))
|
|
var strength := float(ed.get("strength", 1.0))
|
|
if is_pressed:
|
|
Input.action_press(action_name, strength)
|
|
else:
|
|
Input.action_release(action_name)
|
|
var act := InputEventAction.new()
|
|
act.action = action_name
|
|
act.pressed = is_pressed
|
|
act.strength = strength
|
|
Input.parse_input_event(act)
|
|
event_result["action"] = str(action_name)
|
|
event_result["pressed"] = is_pressed
|
|
elif et == "send_text":
|
|
# Type a string into a text surface via the focus owner so the real
|
|
# gui_input → text_changed/text_submitted fire (set_property(.text)
|
|
# skips them). event_data values are Variant — coerce each explicitly.
|
|
var text_val: String = str(ed.get("text", ""))
|
|
var node_path_str: String = str(ed.get("node_path", ""))
|
|
var do_submit: bool = bool(ed.get("submit", false))
|
|
|
|
# Focus precedence: an explicit Control node_path wins; otherwise type
|
|
# into whatever currently holds GUI focus. A custom _input reader gets
|
|
# the events regardless of focus, so "none" is a hint, not a failure.
|
|
var target: Node = null
|
|
var focus_source := "none"
|
|
var node_path_problem := ""
|
|
if not node_path_str.is_empty():
|
|
# Resolve against /root (NOT click_node's current_scene resolver) so
|
|
# absolute /root/... paths and autoloads resolve as documented.
|
|
var resolved = _resolve_runtime_node(node_path_str)
|
|
if resolved == null:
|
|
node_path_problem = "not found"
|
|
elif resolved is Control:
|
|
target = resolved
|
|
(resolved as Control).grab_focus()
|
|
# One frame for the focus change to settle before typing.
|
|
await get_tree().process_frame
|
|
focus_source = "node_path"
|
|
else:
|
|
node_path_problem = "is not a Control (%s)" % resolved.get_class()
|
|
if target == null:
|
|
target = vp.gui_get_focus_owner() if vp != null else null
|
|
focus_source = "existing" if target != null else "none"
|
|
|
|
# Capture before/after only when the target exposes a readable String
|
|
# `text`; a custom reader has none → text_changed stays null.
|
|
var has_text := false
|
|
var text_before := ""
|
|
if target != null:
|
|
var tb: Variant = target.get("text")
|
|
if typeof(tb) == TYPE_STRING:
|
|
has_text = true
|
|
text_before = tb
|
|
# Redact the echo when the target's `secret` is true (LineEdit.secret);
|
|
# the change comparison below still reads the real value — only the echo masks.
|
|
var is_secret := false
|
|
if target != null:
|
|
var sec: Variant = target.get("secret")
|
|
is_secret = typeof(sec) == TYPE_BOOL and bool(sec)
|
|
|
|
# Deliver via push_input (routes a key event to gui.key_focus); mirror
|
|
# the mouse path's parse_input_event fallback when there is no viewport.
|
|
for key_ev in TextInputSynth.synthesize_text_events(text_val):
|
|
if vp != null:
|
|
vp.push_input(key_ev)
|
|
else:
|
|
Input.parse_input_event(key_ev)
|
|
if do_submit:
|
|
for enter_ev in TextInputSynth.synthesize_enter():
|
|
if vp != null:
|
|
vp.push_input(enter_ev)
|
|
else:
|
|
Input.parse_input_event(enter_ev)
|
|
var chars_sent := text_val.length()
|
|
|
|
var text_changed: Variant = null
|
|
if has_text:
|
|
var ta: Variant = target.get("text")
|
|
var raw_after: String = ta if typeof(ta) == TYPE_STRING else ""
|
|
text_changed = raw_after != text_before
|
|
event_result["text_after"] = TextInputSynth.format_text_after(raw_after, is_secret)
|
|
event_result["chars_sent"] = chars_sent
|
|
event_result["focus_source"] = focus_source
|
|
if target != null:
|
|
event_result["focus_target"] = {"path": str(target.get_path()), "class": target.get_class()}
|
|
else:
|
|
event_result["focus_target"] = null
|
|
event_result["text_changed"] = text_changed
|
|
|
|
if node_path_problem != "":
|
|
event_result["hint"] = "node_path '%s' %s; pass a valid Control path or omit node_path to type into the focused field." % [node_path_str, node_path_problem]
|
|
else:
|
|
var focus_path := str(target.get_path()) if target != null else ""
|
|
var focus_class := target.get_class() if target != null else ""
|
|
var paused := bool(event_result.get("tree_paused", false))
|
|
var send_text_hint := TextInputSynth.build_hint(
|
|
focus_source, text_changed, focus_path, focus_class, chars_sent, paused)
|
|
if send_text_hint != "":
|
|
event_result["hint"] = send_text_hint
|
|
else:
|
|
var ev := _build_input_event(et, ed)
|
|
if ev == null:
|
|
event_result["dispatched"] = false
|
|
event_result["error"] = "unknown event_type (expected key|mouse_button|mouse_motion|action|click|click_node|send_text)"
|
|
results.append(event_result)
|
|
var err := MCPToolkitError.fail("INVALID_PARAMS",
|
|
"unknown event_type at index %d: %s" % [i, et])
|
|
err["events_processed"] = processed
|
|
if summary_mode:
|
|
err["failed_at"] = event_result
|
|
else:
|
|
err["results"] = results
|
|
_send_result(peer, id, err)
|
|
return
|
|
# Mouse events use push_input for proper GUI/CanvasLayer routing.
|
|
# No OS-level focus — position + global_position fields are
|
|
# sufficient for viewport hit-testing and parallel-safe.
|
|
if ev is InputEventMouse:
|
|
if get_viewport() != null:
|
|
get_viewport().push_input(ev)
|
|
else:
|
|
Input.parse_input_event(ev)
|
|
else:
|
|
Input.parse_input_event(ev)
|
|
results.append(event_result)
|
|
processed += 1
|
|
if delay_after_ms > 0:
|
|
await get_tree().create_timer(delay_after_ms / 1000.0).timeout
|
|
|
|
if summary_mode:
|
|
_send_result(peer, id, {"success": true, "events_processed": processed,
|
|
"total": total, "last_event": results.back()})
|
|
else:
|
|
_send_result(peer, id, {"success": true, "events_processed": processed,
|
|
"total": total, "results": results})
|
|
|
|
|
|
## Parses mouse position from event_data. Accepts both flat {x, y} and
|
|
## nested {position: {x, y}} formats for LLM robustness.
|
|
func _parse_mouse_position(event_data: Dictionary) -> Vector2:
|
|
var pos = event_data.get("position", null)
|
|
if pos == null and event_data.has("x"):
|
|
pos = {"x": event_data.get("x", 0.0), "y": event_data.get("y", 0.0)}
|
|
if typeof(pos) == TYPE_DICTIONARY:
|
|
return Vector2(float(pos.get("x", 0.0)), float(pos.get("y", 0.0)))
|
|
return Vector2.ZERO
|
|
|
|
|
|
## Raise + focus the game's OS window. Only for an explicit opt-in
|
|
## (force_foreground_game): input synthesis does NOT need it — push_input() with
|
|
## correct position + global_position is enough for viewport hit-testing without
|
|
## OS focus. Hazard: during parallel multi-instance runs this makes sessions
|
|
## fight over the OS mouse and window focus, so it must stay opt-in.
|
|
func _ensure_game_focus() -> void:
|
|
DisplayServer.window_move_to_foreground()
|
|
var win := get_window()
|
|
if win != null:
|
|
win.grab_focus()
|
|
|
|
|
|
func _build_input_event(event_type: String, event_data: Dictionary) -> InputEvent:
|
|
match event_type:
|
|
"key":
|
|
var key_ev := InputEventKey.new()
|
|
key_ev.keycode = int(event_data.get("keycode", 0))
|
|
key_ev.pressed = bool(event_data.get("pressed", true))
|
|
if event_data.has("physical_keycode"):
|
|
key_ev.physical_keycode = int(event_data.get("physical_keycode", 0))
|
|
if event_data.has("unicode"):
|
|
key_ev.unicode = int(event_data.get("unicode", 0))
|
|
key_ev.shift_pressed = bool(event_data.get("shift", false))
|
|
key_ev.ctrl_pressed = bool(event_data.get("ctrl", false))
|
|
key_ev.alt_pressed = bool(event_data.get("alt", false))
|
|
key_ev.meta_pressed = bool(event_data.get("meta", false))
|
|
return key_ev
|
|
"mouse_button":
|
|
var mb := InputEventMouseButton.new()
|
|
mb.button_index = int(event_data.get("button_index", MOUSE_BUTTON_LEFT))
|
|
mb.pressed = bool(event_data.get("pressed", true))
|
|
var mb_vec := _parse_mouse_position(event_data)
|
|
mb.position = mb_vec
|
|
mb.global_position = mb_vec
|
|
if event_data.has("world_position"):
|
|
var wp := _parse_mouse_position(
|
|
{"position": event_data["world_position"]})
|
|
var vp_pos: Vector2 = get_viewport().get_canvas_transform() * wp
|
|
mb.position = vp_pos
|
|
mb.global_position = vp_pos
|
|
mb.shift_pressed = bool(event_data.get("shift", false))
|
|
mb.ctrl_pressed = bool(event_data.get("ctrl", false))
|
|
mb.alt_pressed = bool(event_data.get("alt", false))
|
|
mb.meta_pressed = bool(event_data.get("meta", false))
|
|
return mb
|
|
"mouse_motion":
|
|
var mm := InputEventMouseMotion.new()
|
|
var mm_vec := _parse_mouse_position(event_data)
|
|
mm.position = mm_vec
|
|
mm.global_position = mm_vec
|
|
if event_data.has("world_position"):
|
|
var wp := _parse_mouse_position(
|
|
{"position": event_data["world_position"]})
|
|
var vp_pos: Vector2 = get_viewport().get_canvas_transform() * wp
|
|
mm.position = vp_pos
|
|
mm.global_position = vp_pos
|
|
var rel = event_data.get("relative", null)
|
|
if typeof(rel) == TYPE_DICTIONARY:
|
|
mm.relative = Vector2(float(rel.get("x", 0.0)), float(rel.get("y", 0.0)))
|
|
return mm
|
|
"action":
|
|
var act := InputEventAction.new()
|
|
act.action = StringName(str(event_data.get("action", "")))
|
|
act.pressed = bool(event_data.get("pressed", true))
|
|
if event_data.has("strength"):
|
|
act.strength = float(event_data.get("strength", 1.0))
|
|
return act
|
|
_:
|
|
return null
|
|
|
|
|
|
## click: press + delay + release at a position (50 ms default internal delay).
|
|
## Uses push_input with position + global_position for GUI hit-testing.
|
|
## No OS-level warp_mouse or window focus — safe for parallel multi-instance runs.
|
|
func _dispatch_click(event_data: Dictionary) -> void:
|
|
var mb_press := InputEventMouseButton.new()
|
|
mb_press.button_index = int(event_data.get("button_index", MOUSE_BUTTON_LEFT))
|
|
mb_press.pressed = true
|
|
var vec := _parse_mouse_position(event_data)
|
|
if event_data.has("world_position"):
|
|
var wp := _parse_mouse_position(
|
|
{"position": event_data["world_position"]})
|
|
vec = get_viewport().get_canvas_transform() * wp
|
|
mb_press.position = vec
|
|
mb_press.global_position = vec
|
|
mb_press.shift_pressed = bool(event_data.get("shift", false))
|
|
mb_press.ctrl_pressed = bool(event_data.get("ctrl", false))
|
|
mb_press.alt_pressed = bool(event_data.get("alt", false))
|
|
mb_press.meta_pressed = bool(event_data.get("meta", false))
|
|
var vp := get_viewport()
|
|
if vp != null:
|
|
vp.push_input(mb_press)
|
|
else:
|
|
Input.parse_input_event(mb_press)
|
|
var click_delay := int(event_data.get("click_delay_ms", 50))
|
|
await get_tree().create_timer(click_delay / 1000.0).timeout
|
|
var mb_release := InputEventMouseButton.new()
|
|
mb_release.button_index = mb_press.button_index
|
|
mb_release.pressed = false
|
|
mb_release.position = vec
|
|
mb_release.global_position = vec
|
|
mb_release.shift_pressed = mb_press.shift_pressed
|
|
mb_release.ctrl_pressed = mb_press.ctrl_pressed
|
|
mb_release.alt_pressed = mb_press.alt_pressed
|
|
mb_release.meta_pressed = mb_press.meta_pressed
|
|
if vp != null:
|
|
vp.push_input(mb_release)
|
|
else:
|
|
Input.parse_input_event(mb_release)
|
|
|
|
|
|
## click_node: programmatic click on a node by path. Calls grab_focus() on
|
|
## Controls and emits pressed signal on BaseButtons. No coordinate guessing.
|
|
func _dispatch_click_node(event_data: Dictionary) -> Dictionary:
|
|
var node_path_str := str(event_data.get("node_path", ""))
|
|
if node_path_str.is_empty():
|
|
return {"dispatched": false, "error": "node_path is required for click_node"}
|
|
var root := get_tree().current_scene if get_tree() != null else null
|
|
if root == null:
|
|
return {"dispatched": false, "error": "no current scene"}
|
|
var target := root.get_node_or_null(NodePath(node_path_str))
|
|
if target == null:
|
|
return {"dispatched": false, "error": "node not found: %s" % node_path_str}
|
|
var result := {"dispatched": true, "node_path": node_path_str, "node_class": target.get_class()}
|
|
if target is Control:
|
|
target.grab_focus()
|
|
result["focused"] = true
|
|
if target is BaseButton:
|
|
if target.toggle_mode:
|
|
target.button_pressed = not target.button_pressed
|
|
result["toggled_to"] = target.button_pressed
|
|
target.emit_signal("pressed")
|
|
result["pressed_emitted"] = true
|
|
else:
|
|
result["pressed_emitted"] = false
|
|
result["note"] = "node is not a BaseButton; grab_focus applied if Control"
|
|
return result
|
|
|
|
|
|
# animation_player.control: drive an AnimationPlayer in the live SceneTree.
|
|
# Returns post-op state so the caller can confirm the seek/play landed
|
|
# without an extra round-trip.
|
|
func _cmd_animation_player_control(peer: WebSocketPeer, id, params) -> void:
|
|
if typeof(params) != TYPE_DICTIONARY:
|
|
_send_result(peer, id, MCPToolkitError.fail("INVALID_PARAMS", "params must be an object"))
|
|
return
|
|
var path := str(params.get("node_path", ""))
|
|
if path.is_empty():
|
|
_send_result(peer, id, MCPToolkitError.fail("INVALID_PARAMS", "missing node_path"))
|
|
return
|
|
var node = _resolve_runtime_node(path)
|
|
if node == null:
|
|
_send_result(peer, id, MCPToolkitError.fail("NOT_FOUND", "node not found: %s" % path))
|
|
return
|
|
if not (node is AnimationPlayer):
|
|
_send_result(peer, id, MCPToolkitError.fail("INVALID_PARAMS", "node is not AnimationPlayer: %s (got %s)" % [path, node.get_class()]))
|
|
return
|
|
var ap: AnimationPlayer = node
|
|
var op := str(params.get("operation", ""))
|
|
match op:
|
|
"play":
|
|
var anim := str(params.get("animation_name", ""))
|
|
if anim.is_empty():
|
|
ap.play()
|
|
else:
|
|
if not ap.has_animation(anim):
|
|
_send_result(peer, id, MCPToolkitError.fail("NOT_FOUND", "animation not found: %s" % anim))
|
|
return
|
|
ap.play(anim)
|
|
"pause":
|
|
ap.pause()
|
|
"stop":
|
|
ap.stop()
|
|
"seek":
|
|
ap.seek(float(params.get("time", 0.0)), true)
|
|
_:
|
|
_send_result(peer, id, MCPToolkitError.fail("INVALID_PARAMS", "unknown op: %s (expected play|pause|stop|seek)" % op))
|
|
return
|
|
_send_result(peer, id, {
|
|
"success": true,
|
|
"current_animation": String(ap.current_animation),
|
|
"current_animation_position": ap.current_animation_position,
|
|
})
|
|
|
|
|
|
# execute.code: evaluates GDScript via Expression in the running game's context.
|
|
# Risk communicated via MCP annotations (destructiveHint: true) and
|
|
# security-recommendations.md; agent-side tool filtering is the enforcement layer.
|
|
func _cmd_execute_code(peer: WebSocketPeer, id, params) -> void:
|
|
if typeof(params) != TYPE_DICTIONARY:
|
|
_send_result(peer, id, MCPToolkitError.fail("INVALID_PARAMS", "params must be an object"))
|
|
return
|
|
var code := str(params.get("code", ""))
|
|
if code.is_empty():
|
|
_send_result(peer, id, MCPToolkitError.fail("INVALID_PARAMS", "missing code"))
|
|
return
|
|
|
|
var truncated := code.substr(0, _EXECUTE_CODE_LOG_CAP)
|
|
if code.length() > _EXECUTE_CODE_LOG_CAP:
|
|
truncated += "...[+%d chars]" % (code.length() - _EXECUTE_CODE_LOG_CAP)
|
|
print("[MCPTools] execute.code: %s" % truncated)
|
|
|
|
var scope_node: Node = null
|
|
var scope_path := str(params.get("scope_path", ""))
|
|
if scope_path.is_empty():
|
|
var tree := get_tree()
|
|
if tree == null or tree.root == null:
|
|
_send_result(peer, id, MCPToolkitError.fail("INTERNAL", "scene tree unavailable"))
|
|
return
|
|
scope_node = tree.root
|
|
else:
|
|
scope_node = _resolve_runtime_node(scope_path)
|
|
if scope_node == null:
|
|
_send_result(peer, id, MCPToolkitError.fail("NOT_FOUND", "scope node not found: %s" % scope_path))
|
|
return
|
|
|
|
# Expression only supports expressions, not statements. Catch common
|
|
# statement keywords early with a clear error instead of the cryptic
|
|
# "Invalid named index 'var'" from Expression.parse().
|
|
var _trimmed := code.strip_edges()
|
|
for _kw in ["var", "return", "func", "if", "for", "while", "class", "const", "match"]:
|
|
if _trimmed == _kw or _trimmed.begins_with(_kw + " ") or _trimmed.begins_with(_kw + "\t") or _trimmed.begins_with(_kw + "\n"):
|
|
_send_result(peer, id, MCPToolkitError.fail("PARSE_ERROR",
|
|
"execute_code only supports expressions, not statements. '%s' is a statement keyword. " % _kw +
|
|
"Use method calls (node.method()), property access (node.property), or arithmetic instead."))
|
|
return
|
|
|
|
var expr := Expression.new()
|
|
var parse_err := expr.parse(code, PackedStringArray())
|
|
if parse_err != OK:
|
|
_send_result(peer, id, MCPToolkitError.fail("PARSE_ERROR", expr.get_error_text()))
|
|
return
|
|
var result = expr.execute([], scope_node, false)
|
|
if expr.has_execute_failed():
|
|
var err_text := expr.get_error_text()
|
|
# Enrich via the shared helper so editor and runtime give identical guidance.
|
|
err_text += ExecuteHints.build_hint(err_text, code)
|
|
_send_result(peer, id, MCPToolkitError.fail("EXECUTE_FAILED", err_text))
|
|
return
|
|
_send_result(peer, id, {"result": Coerce.serialize_value(result)})
|