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

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)})