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

1209 lines
52 KiB
GDScript

@tool
extends RefCounted
## node.* command handlers — property get/set/list, method calls, script attachment.
const Modules := preload("res://addons/godot_mcp_toolkit/core/modules.gd")
const Coerce = Modules.Coerce
const FileGuard = Modules.FileGuard
const Helpers = Modules.CommandHelpers
## node.set_property rejects "groups" because its set is a declarative full
## replace (it would drop any group not in the list), whereas node.groups is
## incremental. The two semantics are opposites, so groups go through one tool.
const _GROUPS_REJECTION_MESSAGE := "'groups' cannot be set via node.set_property: it would replace the whole group list and silently drop any group not included."
const _GROUPS_REJECTION_HINT := "Use node.groups (action 'add'/'remove') to change group membership incrementally, or 'list' to read it."
const COMMON_PROPERTIES_BY_CLASS := {
"Node": ["name", "process_mode"],
"Node2D": ["position", "rotation", "scale", "z_index", "visible", "modulate"],
"Node3D": ["position", "rotation", "scale", "visible"],
"Control": ["position", "size", "anchor_left", "anchor_right", "anchor_top",
"anchor_bottom", "visible", "modulate", "size_flags_horizontal", "size_flags_vertical"],
"Sprite2D": ["texture", "centered", "offset", "flip_h", "flip_v", "hframes", "vframes", "frame"],
"Sprite3D": ["texture", "centered", "offset", "flip_h", "flip_v"],
"CollisionShape2D": ["shape", "disabled"],
"CollisionShape3D": ["shape", "disabled"],
"RigidBody2D": ["mass", "gravity_scale", "linear_velocity", "angular_velocity"],
"RigidBody3D": ["mass", "gravity_scale", "linear_velocity", "angular_velocity"],
"CharacterBody2D": ["velocity", "floor_max_angle", "up_direction"],
"CharacterBody3D": ["velocity", "floor_max_angle", "up_direction"],
"Camera2D": ["zoom", "offset", "position_smoothing_enabled"],
"Camera3D": ["fov", "near", "far", "current"],
"Area2D": ["monitoring", "monitorable", "gravity"],
"Area3D": ["monitoring", "monitorable", "gravity"],
"AnimationPlayer": ["current_animation", "autoplay", "speed_scale"],
"Timer": ["wait_time", "one_shot", "autostart"],
"Label": ["text", "horizontal_alignment", "vertical_alignment", "autowrap_mode"],
"Button": ["text", "disabled", "flat"],
"TextureRect": ["texture", "stretch_mode"],
"AudioStreamPlayer": ["stream", "volume_db", "pitch_scale", "autoplay"],
"AudioStreamPlayer2D": ["stream", "volume_db", "pitch_scale", "max_distance"],
"AudioStreamPlayer3D": ["stream", "volume_db", "pitch_scale", "max_distance"],
"MeshInstance3D": ["mesh", "material_override"],
"Light2D": ["energy", "color", "shadow_enabled"],
"DirectionalLight3D": ["light_energy", "light_color", "shadow_enabled"],
"GPUParticles2D": ["process_material", "emitting", "amount", "lifetime"],
"GPUParticles3D": ["process_material", "emitting", "amount", "lifetime"],
"LineEdit": ["text", "placeholder_text", "editable", "max_length"],
"TextEdit": ["text", "editable"],
"RichTextLabel": ["text", "bbcode_enabled"],
}
static func register(registry: MCPToolkitCommandRegistry, server: Node) -> void:
registry.add("node.get_property", func(parameters: Dictionary) -> Dictionary:
return _cmd_node_get_property(parameters)
, MCPToolkitCommandOptions.new().mark_read_only())
registry.add("node.set_property", func(parameters: Dictionary) -> Dictionary:
return _cmd_node_set_property(server, parameters)
, MCPToolkitCommandOptions.new())
registry.add("node.get_property_list", func(parameters: Dictionary) -> Dictionary:
return _cmd_node_get_property_list(parameters)
, MCPToolkitCommandOptions.new().mark_read_only())
registry.add("node.call_method", func(parameters: Dictionary) -> Dictionary:
return _cmd_node_call_method(parameters)
, MCPToolkitCommandOptions.new())
registry.add("node.set_script", func(parameters: Dictionary) -> Dictionary:
return _cmd_node_set_script(parameters)
, MCPToolkitCommandOptions.new())
registry.add("node.manage", func(parameters: Dictionary) -> Dictionary:
return _cmd_node_manage(parameters)
, MCPToolkitCommandOptions.new())
registry.add("node.groups", func(parameters: Dictionary) -> Dictionary:
return _cmd_node_groups(parameters)
, MCPToolkitCommandOptions.new())
registry.add("node.collision_from_sprite", func(parameters: Dictionary) -> Dictionary:
return _cmd_collision_from_sprite(parameters)
, MCPToolkitCommandOptions.new())
registry.add("control.set_layout", func(parameters: Dictionary) -> Dictionary:
return _cmd_control_set_layout(parameters)
, MCPToolkitCommandOptions.new())
# -- Helpers ------------------------------------------------------------------
static func _get_edited_root() -> Node:
return Helpers.get_edited_root()
static func _resolve_scene_node(node_path: String) -> Variant:
return Helpers.resolve_scene_node(node_path)
## True for the one property name node.set_property refuses in favour of
## node.groups. Pure (no engine state) so single- and batch-mode share one
## decision and a unit test can pin it without an editor.
static func _is_groups_property(property_name: String) -> bool:
return property_name == "groups"
## Detect silent compound-path set failure by comparing readback to expected.
static func _iscompound_set_failure(expected: Variant, actual: Variant) -> bool:
if expected == null:
return false # Setting null — nothing to verify
if actual == null:
return true # Expected non-null, got null
if typeof(actual) == TYPE_DICTIONARY and (actual as Dictionary).is_empty():
if expected is Resource:
return true # Expected resource, got empty dict
if typeof(expected) == TYPE_DICTIONARY and not (expected as Dictionary).is_empty():
return true # Expected populated dict, got empty dict
return false
## Short string representation of a value for warning messages.
static func _brief_value(value: Variant) -> String:
if value == null:
return "null"
if typeof(value) == TYPE_DICTIONARY and (value as Dictionary).is_empty():
return "{} (empty)"
var s := var_to_str(value)
if s.length() > 60:
s = s.left(57) + "..."
return s
# -- Commands -----------------------------------------------------------------
static func _cmd_node_get_property(parameters: Dictionary) -> Dictionary:
var root := _get_edited_root()
if root == null:
return MCPToolkitError.fail("NO_SCENE", "no edited scene")
var node_path := str(parameters.get("node_path", ""))
node_path = Helpers.normalize_editor_path(node_path)
var property_name := str(parameters.get("property", ""))
if node_path.is_empty() or property_name.is_empty():
return MCPToolkitError.fail("INVALID_PARAMS", "missing node_path or property")
var node := root.get_node_or_null(node_path)
if node == null:
return MCPToolkitError.fail("NOT_FOUND", "node not found: %s" % node_path, MCPToolkitError.HINT_NODE_PATH)
# Compound paths (colon-chained sub-resource paths like
# "material:shader_parameter/value") use the centralized handler which
# tries node-level override first, then falls back to sub-resource read.
if ":" in property_name:
var result := Helpers.get_property_compound(node, property_name)
if not result.get("ok", false):
return MCPToolkitError.fail(
result.get("code", "NOT_FOUND"),
str(result.get("error", "")))
return MCPToolkitSuccess.ok({"value": result["value"]})
return MCPToolkitSuccess.ok({"value": Coerce.serialize_value(node.get(property_name))})
static func _cmd_node_set_property(server: Node, parameters: Dictionary) -> Dictionary:
var root := _get_edited_root()
if root == null:
return MCPToolkitError.fail("NO_SCENE", "no edited scene")
# Batch mode — set multiple properties in a single UndoRedo action.
var batch_raw = parameters.get("batch", null)
if batch_raw != null and typeof(batch_raw) == TYPE_ARRAY and (batch_raw as Array).size() > 0:
return _batch_set_properties(server, root, batch_raw as Array)
var node_path := str(parameters.get("node_path", ""))
node_path = Helpers.normalize_editor_path(node_path)
var property_name := str(parameters.get("property", ""))
var raw_value = parameters.get("value", null)
if node_path.is_empty() or property_name.is_empty():
return MCPToolkitError.fail("INVALID_PARAMS", "missing node_path or property")
# "groups" is not a regular property — it lives in the .tscn node header,
# and a full-set-replace here would silently strip any group not in the
# given list. Steer all group mutation to node.groups, whose verbs are
# incremental (and which both tools being eager makes always reachable).
# Checked before node resolution so the error is consistent regardless of
# whether node_path is valid (mirrors the batch-mode placement).
if _is_groups_property(property_name):
return MCPToolkitError.fail("INVALID_PARAMS",
_GROUPS_REJECTION_MESSAGE, _GROUPS_REJECTION_HINT)
var node := root.get_node_or_null(node_path)
if node == null:
return MCPToolkitError.fail("NOT_FOUND", "node not found: %s" % node_path, MCPToolkitError.HINT_NODE_PATH)
# Compound / colon-chained paths (e.g. "libraries/test",
# "material:shader_parameter/value", "theme_override_colors/font_color") take
# the sub-resource navigation + UndoRedo path; everything else is a scalar set.
if ":" in property_name or "/" in property_name:
return _set_property_compound_single(
server, node, node_path, property_name, raw_value, parameters)
return _set_property_scalar_single(node, node_path, property_name, raw_value)
## Set one compound / colon-chained property and register its UndoRedo. The
## centralized handler in editor_helpers.gd does the sub-resource navigation,
## shader_parameter/ dedicated setter, value coercion, and readback; it returns
## _undo info we register here via commit_recorded(). Leaf mechanics of
## node.set_property's compound branch, lifted out of the handler so the latter
## reads as a top-down orchestrator.
## Reads only `make_unique` from parameters. Returns the response dict directly.
static func _set_property_compound_single(
server: Node, node: Node, node_path: String, property_name: String,
raw_value: Variant, parameters: Dictionary,
) -> Dictionary:
var do_unique := bool(parameters.get("make_unique", false))
var result := Helpers.set_property_compound(
node, property_name, raw_value, do_unique)
if not result.get("ok", false):
return MCPToolkitError.fail(
result.get("code", "INVALID_VALUE"),
str(result.get("error", "")))
# Register UndoRedo for compound paths. All do/undo methods route
# through server.undo_helpers.compound_set for consistent
# EditorUndoRedoManager context (avoids history mismatch errors).
var ui: Dictionary = result.get("_undo", {})
var undo_type: String = str(ui.get("type", ""))
if undo_type != "":
var undo_path: String = ui["path"]
var new_val = ui.get("new", node.get(undo_path))
var action = MCPToolkitUndoRedoAction.begin("set %s.%s" % [node_path, property_name], node)
action.do_method(server.undo_helpers.compound_set.bind(
node, undo_path, new_val))
action.undo_method(server.undo_helpers.compound_set.bind(
node, undo_path, ui["old"]))
# make_unique: undo restores original external resource.
if ui.has("old_resource_prop"):
var res_prop: String = ui["old_resource_prop"]
var new_res = node.get(res_prop)
action.do_method(server.undo_helpers.compound_set.bind(
node, res_prop, new_res))
action.undo_method(server.undo_helpers.compound_set.bind(
node, res_prop, ui["old_resource"]))
if new_res is Resource:
action.do_reference(new_res)
if ui["old_resource"] is Resource:
action.undo_reference(ui["old_resource"])
action.commit_recorded()
var response := {}
if result.has("made_unique"):
response["made_unique"] = result["made_unique"]
if result.has("warning"):
response["warning"] = result["warning"]
return MCPToolkitSuccess.ok(response)
## Set one scalar (non-compound) property: coerce → set → register UndoRedo,
## then the bare-res:// readback guard (a bare "res://…" string assigned
## to a Resource-typed property silently fails to load — detect it and steer the
## caller to the tagged {type:"Resource", path:…} form). Leaf mechanics of
## node.set_property's scalar branch, lifted out of the handler for SRP.
## Needs no `server` — scalar undo uses do/undo_property.
static func _set_property_scalar_single(
node: Node, node_path: String, property_name: String, raw_value: Variant,
) -> Dictionary:
var coerce_result := Helpers.coerce_for_property(node, property_name, raw_value)
if not coerce_result.get("ok", false):
return MCPToolkitError.fail(
coerce_result.get("code", "INVALID_VALUE"),
str(coerce_result.get("error", "")))
var coerced = coerce_result["value"]
var old_value = node.get(property_name)
# Drop Godot 4.3's SceneTreeEditor tooltip-timer connections before writing
# editor_description so this set arms no timer against a soon-freed tree row
# (no-op for other properties / other versions). A later human undo/redo replays
# the set outside this guard — out of scope, cured in 4.4. See disarm_tooltip_uaf.
Helpers.disarm_tooltip_uaf(node, property_name)
node.set(property_name, coerced)
# Bare res:// string on a Resource-typed property: give the specific
# tagged-form hint (a bare path silently fails to load) BEFORE the generic
# drop guard below, which would otherwise fire first with a vaguer message.
# Signature: raw String + non-Resource coercion + non-String readback.
if typeof(raw_value) == TYPE_STRING and str(raw_value).begins_with("res://") \
and not (coerced is Resource) and not (node.get(property_name) is String):
return MCPToolkitError.fail("INVALID_VALUE",
"property '%s' expects a Resource, not a bare string path. " % property_name +
"Use {\"type\": \"Resource\", \"path\": \"%s\"} as the value." % str(raw_value))
# Classify the write. DROPPED (silent wrong-type) fails before undo — nothing
# landed, so there is no state to roll back. ADJUSTED (engine reshaped the value,
# e.g. truncation 7.9→7 or a normalize) DID commit, so it records undo like a
# clean write and returns success WITH a warning naming the stored-vs-requested
# delta. OK is a clean success.
var outcome := Helpers.describe_set_drop(old_value, node.get(property_name), coerced, property_name)
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 a SET_FAILED is truly
# non-destructive. Done before undo registration — nothing to roll back.
node.set(property_name, old_value)
return MCPToolkitError.fail("SET_FAILED", str(outcome.get("error", "")))
var action = MCPToolkitUndoRedoAction.begin("set %s.%s" % [node_path, property_name], node)
action.do_property(node, property_name, coerced)
action.undo_property(node, property_name, old_value)
if coerced is Resource:
action.do_reference(coerced)
if old_value is Resource:
action.undo_reference(old_value)
action.commit_recorded()
if outcome.get("status", "") == "adjusted":
return MCPToolkitSuccess.ok({"warning": str(outcome.get("warning", ""))})
return MCPToolkitSuccess.ok()
## Batch set multiple properties in one UndoRedo action.
## Compound paths (: or /) use set_property_compound() and add their undo
## info to the same batch action when possible (property-type paths only).
static func _batch_set_properties(server: Node, root: Node, entries: Array) -> Dictionary:
var action = MCPToolkitUndoRedoAction.begin("batch set %d properties" % entries.size(), root)
var results: Array = []
for entry in entries:
if typeof(entry) != TYPE_DICTIONARY:
results.append({"success": false, "error": "entry must be an object"})
continue
var np := str(entry.get("node_path", ""))
np = Helpers.normalize_editor_path(np)
var prop := str(entry.get("property", ""))
var raw_val = entry.get("value", null)
if np.is_empty() or prop.is_empty():
results.append({"node_path": np, "property": prop,
"success": false, "error": "missing node_path or property"})
continue
# Reject "groups" per-entry (the rest of the batch still applies). Without
# this it would fall through to node.set("groups", …) — a non-property —
# and vanish silently; group mutation belongs to node.groups (incremental).
if _is_groups_property(prop):
results.append({"node_path": np, "property": prop, "success": false,
"error": _GROUPS_REJECTION_MESSAGE, "hint": _GROUPS_REJECTION_HINT})
continue
var node := root.get_node_or_null(np)
if node == null:
results.append({"node_path": np, "property": prop,
"success": false, "error": "node not found"})
continue
# Compound paths: route through set_property_compound() which handles
# colon→slash conversion, sub-resource navigation, and readback.
# All compound undo uses server.undo_helpers.compound_set to keep
# the entire batch in a consistent EditorUndoRedoManager context.
if ":" in prop or "/" in prop:
var do_unique := bool(entry.get("make_unique", false))
var result := Helpers.set_property_compound(
node, prop, raw_val, do_unique)
if result.get("ok", false):
var ui: Dictionary = result.get("_undo", {})
var undo_type: String = str(ui.get("type", ""))
if undo_type != "":
var undo_path: String = ui["path"]
var new_val = ui.get("new", node.get(undo_path))
action.do_method(server.undo_helpers.compound_set.bind(
node, undo_path, new_val))
action.undo_method(server.undo_helpers.compound_set.bind(
node, undo_path, ui["old"]))
if ui.has("old_resource_prop"):
var res_prop: String = ui["old_resource_prop"]
var new_res = node.get(res_prop)
action.do_method(server.undo_helpers.compound_set.bind(
node, res_prop, new_res))
action.undo_method(server.undo_helpers.compound_set.bind(
node, res_prop, ui["old_resource"]))
if new_res is Resource:
action.do_reference(new_res)
if ui["old_resource"] is Resource:
action.undo_reference(ui["old_resource"])
var res_entry := {"node_path": np, "property": prop, "success": true}
if result.has("made_unique"):
res_entry["made_unique"] = result["made_unique"]
if result.has("warning"):
res_entry["warning"] = result["warning"]
results.append(res_entry)
else:
results.append({"node_path": np, "property": prop,
"success": false, "error": str(result.get("error", ""))})
continue
var missing := Coerce.check_resource_paths(raw_val)
if missing != "":
results.append({"node_path": np, "property": prop,
"success": false, "error": "resource not found: %s" % missing})
continue
var coerced = Coerce.coerce_value(raw_val)
if typeof(coerced) == TYPE_DICTIONARY and (coerced as Dictionary).has("_coerce_error"):
results.append({"node_path": np, "property": prop,
"success": false, "error": str(coerced["_coerce_error"])})
continue
var old_value = node.get(prop)
if typeof(old_value) == TYPE_NODE_PATH and typeof(coerced) == TYPE_STRING:
coerced = NodePath(str(coerced))
# Apply mutation unconditionally, then record for undo. Disarm the Godot 4.3
# tooltip-timer UAF first when prop is editor_description (no-op otherwise).
Helpers.disarm_tooltip_uaf(node, prop)
node.set(prop, coerced)
# Classify: DROPPED → per-entry failure (skip undo; the rest of the batch
# still applies, summarize_batch rolls it up). ADJUSTED/OK committed → register
# undo; ADJUSTED adds a per-entry warning naming the engine's value reshape.
var outcome := Helpers.describe_set_drop(old_value, node.get(prop), coerced, prop)
if outcome.get("status", "") == "dropped":
# Restore the prior value (a bound setter may have zeroed it) so this
# failed entry is non-destructive; skip its undo, rest of batch applies.
node.set(prop, old_value)
results.append({"node_path": np, "property": prop,
"success": false, "error": str(outcome.get("error", ""))})
continue
action.do_method(server.undo_helpers.compound_set.bind(node, prop, coerced))
action.undo_method(server.undo_helpers.compound_set.bind(node, prop, old_value))
if coerced is Resource:
action.do_reference(coerced)
if old_value is Resource:
action.undo_reference(old_value)
var scalar_entry := {"node_path": np, "property": prop, "success": true}
if outcome.get("status", "") == "adjusted":
scalar_entry["warning"] = str(outcome.get("warning", ""))
results.append(scalar_entry)
action.commit_recorded()
# Aggregate warnings for non-persisting external sub-resource mutations.
var non_persisting: PackedStringArray = []
for r in results:
if r.has("warning"):
non_persisting.append(str(r.get("property", "")))
var response := {"results": results}
if non_persisting.size() > 0:
response["warning"] = (
"These compound paths were set on shared external sub-resources "
+ "and may not persist after save/reload: %s. " % ", ".join(non_persisting)
+ "Retry those entries with make_unique: true to auto-duplicate "
+ "them as inline copies that persist.")
# Roll per-entry failures up into a top-level failed count + hint (additive:
# all-success batches are byte-identical; the existing warning is preserved).
return MCPToolkitSuccess.ok(Helpers.summarize_batch(response, "results"))
static func _resolve_common_property_names(node: Object) -> Array[String]:
var result: Array[String] = []
var current := node.get_class()
var depth := 0
while not current.is_empty() and depth < 16:
if COMMON_PROPERTIES_BY_CLASS.has(current):
for prop_name in COMMON_PROPERTIES_BY_CLASS[current]:
if prop_name not in result:
result.append(prop_name)
if ClassDB.class_exists(current):
current = ClassDB.get_parent_class(current)
else:
break
depth += 1
return result
static func _cmd_node_get_property_list(parameters: Dictionary) -> Dictionary:
var root := _get_edited_root()
if root == null:
return MCPToolkitError.fail("NO_SCENE", "no edited scene")
var node_path := str(parameters.get("node_path", ""))
node_path = Helpers.normalize_editor_path(node_path)
var node = _resolve_scene_node(node_path)
if node == null:
return MCPToolkitError.fail("NOT_FOUND", "node not found: %s" % node_path, MCPToolkitError.HINT_NODE_PATH)
var mask := str(parameters.get("mask", "common"))
if not (mask in ["common", "all", "groups", "script"]):
return MCPToolkitError.fail("INVALID_PARAMS",
"mask must be 'common', 'all', 'groups', or 'script' (got '%s')" % mask)
var visibility_filter := str(parameters.get("visibility", "all"))
if mask == "script" and not (visibility_filter in ["public", "private", "all"]):
return MCPToolkitError.fail("INVALID_PARAMS",
"visibility must be 'public', 'private', or 'all' (got '%s')" % visibility_filter)
var common_names: Array[String] = []
if mask == "common":
common_names = _resolve_common_property_names(node)
var properties: Array = []
if mask == "script":
# Use Script.get_script_property_list() directly — it works for both
# @tool and non-@tool scripts, unlike node.get_property_list() which
# may omit PROPERTY_USAGE_SCRIPT_VARIABLE for non-@tool scripts.
var script: Script = node.get_script() as Script
if script != null:
for property in script.get_script_property_list():
var usage: int = int(property.get("usage", 0))
if not (usage & PROPERTY_USAGE_EDITOR):
continue
var property_name := str(property.get("name", ""))
if property_name.is_empty():
continue
var vis := "private" if property_name.begins_with("_") else "public"
if visibility_filter != "all" and vis != visibility_filter:
continue
properties.append({
"name": property_name,
"type": int(property.get("type", 0)),
"hint": int(property.get("hint", 0)),
"hint_string": str(property.get("hint_string", "")),
"visibility": vis,
})
else:
for property in node.get_property_list():
var usage: int = int(property.get("usage", 0))
var property_name := str(property.get("name", ""))
if property_name.is_empty():
continue
if not (usage & PROPERTY_USAGE_EDITOR):
continue
if property_name.begins_with("_"):
continue
if mask == "common" and property_name not in common_names:
continue
if mask == "groups":
properties.append({
"name": property_name,
"usage": usage,
})
else:
properties.append({
"name": property_name,
"type": int(property.get("type", 0)),
"hint": int(property.get("hint", 0)),
"hint_string": str(property.get("hint_string", "")),
})
return MCPToolkitSuccess.ok({
"path": node_path,
"class": node.get_class(),
"mask": mask,
"properties": properties,
"count": properties.size(),
})
## Returns the version-tailored stale-live-instance recovery hint when the target node's
## ON-DISK .gd defines `method_name` (and compiles) but the live instance lacks it —
## i.e. the instance is stale, not the call wrong. Fires on Godot < 4.4 (any mode) and on
## 4.4+ when HEADLESS (a headless editor never re-instantiates a reloaded node). ""
## otherwise (no script / non-.gd / method genuinely absent / disk won't compile / 4.4+
## with a display). The pure decision lives in StaleInstanceHint; this just reads the
## running version + headless flag + the on-disk source. Called only on the
## INVALID_METHOD error path.
static func _stale_method_hint(node: Object, method_name: String) -> String:
var scr = node.get_script()
if scr == null:
return ""
var scr_path := str(scr.resource_path)
if not scr_path.to_lower().ends_with(".gd") or not FileAccess.file_exists(scr_path):
return ""
var disk_source := FileAccess.get_file_as_string(scr_path)
var version := Modules.VersionUtils.get_engine_version_ints()
var headless := Modules.VersionUtils.is_headless()
var disk_has := Modules.StaleInstanceHint.source_has_method(disk_source, method_name)
var disk_ok := Modules.StaleInstanceHint.source_compiles(disk_source)
if not Modules.StaleInstanceHint.should_hint_on_call(false, disk_has, disk_ok, true, version.x, version.y, headless):
return ""
return Modules.StaleInstanceHint.recovery_message(
Modules.VersionUtils.get_engine_version_pair(), version.y, headless)
## Builds the node.call_method hint for a null callv() result, version-gated.
##
## A non-@tool GDScript never runs in the editor, so callv() cannot dispatch its
## method and returns null (the editor logs "Method not found"). The hint leads with
## the reliable runtime path, then the editor @tool fix. That fix is gated at 4.5:
## on 4.5+ an attached instance's method table is rebuilt only by fully reopening the
## scene (scene_close + scene_open) — editor_refresh does not suffice — while below
## 4.5 no MCP-actionable scene reopen exists, so the editor must be relaunched. C#
## additionally needs a project rebuild, on every version.
## [param ver_pair] the running engine "major.minor" (drives the 4.5 gate).
static func _call_method_null_hint(is_csharp: bool, ver_pair: String) -> String:
if is_csharp:
return ("Return value was null. C# methods cannot execute in editor mode without "
+ "the [Tool] attribute — Godot registers the method signature but does not "
+ "instantiate the managed .NET object. Properties and signals work normally. Use "
+ "game.start + execute_code to call C# methods at runtime, or set state via "
+ "node.set_property (most C# logic runs in _Ready() at startup). To call it in the "
+ "editor, add [Tool], then rebuild the C# project and relaunch the editor.")
var editor_tail := ("add @tool, then close and reopen the scene (scene_close + scene_open); "
+ "editor_refresh is not sufficient")
if not Modules.VersionUtils.is_at_least(ver_pair, "4.5"):
editor_tail = "add @tool, then relaunch the editor"
return ("Return value was null. A non-@tool GDScript never runs in the editor, so callv() "
+ "cannot find the method (the editor logs 'Method not found'). To call it: (1) runtime, "
+ "reliable — game.start, then execute_code or runtime_get_node_state on the live node; "
+ "(2) editor — " + editor_tail + ".")
static func _cmd_node_call_method(parameters: Dictionary) -> Dictionary:
var root := _get_edited_root()
if root == null:
return MCPToolkitError.fail("NO_SCENE", "no open scene; use scene.open or scene.create first")
var node_path := str(parameters.get("node_path", ""))
node_path = Helpers.normalize_editor_path(node_path)
var method_name := str(parameters.get("method_name", ""))
var args_raw = parameters.get("args", [])
# Resource refs in args are validated via Coerce.check_resource_paths,
# which gates through FileGuard. node_path is a scene-tree path, not filesystem.
if node_path.is_empty() or method_name.is_empty():
return MCPToolkitError.fail("INVALID_PARAMS", "missing node_path or method_name")
if typeof(args_raw) != TYPE_ARRAY:
return MCPToolkitError.fail("INVALID_PARAMS",
"args must be an Array (got %s)" % typeof(args_raw))
var node := root.get_node_or_null(node_path)
if node == null:
return MCPToolkitError.fail("NOT_FOUND",
"no node at path %s. This tool is editor-only — for runtime nodes use execute_code or runtime_get_node_state." % node_path)
if not node.has_method(method_name):
var no_method_msg := "node %s has no method '%s'; use scene.get_tree or inspect the script class via ClassDB" % [
node_path, method_name]
var stale_hint := _stale_method_hint(node, method_name)
if stale_hint != "":
return MCPToolkitError.fail("INVALID_METHOD", no_method_msg, stale_hint)
return MCPToolkitError.fail("INVALID_METHOD", no_method_msg)
var missing := Coerce.check_resource_paths(args_raw)
if missing != "":
return MCPToolkitError.fail("LOAD_FAILED",
"failed to load resource at %s; verify the path or use resource.write to create it first" % missing)
var coerced_args = Coerce.coerce_value(args_raw)
if typeof(coerced_args) != TYPE_ARRAY:
coerced_args = []
for arg in coerced_args:
if typeof(arg) == TYPE_DICTIONARY and (arg as Dictionary).has("_coerce_error"):
return MCPToolkitError.fail("INVALID_PARAMS", str(arg["_coerce_error"]))
print("[MCPTools] node.call_method invoked %s.%s(%d args)" % [
node_path, method_name, (coerced_args as Array).size()])
var result = node.callv(method_name, coerced_args)
var response := {
"path": node_path,
"method": method_name,
"result": Coerce.serialize_value(result),
}
if result == null:
var script = node.get_script()
var is_csharp := script != null and str(script.resource_path).ends_with(".cs")
response["hint"] = _call_method_null_hint(is_csharp, Modules.VersionUtils.get_engine_version_pair())
return MCPToolkitSuccess.ok(response)
static func _cmd_node_set_script(parameters: Dictionary) -> Dictionary:
var root := _get_edited_root()
if root == null:
return MCPToolkitError.fail("NO_SCENE", "no edited scene")
var node_path := str(parameters.get("node_path", ""))
node_path = Helpers.normalize_editor_path(node_path)
if node_path.is_empty():
return MCPToolkitError.fail("INVALID_PARAMS", "missing node_path")
var node := root.get_node_or_null(node_path)
if node == null:
return MCPToolkitError.fail("NOT_FOUND", "node not found: %s" % node_path, MCPToolkitError.HINT_NODE_PATH)
var script_path := str(parameters.get("script_path", ""))
if script_path.is_empty():
var old_script = node.get_script()
node.set_script(null)
var clear_action = MCPToolkitUndoRedoAction.begin("clear script on %s" % node_path, node)
clear_action.do_property(node, &"script", null)
clear_action.undo_property(node, &"script", old_script)
if old_script is Resource:
clear_action.undo_reference(old_script)
clear_action.commit_recorded()
return MCPToolkitSuccess.ok({"path": node_path, "script": null, "properties": []})
var guard := FileGuard.resolve_safe(script_path)
if guard["error"] != null:
return MCPToolkitError.fail("PATH_DENIED", str(guard["reason"]))
var loaded = ResourceLoader.load(script_path)
if loaded == null:
return MCPToolkitError.fail("LOAD_FAILED",
"cannot load script at %s; verify the path or use script.write to create it first" % script_path)
if not (loaded is Script):
return MCPToolkitError.fail("INVALID_PARAMS",
"resource at %s is not a Script (got %s)" % [script_path, loaded.get_class()])
var old_script = node.get_script()
node.set_script(loaded)
var set_action = MCPToolkitUndoRedoAction.begin("set script %s on %s" % [script_path, node_path], node)
set_action.do_property(node, &"script", loaded)
set_action.undo_property(node, &"script", old_script)
set_action.do_reference(loaded)
if old_script is Resource:
set_action.undo_reference(old_script)
set_action.commit_recorded()
var exports: Array = []
for property in loaded.get_script_property_list():
var usage: int = int(property.get("usage", 0))
if not (usage & PROPERTY_USAGE_EDITOR):
continue
var property_name := str(property.get("name", ""))
if property_name.is_empty() or property_name.begins_with("_"):
continue
exports.append({
"name": property_name,
"type": int(property.get("type", 0)),
"hint": int(property.get("hint", 0)),
"hint_string": str(property.get("hint_string", "")),
})
return MCPToolkitSuccess.ok({"path": node_path, "script": script_path, "properties": exports})
static func _cmd_node_manage(parameters: Dictionary) -> Dictionary:
var action := str(parameters.get("action", ""))
if action.is_empty():
return MCPToolkitError.fail("INVALID_PARAMS", "missing action (rename|reparent|reorder|duplicate)")
var root := _get_edited_root()
if root == null:
return MCPToolkitError.fail("NO_SCENE", "no edited scene")
var node_path := str(parameters.get("node_path", ""))
node_path = Helpers.normalize_editor_path(node_path)
if node_path.is_empty():
return MCPToolkitError.fail("INVALID_PARAMS", "missing node_path")
var node := root.get_node_or_null(node_path)
if node == null:
return MCPToolkitError.fail("NOT_FOUND", "node not found: %s" % node_path, MCPToolkitError.HINT_NODE_PATH)
match action:
"rename":
return _manage_rename(root, node, node_path, parameters)
"reparent":
return _manage_reparent(root, node, node_path, parameters)
"reorder":
return _manage_reorder(root, node, node_path, parameters)
"duplicate":
return _manage_duplicate(root, node, node_path, parameters)
_:
return MCPToolkitError.fail("INVALID_PARAMS",
"unknown action '%s'; must be rename|reparent|reorder|duplicate" % action)
static func _manage_rename(
root: Node, node: Node, node_path: String, parameters: Dictionary,
) -> Dictionary:
var new_name := str(parameters.get("new_name", ""))
if new_name.is_empty():
return MCPToolkitError.fail("INVALID_PARAMS", "rename requires new_name")
# No root guard: renaming the scene root is valid (the root name is independent
# of the scene file name) and node.set_property already renames it via "name" —
# the two paths must agree. Reparent/reorder/duplicate keep their root guards:
# those ARE structurally invalid for a root.
var old_name := String(node.name)
node.name = new_name
MCPToolkitUndoRedoAction.begin("rename %s -> %s" % [old_name, new_name], node) \
.do_property(node, &"name", new_name) \
.undo_property(node, &"name", old_name) \
.commit_recorded()
var parent := node.get_parent()
var new_path := str(root.get_path_to(node))
return MCPToolkitSuccess.ok({"action": "rename", "old_name": old_name,
"new_name": String(node.name), "new_path": new_path})
static func _manage_reparent(
root: Node, node: Node, node_path: String, parameters: Dictionary,
) -> Dictionary:
var new_parent_path := str(parameters.get("new_parent_path", ""))
new_parent_path = Helpers.normalize_editor_path(new_parent_path)
if new_parent_path.is_empty():
return MCPToolkitError.fail("INVALID_PARAMS", "reparent requires new_parent_path")
if node == root:
return MCPToolkitError.fail("INVALID_PATH", "cannot reparent the scene root")
var new_parent := root.get_node_or_null(new_parent_path)
if new_parent == null:
return MCPToolkitError.fail("NOT_FOUND",
"new parent not found: %s" % new_parent_path, MCPToolkitError.HINT_NODE_PATH)
# Prevent reparenting a node under itself (would create a cycle).
if new_parent == node or node.is_ancestor_of(new_parent):
return MCPToolkitError.fail("INVALID_PARAMS",
"cannot reparent a node under itself or a descendant")
var keep_global := bool(parameters.get("keep_global_transform", true))
var old_parent := node.get_parent()
var old_index := node.get_index()
node.reparent(new_parent, keep_global)
node.set_owner(root)
MCPToolkitUndoRedoAction.begin("reparent %s -> %s" % [node_path, new_parent_path], node) \
.do_method(node.reparent.bind(new_parent, keep_global)) \
.do_method(node.set_owner.bind(root)) \
.undo_method(node.reparent.bind(old_parent, keep_global)) \
.undo_method(old_parent.move_child.bind(node, old_index)) \
.undo_method(node.set_owner.bind(root)) \
.commit_recorded()
var new_path := str(root.get_path_to(node))
return MCPToolkitSuccess.ok({"action": "reparent", "new_path": new_path})
static func _manage_reorder(
root: Node, node: Node, node_path: String, parameters: Dictionary,
) -> Dictionary:
if not parameters.has("new_index"):
return MCPToolkitError.fail("INVALID_PARAMS", "reorder requires new_index")
var new_index := int(parameters.get("new_index", 0))
if node == root:
return MCPToolkitError.fail("INVALID_PATH", "cannot reorder the scene root")
var parent := node.get_parent()
if parent == null:
return MCPToolkitError.fail("INTERNAL", "node has no parent")
var old_index := node.get_index()
var child_count := parent.get_child_count()
if new_index < 0 or new_index >= child_count:
return MCPToolkitError.fail("INVALID_PARAMS",
"new_index %d out of range [0, %d)" % [new_index, child_count])
parent.move_child(node, new_index)
MCPToolkitUndoRedoAction.begin("reorder %s to index %d" % [node_path, new_index], node) \
.do_method(parent.move_child.bind(node, new_index)) \
.undo_method(parent.move_child.bind(node, old_index)) \
.commit_recorded()
return MCPToolkitSuccess.ok({"action": "reorder", "path": node_path,
"old_index": old_index, "new_index": node.get_index()})
static func _manage_duplicate(
root: Node, node: Node, node_path: String, parameters: Dictionary,
) -> Dictionary:
if node == root:
return MCPToolkitError.fail("INVALID_PATH", "cannot duplicate the scene root")
var dup := node.duplicate()
if dup == null:
return MCPToolkitError.fail("INTERNAL", "Node.duplicate() returned null for %s" % node_path)
var new_name := str(parameters.get("new_name", ""))
if not new_name.is_empty():
dup.name = new_name
var parent_path := str(parameters.get("parent_path", ""))
parent_path = Helpers.normalize_editor_path(parent_path)
var target_parent: Node
if parent_path.is_empty():
target_parent = node.get_parent()
else:
target_parent = root.get_node_or_null(parent_path)
if target_parent == null:
dup.queue_free()
return MCPToolkitError.fail("NOT_FOUND",
"parent not found: %s" % parent_path, MCPToolkitError.HINT_NODE_PATH)
target_parent.add_child(dup)
dup.set_owner(root)
MCPToolkitUndoRedoAction.begin("duplicate %s" % node_path, target_parent) \
.do_method(target_parent.add_child.bind(dup)) \
.do_method(dup.set_owner.bind(root)) \
.do_reference(dup) \
.undo_method(target_parent.remove_child.bind(dup)) \
.commit_recorded()
# Apply optional property overrides (position, scale, etc.).
# Use coerce_value_hint so untagged dicts like {x:200,y:300}
# are inferred as Vector2/Vector3/Color from the property type.
var props_raw = parameters.get("properties", null)
if typeof(props_raw) == TYPE_DICTIONARY:
for key in (props_raw as Dictionary):
var prop_name := str(key)
var existing = dup.get(prop_name)
var coerced = Coerce.coerce_value_hint(props_raw[key], existing)
if typeof(coerced) == TYPE_DICTIONARY and (coerced as Dictionary).has("_coerce_error"):
return MCPToolkitError.fail("INVALID_PARAMS", str(coerced["_coerce_error"]))
dup.set(prop_name, coerced)
var dup_path := str(root.get_path_to(dup))
return MCPToolkitSuccess.ok({"action": "duplicate", "path": dup_path,
"class": dup.get_class()})
static func _cmd_node_groups(parameters: Dictionary) -> Dictionary:
var action := str(parameters.get("action", ""))
if action.is_empty():
return MCPToolkitError.fail("INVALID_PARAMS", "missing action (add|remove|list)")
var root := _get_edited_root()
if root == null:
return MCPToolkitError.fail("NO_SCENE", "no edited scene")
# Batch mode: entries array present → process N node+group pairs in one UndoRedo action.
var entries_raw = parameters.get("entries", null)
if typeof(entries_raw) == TYPE_ARRAY and (entries_raw as Array).size() > 0:
if action == "list":
return MCPToolkitError.fail("INVALID_PARAMS",
"batch entries not supported with action 'list'; use single mode per node")
return _batch_node_groups(root, action, entries_raw as Array)
var node_path := str(parameters.get("node_path", ""))
node_path = Helpers.normalize_editor_path(node_path)
if node_path.is_empty():
return MCPToolkitError.fail("INVALID_PARAMS",
"missing node_path: required for single-node group operations (add/remove/list); omit it only in batch mode, where each item in entries carries its own node_path.",
MCPToolkitError.HINT_NODE_PATH)
var node := root.get_node_or_null(node_path)
if node == null:
return MCPToolkitError.fail("NOT_FOUND", "node not found: %s" % node_path, MCPToolkitError.HINT_NODE_PATH)
match action:
"add":
var group := str(parameters.get("group", ""))
if group.is_empty():
return MCPToolkitError.fail("INVALID_PARAMS", "add requires group name")
var persistent := bool(parameters.get("persistent", true))
node.add_to_group(group, persistent)
MCPToolkitUndoRedoAction.begin("add %s to group %s" % [node_path, group], node) \
.do_method(node.add_to_group.bind(group, persistent)) \
.undo_method(node.remove_from_group.bind(group)) \
.commit_recorded()
return MCPToolkitSuccess.ok({"action": "add", "node": node_path, "group": group})
"remove":
var group := str(parameters.get("group", ""))
if group.is_empty():
return MCPToolkitError.fail("INVALID_PARAMS", "remove requires group name")
if not node.is_in_group(group):
return MCPToolkitError.fail("NOT_FOUND",
"node %s is not in group '%s'" % [node_path, group])
node.remove_from_group(group)
MCPToolkitUndoRedoAction.begin("remove %s from group %s" % [node_path, group], node) \
.do_method(node.remove_from_group.bind(group)) \
.undo_method(node.add_to_group.bind(group, true)) \
.commit_recorded()
return MCPToolkitSuccess.ok({"action": "remove", "node": node_path, "group": group})
"list":
var groups: Array[String] = []
for g in node.get_groups():
var gs := str(g)
if not gs.begins_with("_"):
groups.append(gs)
return MCPToolkitSuccess.ok({"action": "list", "node": node_path,
"groups": groups, "count": groups.size()})
_:
return MCPToolkitError.fail("INVALID_PARAMS",
"unknown action '%s'; must be add|remove|list" % action)
static func _batch_node_groups(root: Node, batch_action: String, entries: Array) -> Dictionary:
var undo_action = MCPToolkitUndoRedoAction.begin(
"batch %s groups (%d entries)" % [batch_action, entries.size()], root)
var results: Array = []
for entry in entries:
var e: Dictionary = entry if typeof(entry) == TYPE_DICTIONARY else {}
var np := str(e.get("node_path", ""))
np = Helpers.normalize_editor_path(np)
var group := str(e.get("group", ""))
if np.is_empty() or group.is_empty():
results.append({"node_path": np, "group": group, "error": "missing node_path or group"})
continue
var node := root.get_node_or_null(np)
if node == null:
results.append({"node_path": np, "group": group, "error": "node not found"})
continue
match batch_action:
"add":
node.add_to_group(group, true)
undo_action.do_method(node.add_to_group.bind(group, true))
undo_action.undo_method(node.remove_from_group.bind(group))
results.append({"node_path": np, "group": group, "status": "added"})
"remove":
if not node.is_in_group(group):
results.append({"node_path": np, "group": group, "error": "not in group"})
continue
node.remove_from_group(group)
undo_action.do_method(node.remove_from_group.bind(group))
undo_action.undo_method(node.add_to_group.bind(group, true))
results.append({"node_path": np, "group": group, "status": "removed"})
undo_action.commit_recorded()
# Entries here carry {status?, error?} with no `success` key — summarize_batch's
# tolerant predicate (no-success + error => failure) counts them. Additive:
# all-success batches keep the same {action, results, count} shape.
var response := {"action": batch_action, "results": results, "count": results.size()}
return MCPToolkitSuccess.ok(Helpers.summarize_batch(response, "results"))
const _LAYOUT_PRESETS := {
"PRESET_TOP_LEFT": Control.PRESET_TOP_LEFT,
"PRESET_TOP_RIGHT": Control.PRESET_TOP_RIGHT,
"PRESET_BOTTOM_LEFT": Control.PRESET_BOTTOM_LEFT,
"PRESET_BOTTOM_RIGHT": Control.PRESET_BOTTOM_RIGHT,
"PRESET_CENTER_LEFT": Control.PRESET_CENTER_LEFT,
"PRESET_CENTER_TOP": Control.PRESET_CENTER_TOP,
"PRESET_CENTER_RIGHT": Control.PRESET_CENTER_RIGHT,
"PRESET_CENTER_BOTTOM": Control.PRESET_CENTER_BOTTOM,
"PRESET_CENTER": Control.PRESET_CENTER,
"PRESET_LEFT_WIDE": Control.PRESET_LEFT_WIDE,
"PRESET_TOP_WIDE": Control.PRESET_TOP_WIDE,
"PRESET_RIGHT_WIDE": Control.PRESET_RIGHT_WIDE,
"PRESET_BOTTOM_WIDE": Control.PRESET_BOTTOM_WIDE,
"PRESET_VCENTER_WIDE": Control.PRESET_VCENTER_WIDE,
"PRESET_HCENTER_WIDE": Control.PRESET_HCENTER_WIDE,
"PRESET_FULL_RECT": Control.PRESET_FULL_RECT,
}
static func _cmd_control_set_layout(parameters: Dictionary) -> Dictionary:
var root := _get_edited_root()
if root == null:
return MCPToolkitError.fail("NO_SCENE", "no edited scene")
var node_path := str(parameters.get("node_path", ""))
node_path = Helpers.normalize_editor_path(node_path)
var preset_name := str(parameters.get("preset", ""))
var resize_mode_str := str(parameters.get("resize_mode", "keep_size"))
var margins_raw = parameters.get("margins", null)
if node_path.is_empty():
return MCPToolkitError.fail("INVALID_PARAMS", "missing node_path")
if preset_name.is_empty():
return MCPToolkitError.fail("INVALID_PARAMS", "missing preset")
var node := root.get_node_or_null(node_path)
if node == null:
return MCPToolkitError.fail("NOT_FOUND", "node not found: %s" % node_path, MCPToolkitError.HINT_NODE_PATH)
if not (node is Control):
return MCPToolkitError.fail("INVALID_CLASS",
"node at %s is %s — control.set_layout requires a Control node" % [
node_path, node.get_class()])
var ctrl: Control = node as Control
if not _LAYOUT_PRESETS.has(preset_name):
var available := ", ".join(PackedStringArray(_LAYOUT_PRESETS.keys()))
return MCPToolkitError.fail("INVALID_PARAMS",
"unknown preset '%s'. Available: %s" % [preset_name, available])
var preset_enum: int = _LAYOUT_PRESETS[preset_name]
var mode: int = Control.PRESET_MODE_KEEP_SIZE \
if resize_mode_str == "keep_size" \
else Control.PRESET_MODE_MINSIZE
# Capture state for UndoRedo
var old_anchor_left := ctrl.anchor_left
var old_anchor_top := ctrl.anchor_top
var old_anchor_right := ctrl.anchor_right
var old_anchor_bottom := ctrl.anchor_bottom
var old_offset_left := ctrl.offset_left
var old_offset_top := ctrl.offset_top
var old_offset_right := ctrl.offset_right
var old_offset_bottom := ctrl.offset_bottom
# Apply preset + margins directly so margin offsets are relative to the
# NEW anchor positions, not the old ones (UndoRedo queues do-methods, so
# margin values would be computed against stale offsets if queued).
ctrl.set_anchors_and_offsets_preset(preset_enum, mode)
if margins_raw != null and typeof(margins_raw) == TYPE_DICTIONARY:
if margins_raw.has("left"):
ctrl.offset_left += float(margins_raw["left"])
if margins_raw.has("right"):
ctrl.offset_right += float(margins_raw["right"])
if margins_raw.has("top"):
ctrl.offset_top += float(margins_raw["top"])
if margins_raw.has("bottom"):
ctrl.offset_bottom += float(margins_raw["bottom"])
# Record for undo using the final property values (already applied).
MCPToolkitUndoRedoAction.begin("control.set_layout %s %s" % [node_path, preset_name], ctrl) \
.do_property(ctrl, &"anchor_left", ctrl.anchor_left) \
.do_property(ctrl, &"anchor_top", ctrl.anchor_top) \
.do_property(ctrl, &"anchor_right", ctrl.anchor_right) \
.do_property(ctrl, &"anchor_bottom", ctrl.anchor_bottom) \
.do_property(ctrl, &"offset_left", ctrl.offset_left) \
.do_property(ctrl, &"offset_top", ctrl.offset_top) \
.do_property(ctrl, &"offset_right", ctrl.offset_right) \
.do_property(ctrl, &"offset_bottom", ctrl.offset_bottom) \
.undo_property(ctrl, &"anchor_left", old_anchor_left) \
.undo_property(ctrl, &"anchor_top", old_anchor_top) \
.undo_property(ctrl, &"anchor_right", old_anchor_right) \
.undo_property(ctrl, &"anchor_bottom", old_anchor_bottom) \
.undo_property(ctrl, &"offset_left", old_offset_left) \
.undo_property(ctrl, &"offset_top", old_offset_top) \
.undo_property(ctrl, &"offset_right", old_offset_right) \
.undo_property(ctrl, &"offset_bottom", old_offset_bottom) \
.commit_recorded()
var response := {
"path": node_path,
"preset": preset_name,
"final_rect": {
"position": {"x": ctrl.position.x, "y": ctrl.position.y},
"size": {"x": ctrl.size.x, "y": ctrl.size.y},
},
}
# Warn if the Control is inside a Container
var parent := ctrl.get_parent()
if parent != null and parent is Container:
response["warning"] = (
"This Control is inside a %s container. " % parent.get_class() +
"The container will override layout on the next layout pass. " +
"Consider using size_flags or moving the node outside the container.")
return MCPToolkitSuccess.ok(response)
static func _cmd_collision_from_sprite(parameters: Dictionary) -> Dictionary:
var err = MCPToolkitError.require(parameters, ["sprite_path"])
if err != null:
return err
var root := Helpers.get_edited_root()
if root == null:
return MCPToolkitError.fail("NO_SCENE", "no edited scene")
var sprite_path := str(parameters.get("sprite_path", ""))
sprite_path = Helpers.normalize_editor_path(sprite_path)
var node = Helpers.resolve_scene_node(sprite_path)
if node == null:
return MCPToolkitError.fail("NOT_FOUND", "node not found: %s" % sprite_path, MCPToolkitError.HINT_NODE_PATH)
if not (node is Sprite2D or node is TextureRect):
return MCPToolkitError.fail("INVALID_CLASS",
"node at %s is %s — expected Sprite2D or TextureRect" % [sprite_path, node.get_class()])
var tex = node.get("texture") as Texture2D
if tex == null:
return MCPToolkitError.fail("INVALID_PARAMS", "sprite has no texture")
var img := tex.get_image()
if img == null:
return MCPToolkitError.fail("INVALID_PARAMS", "cannot read image data")
var simplification := float(parameters.get("simplification", 2.0))
var bitmap := BitMap.new()
bitmap.create_from_image_alpha(img, 0.1)
var polygons := bitmap.opaque_to_polygons(
Rect2(Vector2.ZERO, Vector2(img.get_width(), img.get_height())),
simplification)
if polygons.is_empty():
return MCPToolkitError.fail("INVALID_PARAMS", "no opaque regions found in texture")
# Resolve target parent
var target_parent: Node
var target_parent_path := str(parameters.get("parent_path", ""))
if target_parent_path.is_empty():
target_parent = node.get_parent()
else:
target_parent_path = Helpers.normalize_editor_path(target_parent_path)
target_parent = root.get_node_or_null(target_parent_path)
if target_parent == null:
return MCPToolkitError.fail("NOT_FOUND",
"target parent not found: %s" % target_parent_path, MCPToolkitError.HINT_NODE_PATH)
var sprite_name := String(node.name)
var base_name := str(parameters.get("target_name", "%s_collision" % sprite_name))
var total_points := 0
var first_path := ""
var coll_action = MCPToolkitUndoRedoAction.begin("collision from sprite", target_parent)
for i in range(polygons.size()):
var coll := CollisionPolygon2D.new()
if polygons.size() == 1:
coll.name = base_name
else:
coll.name = "%s_%d" % [base_name, i]
coll.polygon = polygons[i]
total_points += (polygons[i] as PackedVector2Array).size()
target_parent.add_child(coll)
coll.set_owner(root)
coll_action.do_method(target_parent.add_child.bind(coll))
coll_action.do_method(coll.set_owner.bind(root))
coll_action.do_reference(coll)
coll_action.undo_method(target_parent.remove_child.bind(coll))
if i == 0:
first_path = str(root.get_path_to(coll))
coll_action.commit_recorded()
return MCPToolkitSuccess.ok({
"path": first_path,
"polygon_count": polygons.size(),
"total_points": total_points,
})