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