@tool extends RefCounted const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd") const VariantSerializer := preload("res://addons/godot_ai/utils/variant_serializer.gd") ## Handles node creation and manipulation with undo/redo support. const ResourceHandler := preload("res://addons/godot_ai/handlers/resource_handler.gd") var _undo_redo: EditorUndoRedoManager func _init(undo_redo: EditorUndoRedoManager) -> void: _undo_redo = undo_redo func create_node(params: Dictionary) -> Dictionary: var node_type: String = params.get("type", "") var node_name: String = params.get("name", "") var parent_path: String = params.get("parent_path", "") var scene_path: String = params.get("scene_path", "") var scene_check := McpScenePath.require_edited_scene(params.get("scene_file", "")) if scene_check.has("error"): return scene_check var scene_root: Node = scene_check.node var parent: Node = scene_root if not parent_path.is_empty(): parent = McpScenePath.resolve(parent_path, scene_root) if parent == null: return ErrorCodes.make(ErrorCodes.NODE_NOT_FOUND, McpScenePath.format_parent_error(parent_path, scene_root)) var new_node: Node if not scene_path.is_empty(): # Scene instancing path — load and instantiate a PackedScene. # GEN_EDIT_STATE_INSTANCE makes the editor treat the result as a real # scene instance (foldout icon, the .tscn stores a reference instead of # an exploded subtree). Descendants remain owned by their sub-scene; # setting their owner to our scene_root would break the instance link. var scene_path_err = McpPathValidator.loadable_error(scene_path, "scene_path") if scene_path_err != null: return scene_path_err if not ResourceLoader.exists(scene_path): return ErrorCodes.make(ErrorCodes.RESOURCE_NOT_FOUND, "Scene not found: %s" % scene_path) var packed_scene = ResourceLoader.load(scene_path) if packed_scene == null or not packed_scene is PackedScene: return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "Resource at %s is not a PackedScene" % scene_path) new_node = packed_scene.instantiate(PackedScene.GEN_EDIT_STATE_INSTANCE) if new_node == null: return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Failed to instantiate scene: %s" % scene_path) else: # ClassDB path — create by type. if node_type.is_empty(): return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: type (or provide scene_path)") if not ClassDB.class_exists(node_type): return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Unknown node type: %s" % node_type) if not ClassDB.is_parent_class(node_type, "Node"): return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "%s is not a Node type" % node_type) new_node = ClassDB.instantiate(node_type) if new_node == null: return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Failed to instantiate %s" % node_type) if not node_name.is_empty(): new_node.name = node_name _undo_redo.create_action("MCP: Create %s" % new_node.name) _undo_redo.add_do_method(parent, "add_child", new_node, true) _undo_redo.add_do_method(new_node, "set_owner", scene_root) _undo_redo.add_do_reference(new_node) _undo_redo.add_undo_method(parent, "remove_child", new_node) _undo_redo.commit_action() var response := { "name": new_node.name, "type": new_node.get_class(), "path": McpScenePath.from_node(new_node, scene_root), "parent_path": McpScenePath.from_node(parent, scene_root), "undoable": true, } if not scene_path.is_empty(): response["scene_path"] = scene_path return {"data": response} func delete_node(params: Dictionary) -> Dictionary: var resolved := _resolve_node(params) if resolved.has("error"): return resolved var node: Node = resolved.node var node_path: String = resolved.path var scene_root: Node = resolved.scene_root var root_err := _reject_if_scene_root(node, scene_root, "delete") if root_err != null: return root_err var parent := node.get_parent() var idx := node.get_index() _undo_redo.create_action("MCP: Delete %s" % node.name) _undo_redo.add_do_method(parent, "remove_child", node) _undo_redo.add_undo_method(parent, "add_child", node, true) _undo_redo.add_undo_method(parent, "move_child", node, idx) _undo_redo.add_undo_method(node, "set_owner", scene_root) _undo_redo.add_undo_reference(node) _undo_redo.commit_action() return { "data": { "path": node_path, "undoable": true, } } func reparent_node(params: Dictionary) -> Dictionary: var resolved := _resolve_node(params) if resolved.has("error"): return resolved var node: Node = resolved.node var node_path: String = resolved.path var scene_root: Node = resolved.scene_root var new_parent_path: String = params.get("new_parent", "") if new_parent_path.is_empty(): return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: new_parent") var new_parent := McpScenePath.resolve(new_parent_path, scene_root) if new_parent == null: return ErrorCodes.make(ErrorCodes.NODE_NOT_FOUND, McpScenePath.format_parent_error(new_parent_path, scene_root)) var root_err := _reject_if_scene_root(node, scene_root, "reparent") if root_err != null: return root_err # Prevent reparenting a node to itself or to one of its own descendants. # Godot's `A.is_ancestor_of(B)` returns true iff B is a descendant of A, so # the direction here matters: we want `node.is_ancestor_of(new_parent)` to # catch "new_parent is below node in the tree" and thus would create a # cycle. The previous direction (`new_parent.is_ancestor_of(node)`) asked # the opposite question — whether we were trying to move a node to one of # its own ancestors — which is a perfectly valid operation. See issue #121. if node == new_parent or node.is_ancestor_of(new_parent): return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, "Cannot reparent a node to itself or its descendant") var old_parent := node.get_parent() var old_idx := node.get_index() _undo_redo.create_action("MCP: Reparent %s" % node.name) _undo_redo.add_do_method(old_parent, "remove_child", node) _undo_redo.add_do_method(new_parent, "add_child", node, true) _undo_redo.add_do_method(node, "set_owner", scene_root) _undo_redo.add_do_reference(node) _undo_redo.add_undo_method(new_parent, "remove_child", node) _undo_redo.add_undo_method(old_parent, "add_child", node, true) _undo_redo.add_undo_method(old_parent, "move_child", node, old_idx) _undo_redo.add_undo_method(node, "set_owner", scene_root) _undo_redo.add_undo_reference(node) _undo_redo.commit_action() # Re-set owner for all descendants (reparent can break ownership chain) _set_owner_recursive(node, scene_root) return { "data": { "path": McpScenePath.from_node(node, scene_root), "old_parent": McpScenePath.from_node(old_parent, scene_root), "new_parent": McpScenePath.from_node(new_parent, scene_root), "undoable": true, } } func set_property(params: Dictionary) -> Dictionary: var resolved := _resolve_node(params) if resolved.has("error"): return resolved var node: Node = resolved.node var node_path: String = resolved.path var scene_root: Node = resolved.scene_root var property: String = params.get("property", "") if property.is_empty(): return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: property") if not "value" in params: return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: value") var value = params.get("value") var found := false var prop_type: int = TYPE_NIL for prop in node.get_property_list(): if prop.name == property: found = true prop_type = prop.get("type", TYPE_NIL) break if not found: return ErrorCodes.make(ErrorCodes.PROPERTY_NOT_ON_CLASS, McpPropertyErrors.build_message(node, property)) var old_value = node.get(property) # Prefer declared property type; fall back to runtime type for dynamic props # (scripted @export vars can report TYPE_NIL in the property list). var target_type: int = prop_type if prop_type != TYPE_NIL else typeof(old_value) var instantiated_resource := false # Some MCP clients (Cline) stringify the documented {"__class__": "BoxMesh", ...} # value before sending. Promote that string back to a Dictionary here so the # `__class__` branch below handles it, instead of the next branch treating # the JSON blob as a res:// path and emitting "Resource not found: {...}". # See #206. if target_type == TYPE_OBJECT and value is String and value.begins_with("{"): var json := JSON.new() if json.parse(value) == OK and json.data is Dictionary and (json.data as Dictionary).has("__class__"): value = json.data var nil_resource_string: bool = target_type == TYPE_NIL and (value == "" or (value is String and value.begins_with("res://"))) var resource_string_value: bool = value is String and (target_type == TYPE_OBJECT or nil_resource_string) if resource_string_value: if value == "": value = null else: var value_path_err = McpPathValidator.loadable_error(value, "value") if value_path_err != null: return value_path_err if not ResourceLoader.exists(value): return ErrorCodes.make(ErrorCodes.RESOURCE_NOT_FOUND, "Resource not found: %s" % value) var loaded := ResourceLoader.load(value) if loaded == null: return ErrorCodes.make(ErrorCodes.RESOURCE_NOT_FOUND, "Resource not found: %s" % value) value = loaded elif target_type == TYPE_OBJECT and value is Dictionary and value.has("__class__"): # Shortcut: {"__class__": "BoxMesh", "size": {...}} instantiates a # fresh Resource subclass and applies the remaining keys as # properties. Mirrors resource_create's inline-assign path but # avoids a separate tool call for the common case. var type_str: String = value.get("__class__", "") var class_err := ResourceHandler._validate_resource_class(type_str) if class_err != null: return class_err var instance := ClassDB.instantiate(type_str) if instance == null or not (instance is Resource): return ErrorCodes.make( ErrorCodes.INTERNAL_ERROR, "Failed to instantiate %s as a Resource" % type_str ) var res: Resource = instance var remaining: Dictionary = (value as Dictionary).duplicate() remaining.erase("__class__") if not remaining.is_empty(): var apply_err := ResourceHandler._apply_resource_properties(res, remaining) if apply_err != null: return apply_err value = res instantiated_resource = true else: value = _coerce_value(value, target_type) ## Refuse any value that didn't land as the target compound Variant ## — wrong-shape dict (#123) or non-dict input like list / JSON string ## that used to silently default-construct Vector3.ZERO (#191). var coerce_err := _check_coerced(value, target_type) if coerce_err != null: return coerce_err _undo_redo.create_action("MCP: Set %s.%s" % [node.name, property]) _undo_redo.add_do_property(node, property, value) _undo_redo.add_undo_property(node, property, old_value) if instantiated_resource: _undo_redo.add_do_reference(value) _undo_redo.commit_action() return { "data": { "path": node_path, "property": property, "value": _serialize_value(node.get(property)), "old_value": _serialize_value(old_value), "undoable": true, } } func rename_node(params: Dictionary) -> Dictionary: var resolved := _resolve_node(params) if resolved.has("error"): return resolved var node: Node = resolved.node var node_path: String = resolved.path var scene_root: Node = resolved.scene_root var new_name: String = params.get("new_name", "") if new_name.is_empty(): return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: new_name") ## The scene root's name is baked into the .tscn serialization and is ## referenced by every NodePath that starts with `/` (AnimationPlayer ## tracks, RemoteTransform3D targets, exported NodePath @vars, etc.). ## Renaming it silently breaks those references. The MCP tool's docstring ## has always promised "Cannot rename the scene root" — enforce it. #122 if node == scene_root: return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, "Cannot rename the scene root") if new_name.validate_node_name() != new_name: return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Invalid characters in name: %s" % new_name) var old_name := String(node.name) if old_name == new_name: return { "data": { "path": node_path, "name": new_name, "old_name": old_name, "unchanged": true, "undoable": false, "reason": "Name unchanged", } } var parent := node.get_parent() for sibling in parent.get_children(): if sibling != node and String(sibling.name) == new_name: return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, "A sibling already has the name '%s'" % new_name) _undo_redo.create_action("MCP: Rename %s to %s" % [old_name, new_name]) _undo_redo.add_do_property(node, "name", new_name) _undo_redo.add_undo_property(node, "name", old_name) _undo_redo.commit_action() return { "data": { "path": McpScenePath.from_node(node, scene_root), "old_path": node_path, "name": String(node.name), "old_name": old_name, "undoable": true, } } func duplicate_node(params: Dictionary) -> Dictionary: var resolved := _resolve_node(params) if resolved.has("error"): return resolved var node: Node = resolved.node var node_path: String = resolved.path var scene_root: Node = resolved.scene_root var root_err := _reject_if_scene_root(node, scene_root, "duplicate") if root_err != null: return root_err var parent := node.get_parent() var dup: Node = node.duplicate() if dup == null: return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Failed to duplicate node") # Apply optional name var new_name: String = params.get("name", "") if not new_name.is_empty(): dup.name = new_name _undo_redo.create_action("MCP: Duplicate %s" % node.name) _undo_redo.add_do_method(parent, "add_child", dup, true) _undo_redo.add_do_method(dup, "set_owner", scene_root) _undo_redo.add_do_reference(dup) _undo_redo.add_undo_method(parent, "remove_child", dup) _undo_redo.commit_action() # Set owner for all descendants of the duplicate _set_owner_recursive(dup, scene_root) return { "data": { "path": McpScenePath.from_node(dup, scene_root), "original_path": node_path, "name": dup.name, "type": dup.get_class(), "undoable": true, } } func move_node(params: Dictionary) -> Dictionary: var resolved := _resolve_node(params) if resolved.has("error"): return resolved var node: Node = resolved.node var node_path: String = resolved.path var scene_root: Node = resolved.scene_root var root_err := _reject_if_scene_root(node, scene_root, "reorder") if root_err != null: return root_err if not "index" in params: return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: index") var new_index: int = params.get("index", 0) var parent := node.get_parent() var old_index := node.get_index() var sibling_count := parent.get_child_count() if new_index < 0 or new_index >= sibling_count: return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Index %d out of range (0..%d)" % [new_index, sibling_count - 1]) _undo_redo.create_action("MCP: Move %s to index %d" % [node.name, new_index]) _undo_redo.add_do_method(parent, "move_child", node, new_index) _undo_redo.add_undo_method(parent, "move_child", node, old_index) _undo_redo.commit_action() return { "data": { "path": node_path, "old_index": old_index, "new_index": new_index, "undoable": true, } } func add_to_group(params: Dictionary) -> Dictionary: var resolved := _resolve_node(params) if resolved.has("error"): return resolved var node: Node = resolved.node var node_path: String = resolved.path var group_value: Variant = params.get("group", "") var type_err := McpParamValidators.require_string("group", group_value) if type_err != null: return type_err var group := String(group_value) if group.is_empty(): return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: group") if node.is_in_group(group): return {"data": {"path": node_path, "group": group, "already_member": true, "undoable": false, "reason": "No change made"}} _undo_redo.create_action("MCP: Add %s to group %s" % [node.name, group]) _undo_redo.add_do_method(node, "add_to_group", group, true) _undo_redo.add_undo_method(node, "remove_from_group", group) _undo_redo.commit_action() return { "data": { "path": node_path, "group": group, "undoable": true, } } func remove_from_group(params: Dictionary) -> Dictionary: var resolved := _resolve_node(params) if resolved.has("error"): return resolved var node: Node = resolved.node var node_path: String = resolved.path var group_value: Variant = params.get("group", "") var type_err := McpParamValidators.require_string("group", group_value) if type_err != null: return type_err var group := String(group_value) if group.is_empty(): return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: group") if not node.is_in_group(group): return {"data": {"path": node_path, "group": group, "not_member": true, "undoable": false, "reason": "Node not in group"}} _undo_redo.create_action("MCP: Remove %s from group %s" % [node.name, group]) _undo_redo.add_do_method(node, "remove_from_group", group) _undo_redo.add_undo_method(node, "add_to_group", group, true) _undo_redo.commit_action() return { "data": { "path": node_path, "group": group, "undoable": true, } } func set_selection(params: Dictionary) -> Dictionary: var paths: Array = params.get("paths", []) var _scene_check := McpNodeValidator.require_scene_or_error() if _scene_check.has("error"): return _scene_check var scene_root: Node = _scene_check.scene_root var selection := EditorInterface.get_selection() selection.clear() var selected: Array[String] = [] var not_found: Array[String] = [] for path_variant in paths: var path: String = str(path_variant) var node := McpScenePath.resolve(path, scene_root) if node: selection.add_node(node) selected.append(path) else: not_found.append(path) return { "data": { "selected": selected, "not_found": not_found, "count": selected.size(), "undoable": false, "reason": "Selection changes are not tracked in undo history", } } func _set_owner_recursive(node: Node, owner: Node) -> void: for child in node.get_children(): child.set_owner(owner) _set_owner_recursive(child, owner) ## Canonical dict-key sets for dict→Variant coercion. Alpha on `COLOR_KEYS` ## is optional — the coercer defaults it to 1.0 when absent. const VECTOR2_KEYS: Array[String] = ["x", "y"] const VECTOR3_KEYS: Array[String] = ["x", "y", "z"] const COLOR_KEYS: Array[String] = ["r", "g", "b"] ## End-to-end coerce check for compound JSON-shaped targets ## (Vector2/Vector3/Color). Returns a full `make(...)`-shaped error dict ## if `value` didn't land as the target Variant after `_coerce_value`, ## else null. Wrong-shape dicts get the `_check_dict_coerce_failed` ## message (expected-vs-got keys); non-dict inputs (Array, String, ## primitive) name the received type and a JSON shape hint. No-op for ## non-compound targets — Godot's setter handles those. ## ## Used by set_property, resource_handler, and validation handlers ## (curve, texture). Issue #191 — passing a list, JSON string, or ## anything else to a Vector3 property used to silently store ## Vector3.ZERO; this gates that path. static func _check_coerced(value: Variant, target_type: int, prefix: String = "") -> Variant: var ok := false match target_type: TYPE_VECTOR2: ok = value is Vector2 TYPE_VECTOR3: ok = value is Vector3 TYPE_COLOR: ok = value is Color TYPE_PACKED_VECTOR2_ARRAY: ok = value is PackedVector2Array TYPE_PACKED_VECTOR3_ARRAY: ok = value is PackedVector3Array TYPE_PACKED_COLOR_ARRAY: ok = value is PackedColorArray TYPE_PACKED_INT32_ARRAY: ok = value is PackedInt32Array TYPE_PACKED_INT64_ARRAY: ok = value is PackedInt64Array TYPE_PACKED_FLOAT32_ARRAY: ok = value is PackedFloat32Array TYPE_PACKED_FLOAT64_ARRAY: ok = value is PackedFloat64Array TYPE_PACKED_STRING_ARRAY: ok = value is PackedStringArray _: return null if ok: return null var dict_err := _check_dict_coerce_failed(value, target_type) if dict_err != null: return ErrorCodes.prefix_message(dict_err, prefix) ## Wording stays neutral on shape — `_shape_hint` already produces a ## dict-shaped string for Vector2/3/Color and a list-shaped one for ## the Packed*Array slots. The old "expected a dict like [...]" phrasing ## read self-contradictory for packed targets (PR #424 review). var err := ErrorCodes.make( ErrorCodes.WRONG_TYPE, "Cannot coerce %s to %s; expected %s" % [ type_string(typeof(value)), type_string(target_type), _shape_hint(target_type), ], ) return ErrorCodes.prefix_message(err, prefix) ## Build a "{\"x\":1,...}" hint string from the canonical key constants ## so adding a key (e.g. Vector4) only touches VECTORN_KEYS. Packed*Array ## targets short-circuit to a literal list-shaped hint. static func _shape_hint(target_type: int) -> String: match target_type: TYPE_PACKED_VECTOR2_ARRAY: return "[{\"x\":0,\"y\":0}, ...]" TYPE_PACKED_VECTOR3_ARRAY: return "[{\"x\":0,\"y\":0,\"z\":0}, ...]" TYPE_PACKED_COLOR_ARRAY: return "[{\"r\":0,\"g\":0,\"b\":0,\"a\":1}, ...]" TYPE_PACKED_INT32_ARRAY, TYPE_PACKED_INT64_ARRAY: return "[int, ...]" TYPE_PACKED_FLOAT32_ARRAY, TYPE_PACKED_FLOAT64_ARRAY: return "[float, ...]" TYPE_PACKED_STRING_ARRAY: return "[\"...\", ...]" var keys: Array[String] = [] match target_type: TYPE_VECTOR2: keys = VECTOR2_KEYS TYPE_VECTOR3: keys = VECTOR3_KEYS TYPE_COLOR: keys = COLOR_KEYS var pairs: Array[String] = [] for k in keys: pairs.append("\"%s\":0" % k) return "{" + ",".join(pairs) + "}" ## Detect a failed dict→typed-Variant coercion. Returns an INVALID_PARAMS ## error dict if `value` is still a Dictionary after a coercion attempt ## targeting a Vector2/Vector3/Color slot, else null. Message names the ## expected keys and the keys actually received so agents self-correct ## on the next retry. static func _check_dict_coerce_failed(value: Variant, target_type: int) -> Variant: if not (value is Dictionary): return null var expected: Array[String] = [] var type_name := "" match target_type: TYPE_VECTOR2: expected = VECTOR2_KEYS type_name = "Vector2" TYPE_VECTOR3: expected = VECTOR3_KEYS type_name = "Vector3" TYPE_COLOR: expected = COLOR_KEYS type_name = "Color" _: return null var got_keys: Array = (value as Dictionary).keys() return ErrorCodes.make( ErrorCodes.WRONG_TYPE, "Cannot coerce dict to %s: expected keys %s; got %s" % [type_name, str(expected), str(got_keys)] ) ## Coerce JSON-shaped values into Godot Variants when the target property ## type is known. Returns the coerced value on success, or the input ## unchanged on failure — callers detect the type mismatch via an ## `is ` check (curve_handler, texture_handler) or via the ## `_check_dict_coerce_failed` helper (set_property, resource_handler). ## ## Dictionary→Vector2/Vector3/Color cases REQUIRE all canonical keys; ## wrong-shape dicts flow through unchanged. See issue #123 — previous ## `dict.get(key, 0)` defaults silently zero-filled missing axes. static func _coerce_value(value: Variant, target_type: int) -> Variant: match target_type: TYPE_VECTOR2: if value is Dictionary and value.has_all(VECTOR2_KEYS): return Vector2(value["x"], value["y"]) TYPE_VECTOR3: if value is Dictionary and value.has_all(VECTOR3_KEYS): return Vector3(value["x"], value["y"], value["z"]) TYPE_COLOR: if value is Dictionary and value.has_all(COLOR_KEYS): return Color(value["r"], value["g"], value["b"], value.get("a", 1.0)) if value is String: return Color(value) TYPE_BOOL: if value is float or value is int: return bool(value) TYPE_INT: if value is float: return int(value) TYPE_FLOAT: if value is int: return float(value) TYPE_STRING_NAME: if value is String: return StringName(value) TYPE_NODE_PATH: if value is String: return NodePath(value) if value == null: return NodePath() TYPE_OBJECT: # Resource loading is handled in set_property so we can return a # typed error; here we only pass through cleared values. if value == null: return null TYPE_ARRAY: if value is Array: return value TYPE_DICTIONARY: if value is Dictionary: return value TYPE_PACKED_VECTOR2_ARRAY: if value is Array: var out := PackedVector2Array() for item in value: if item is Vector2: out.append(item) elif item is Dictionary and item.has_all(VECTOR2_KEYS): out.append(Vector2(item["x"], item["y"])) else: return value # leave for _check_coerced to flag return out TYPE_PACKED_VECTOR3_ARRAY: if value is Array: var out := PackedVector3Array() for item in value: if item is Vector3: out.append(item) elif item is Dictionary and item.has_all(VECTOR3_KEYS): out.append(Vector3(item["x"], item["y"], item["z"])) else: return value return out TYPE_PACKED_COLOR_ARRAY: if value is Array: var out := PackedColorArray() for item in value: if item is Color: out.append(item) elif item is Dictionary and item.has_all(COLOR_KEYS): out.append(Color(item["r"], item["g"], item["b"], item.get("a", 1.0))) elif item is String: out.append(Color(item)) else: return value return out TYPE_PACKED_INT32_ARRAY, TYPE_PACKED_INT64_ARRAY: if value is Array: var out: Variant = PackedInt32Array() if target_type == TYPE_PACKED_INT32_ARRAY else PackedInt64Array() for item in value: if item is int or item is float: out.append(int(item)) else: return value return out TYPE_PACKED_FLOAT32_ARRAY, TYPE_PACKED_FLOAT64_ARRAY: if value is Array: var out: Variant = PackedFloat32Array() if target_type == TYPE_PACKED_FLOAT32_ARRAY else PackedFloat64Array() for item in value: if item is float or item is int: out.append(float(item)) else: return value return out TYPE_PACKED_STRING_ARRAY: if value is Array: var out := PackedStringArray() for item in value: if item is String: out.append(item) else: return value return out # PackedByteArray intentionally unhandled — needs design decision # (base64 string vs. raw int list); JSON has no native byte type. return value func get_node_properties(params: Dictionary) -> Dictionary: var resolved := _resolve_node(params) if resolved.has("error"): return resolved var node: Node = resolved.node var node_path: String = resolved.path var scene_root: Node = resolved.scene_root var properties: Array[Dictionary] = [] for prop in node.get_property_list(): var usage: int = prop.get("usage", 0) if not (usage & PROPERTY_USAGE_EDITOR): continue # Safe read: custom script getters can error; skip bad properties # rather than letting one bad read timeout the entire request. var value = node.get(prop.name) if value == null and prop.type != TYPE_NIL: continue properties.append({ "name": prop.name, "type": type_string(prop.type), "value": _serialize_value(value), }) return { "data": { "path": node_path, "node_type": node.get_class(), "properties": properties, "count": properties.size(), } } func get_children(params: Dictionary) -> Dictionary: var resolved := _resolve_node(params) if resolved.has("error"): return resolved var node: Node = resolved.node var node_path: String = resolved.path var scene_root: Node = resolved.scene_root var children: Array[Dictionary] = [] for child in node.get_children(): children.append({ "name": child.name, "type": child.get_class(), "path": McpScenePath.from_node(child, scene_root), "children_count": child.get_child_count(), }) return { "data": { "parent_path": node_path, "children": children, "count": children.size(), } } func get_groups(params: Dictionary) -> Dictionary: var resolved := _resolve_node(params) if resolved.has("error"): return resolved var node: Node = resolved.node var node_path: String = resolved.path var groups: Array[String] = [] for group in node.get_groups(): # Skip internal groups (start with underscore) if not str(group).begins_with("_"): groups.append(str(group)) return { "data": { "path": node_path, "groups": groups, "count": groups.size(), } } ## Validate path param, resolve to node. Returns dict with node/path/scene_root ## on success, or an error dict (has "error" key) on failure. Thin wrapper ## around the shared `McpNodeValidator.resolve_or_error` helper (audit-v2 #20). func _resolve_node(params: Dictionary) -> Dictionary: return McpNodeValidator.resolve_or_error( params.get("path", ""), "path", params.get("scene_file", ""), ) ## Reject operations targeting the scene root. Returns an INVALID_PARAMS error ## dict with "Cannot the scene root", or null if `node` is not the root. static func _reject_if_scene_root(node: Node, scene_root: Node, op: String) -> Variant: if node == scene_root: return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, "Cannot %s the scene root" % op) return null ## Convert a Godot Variant to a JSON-safe value. Compound geometry types ## (AABB, Rect2, Transforms, …) and packed arrays serialize as structured ## dicts/arrays so agents can inspect fields instead of parsing Godot's ## debug repr — see issue #214. static func _serialize_value(value: Variant) -> Variant: return VariantSerializer.serialize(value)