Files

867 lines
29 KiB
GDScript

@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 `/<root>` (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 <Type>` 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 <op> 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)