Files
g-world/addons/godot_ai/handlers/node_handler.gd
T
2026-08-10 20:39:06 +03:00

1391 lines
51 KiB
GDScript
Vendored
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
@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 made := ResourceHandler._instantiate_resource(type_str)
if made is Dictionary:
return made
var res: Resource = made
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
elif target_type == TYPE_ARRAY and old_value is Array and (old_value as Array).is_typed():
## Typed Array[T] slot (#612): the generic TYPE_ARRAY passthrough
## hands an untyped Array to Godot's typed setter, which rejects it
## wholesale and leaves the slot at its default — with success still
## reported. Route through the element-aware coercer instead; errors
## name the offending element index.
var typed_out: Variant = _coerce_typed_array(value, old_value)
if typed_out is Dictionary:
return typed_out
value = typed_out
elif (
target_type == TYPE_DICTIONARY
and old_value is Dictionary
and (old_value as Dictionary).is_typed()
):
## Typed Dictionary[K, V] slot (#612 stage 3) — same silent-drop
## family as typed arrays. A successful result is always a TYPED
## Dictionary (a cleared duplicate of the slot), while the error
## envelope is an untyped {"error": ...} — that's the discriminator
## (a legit payload could contain an "error" key; typedness can't lie).
var typed_dict_out: Dictionary = _coerce_typed_dictionary(value, old_value)
if not typed_dict_out.is_typed():
return typed_dict_out
value = typed_dict_out
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 VECTOR4_KEYS: Array[String] = ["x", "y", "z", "w"]
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_VECTOR4_ARRAY:
ok = value is PackedVector4Array
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
TYPE_VECTOR2I: ok = value is Vector2i
TYPE_VECTOR3I: ok = value is Vector3i
TYPE_VECTOR4: ok = value is Vector4
TYPE_VECTOR4I: ok = value is Vector4i
TYPE_QUATERNION: ok = value is Quaternion
TYPE_RECT2: ok = value is Rect2
TYPE_RECT2I: ok = value is Rect2i
TYPE_AABB: ok = value is AABB
TYPE_PLANE: ok = value is Plane
TYPE_BASIS: ok = value is Basis
TYPE_TRANSFORM2D: ok = value is Transform2D
TYPE_TRANSFORM3D: ok = value is Transform3D
TYPE_PROJECTION: ok = value is Projection
_:
# null / untyped-TYPE_NIL / already-correct-type are handled by
# Godot's setter; anything else would silently no-op, so error.
if value == null or target_type == TYPE_NIL or typeof(value) == target_type:
return null
var unsupported := ErrorCodes.make(
ErrorCodes.WRONG_TYPE,
"Cannot write %s to a %s property; godot-ai has no coercion for that type" % [
type_string(typeof(value)), type_string(target_type),
],
)
return ErrorCodes.prefix_message(unsupported, prefix)
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_VECTOR4_ARRAY:
return "[{\"x\":0,\"y\":0,\"z\":0,\"w\":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 "[\"...\", ...]"
TYPE_VECTOR2I:
return "{\"x\":0,\"y\":0}"
TYPE_VECTOR3I:
return "{\"x\":0,\"y\":0,\"z\":0}"
TYPE_VECTOR4, TYPE_VECTOR4I, TYPE_QUATERNION:
return "{\"x\":0,\"y\":0,\"z\":0,\"w\":0}"
TYPE_RECT2, TYPE_RECT2I, TYPE_AABB:
return "{\"position\":{...},\"size\":{...}}"
TYPE_PLANE:
return "{\"normal\":{...},\"d\":0}"
TYPE_BASIS:
return "{\"x\":{...},\"y\":{...},\"z\":{...}}"
TYPE_TRANSFORM2D:
return "{\"x\":{...},\"y\":{...},\"origin\":{...}}"
TYPE_TRANSFORM3D:
return "{\"basis\":{...},\"origin\":{...}}"
TYPE_PROJECTION:
return "{\"x\":{...},\"y\":{...},\"z\":{...},\"w\":{...}}"
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:
## Vector2/Vector3/Color route through the canonical strict parser
## (#714): same dict/array/string shapes as every other handler, and
## non-numeric components fall through (returning the original
## value) so _check_coerced flags them instead of crashing a typed
## constructor or silently writing black/zeros.
TYPE_VECTOR2:
var v2 = McpJsonValues.parse_vector2(value)
if v2 != null:
return v2
TYPE_VECTOR3:
var v3 = McpJsonValues.parse_vector3(value)
if v3 != null:
return v3
TYPE_COLOR:
var col = McpJsonValues.parse_color(value)
if col != null:
return col
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_VECTOR4_ARRAY:
if value is Array:
var out := PackedVector4Array()
for item in value:
if item is Vector4:
out.append(item)
elif item is Dictionary and item.has_all(VECTOR4_KEYS):
out.append(Vector4(item["x"], item["y"], item["z"], item["w"]))
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
TYPE_VECTOR2I:
if value is Dictionary and value.has_all(VECTOR2_KEYS):
return Vector2i(int(value["x"]), int(value["y"]))
TYPE_VECTOR3I:
if value is Dictionary and value.has_all(VECTOR3_KEYS):
return Vector3i(int(value["x"]), int(value["y"]), int(value["z"]))
TYPE_VECTOR4:
if value is Dictionary and value.has_all(VECTOR4_KEYS):
return Vector4(value["x"], value["y"], value["z"], value["w"])
TYPE_VECTOR4I:
if value is Dictionary and value.has_all(VECTOR4_KEYS):
return Vector4i(int(value["x"]), int(value["y"]), int(value["z"]), int(value["w"]))
TYPE_QUATERNION:
if value is Dictionary and value.has_all(VECTOR4_KEYS):
return Quaternion(value["x"], value["y"], value["z"], value["w"])
TYPE_RECT2:
if value is Dictionary and value.has("position") and value.has("size"):
var p: Variant = _coerce_value(value["position"], TYPE_VECTOR2)
var s: Variant = _coerce_value(value["size"], TYPE_VECTOR2)
if p is Vector2 and s is Vector2:
return Rect2(p, s)
TYPE_RECT2I:
if value is Dictionary and value.has("position") and value.has("size"):
var p: Variant = _coerce_value(value["position"], TYPE_VECTOR2I)
var s: Variant = _coerce_value(value["size"], TYPE_VECTOR2I)
if p is Vector2i and s is Vector2i:
return Rect2i(p, s)
TYPE_AABB:
if value is Dictionary and value.has("position") and value.has("size"):
var p: Variant = _coerce_value(value["position"], TYPE_VECTOR3)
var s: Variant = _coerce_value(value["size"], TYPE_VECTOR3)
if p is Vector3 and s is Vector3:
return AABB(p, s)
TYPE_PLANE:
if value is Dictionary and value.has("normal") and value.has("d"):
var n: Variant = _coerce_value(value["normal"], TYPE_VECTOR3)
if n is Vector3:
return Plane(n, float(value["d"]))
TYPE_BASIS:
if value is Dictionary and value.has_all(["x", "y", "z"]):
var bx: Variant = _coerce_value(value["x"], TYPE_VECTOR3)
var by: Variant = _coerce_value(value["y"], TYPE_VECTOR3)
var bz: Variant = _coerce_value(value["z"], TYPE_VECTOR3)
if bx is Vector3 and by is Vector3 and bz is Vector3:
return Basis(bx, by, bz)
TYPE_TRANSFORM2D:
if value is Dictionary and value.has_all(["x", "y", "origin"]):
var tx: Variant = _coerce_value(value["x"], TYPE_VECTOR2)
var ty: Variant = _coerce_value(value["y"], TYPE_VECTOR2)
var to_: Variant = _coerce_value(value["origin"], TYPE_VECTOR2)
if tx is Vector2 and ty is Vector2 and to_ is Vector2:
return Transform2D(tx, ty, to_)
TYPE_TRANSFORM3D:
if value is Dictionary and value.has("basis") and value.has("origin"):
var b: Variant = _coerce_value(value["basis"], TYPE_BASIS)
var o: Variant = _coerce_value(value["origin"], TYPE_VECTOR3)
if b is Basis and o is Vector3:
return Transform3D(b, o)
TYPE_PROJECTION:
if value is Dictionary and value.has_all(VECTOR4_KEYS):
var px: Variant = _coerce_value(value["x"], TYPE_VECTOR4)
var py: Variant = _coerce_value(value["y"], TYPE_VECTOR4)
var pz: Variant = _coerce_value(value["z"], TYPE_VECTOR4)
var pw: Variant = _coerce_value(value["w"], TYPE_VECTOR4)
if px is Vector4 and py is Vector4 and pz is Vector4 and pw is Vector4:
return Projection(px, py, pz, pw)
# PackedByteArray intentionally unhandled — needs design decision
# (base64 string vs. raw int list); JSON has no native byte type.
return value
## Fill a typed `Array[T]` slot from a JSON list (#612 stage 1: value-element
## types). `slot_value` is the property's current typed Array — Godot's getter
## returns the (possibly empty) typed container, which carries the element
## type, so no PROPERTY_HINT_TYPE_STRING parsing is needed. Elements coerce
## one at a time through the existing `_coerce_value` / `_check_coerced`
## pair, then bulk-move via `Array.assign()` with a post-assign size check,
## so a wrong element can never silently drop the write: it errors naming
## the element index. Returns the filled typed Array on success, or a
## `make(...)`-shaped error Dictionary (callers discriminate on
## `result is Dictionary` — a successful result is always an Array).
##
## Object elements (Array[Texture2D], Array[MyResource], ...) coerce per
## element through `_coerce_object_element` (#612 stage 2), mirroring the
## single-slot TYPE_OBJECT paths: res:// strings load, {"__class__": ...}
## instantiates, and each landed element is conformance-checked against the
## slot's element class/script so a wrong-class Resource errors naming the
## index instead of being rejected wholesale by `assign`.
static func _coerce_typed_array(value: Variant, slot_value: Array, prefix: String = "") -> Variant:
var elem_label := _typed_array_element_label(slot_value)
if not (value is Array):
var err := ErrorCodes.make(
ErrorCodes.WRONG_TYPE,
"Cannot write %s to a typed Array[%s] property; expected a list" % [
type_string(typeof(value)), elem_label,
],
)
return ErrorCodes.prefix_message(err, prefix)
var elem_type := slot_value.get_typed_builtin()
var staging: Array = []
var in_list: Array = value
for i in in_list.size():
var elem_prefix := ("element %d" % i) if prefix.is_empty() else "%s element %d" % [prefix, i]
var coerced: Variant
if elem_type == TYPE_OBJECT:
coerced = _coerce_object_element(in_list[i], elem_prefix)
if coerced is Dictionary:
## Object elements are never legit Dictionaries (a dict input
## is either {"__class__"} — consumed above — or an error), so
## a Dictionary return is unambiguously the error envelope.
return coerced
if coerced != null and not _object_element_conforms(coerced, slot_value):
var conform_err := ErrorCodes.make(
ErrorCodes.WRONG_TYPE,
"element is %s, which is not a %s" % [
(coerced as Object).get_class(), elem_label,
],
)
return ErrorCodes.prefix_message(conform_err, elem_prefix)
else:
coerced = _coerce_value(in_list[i], elem_type)
var elem_err := _check_coerced(coerced, elem_type, elem_prefix)
if elem_err != null:
return elem_err
if coerced == null:
## Prefix with `elem_prefix` (which already folds in `prefix`),
## not `prefix` again — the latter double-stamped the property
## context (PR #682 review). Object arrays allow null entries
## (Godot typed object arrays store null); value-type arrays
## don't.
var null_err := ErrorCodes.make(
ErrorCodes.WRONG_TYPE,
"cannot store null in Array[%s]" % elem_label,
)
return ErrorCodes.prefix_message(null_err, elem_prefix)
staging.append(coerced)
var out := slot_value.duplicate()
out.clear()
out.assign(staging)
if out.size() != staging.size():
## Backstop for element shapes `_check_coerced` waves through but the
## typed container still rejects — `assign` loud-rejects and leaves a
## short array, which without this check would be a partial write.
var assign_err := ErrorCodes.make(
ErrorCodes.WRONG_TYPE,
"Array[%s] element conversion failed during assign (%d of %d elements landed)" % [
elem_label, out.size(), staging.size(),
],
)
return ErrorCodes.prefix_message(assign_err, prefix)
return out
## "int" / "Vector3" / "Texture2D" / "MyItemData" — element-type name of a
## typed Array slot, for error messages.
static func _typed_array_element_label(slot_value: Array) -> String:
if slot_value.get_typed_builtin() == TYPE_OBJECT:
return _object_type_label(slot_value.get_typed_class_name(), slot_value.get_typed_script())
return type_string(slot_value.get_typed_builtin())
## Coerce one element of an object-typed Array (#612 stage 2). Mirrors the
## single-slot TYPE_OBJECT paths in set_property: a res:// path string loads
## the Resource; {"__class__": "X", ...} (including the #206 stringified
## form) instantiates via ResourceHandler and applies the remaining keys;
## "" / null store a null entry (typed object arrays allow them). Returns
## the Object (or null), or a make(...)-shaped error Dictionary with
## `elem_prefix` already folded in.
static func _coerce_object_element(elem: Variant, elem_prefix: String) -> Variant:
if elem == null:
return null
if elem is Object:
return elem
if elem is String and (elem as String).begins_with("{"):
var json := JSON.new()
if json.parse(elem) == OK and json.data is Dictionary and (json.data as Dictionary).has("__class__"):
elem = json.data
if elem is String:
if String(elem).is_empty():
return null
var path_err = McpPathValidator.loadable_error(elem, "value")
if path_err != null:
return ErrorCodes.prefix_message(path_err, elem_prefix)
if not ResourceLoader.exists(elem):
return ErrorCodes.prefix_message(
ErrorCodes.make(ErrorCodes.RESOURCE_NOT_FOUND, "Resource not found: %s" % elem),
elem_prefix,
)
var loaded := ResourceLoader.load(elem)
if loaded == null:
return ErrorCodes.prefix_message(
ErrorCodes.make(ErrorCodes.RESOURCE_NOT_FOUND, "Resource not found: %s" % elem),
elem_prefix,
)
return loaded
if elem is Dictionary and (elem as Dictionary).has("__class__"):
var type_str: String = (elem as Dictionary).get("__class__", "")
var made := ResourceHandler._instantiate_resource(type_str)
if made is Dictionary:
return ErrorCodes.prefix_message(made, elem_prefix)
var res: Resource = made
var remaining: Dictionary = (elem as Dictionary).duplicate()
remaining.erase("__class__")
if not remaining.is_empty():
var apply_err: Variant = ResourceHandler._apply_resource_properties(res, remaining)
if apply_err != null:
return ErrorCodes.prefix_message(apply_err, elem_prefix)
return res
return ErrorCodes.prefix_message(
ErrorCodes.make(
ErrorCodes.WRONG_TYPE,
'cannot convert %s to an Object element; pass a res:// path or {"__class__": ...}'
% type_string(typeof(elem)),
),
elem_prefix,
)
## True when `elem` satisfies the object-typed slot's element constraint —
## script type when the slot is Array[MyScriptClass], native class
## otherwise. Checked per element so a wrong-class Resource errors naming
## the index instead of being rejected wholesale by `Array.assign()`.
static func _object_element_conforms(elem: Object, slot_value: Array) -> bool:
return _object_conforms(elem, slot_value.get_typed_class_name(), slot_value.get_typed_script())
## Shared class/script conformance predicate for typed Array elements and
## typed Dictionary values (#612 stages 23).
static func _object_conforms(elem: Object, cls_name: StringName, script: Variant) -> bool:
if script is Script:
return is_instance_of(elem, script)
var cls := String(cls_name)
return cls.is_empty() or elem.is_class(cls)
## Fill a typed `Dictionary[K, V]` slot from a JSON object (#612 stage 3).
## `slot_value` is the property's current typed Dictionary — the getter
## returns the (possibly empty) typed container carrying both constraint
## sides. Keys coerce via `_coerce_typed_dict_key` (JSON object keys are
## always Strings, so int/float/StringName key slots parse the string and
## fail closed on anything inexact); values mirror the typed-array element
## rules — object values through `_coerce_object_element` + conformance,
## everything else through `_coerce_value`/`_check_coerced`. Never partial:
## any bad key or value errors naming the key and nothing is written.
##
## Returns the filled TYPED Dictionary on success or an UNTYPED
## `make(...)`-shaped error Dictionary — callers discriminate on
## `is_typed()`, since a success result is always a duplicate of the typed
## slot and error envelopes are plain dicts.
static func _coerce_typed_dictionary(
value: Variant, slot_value: Dictionary, prefix: String = ""
) -> Dictionary:
var label := _typed_dictionary_label(slot_value)
if not (value is Dictionary):
var err := ErrorCodes.make(
ErrorCodes.WRONG_TYPE,
"Cannot write %s to a typed %s property; expected an object" % [
type_string(typeof(value)), label,
],
)
return ErrorCodes.prefix_message(err, prefix)
var key_type := slot_value.get_typed_key_builtin() if slot_value.is_typed_key() else TYPE_NIL
var value_type := (
slot_value.get_typed_value_builtin() if slot_value.is_typed_value() else TYPE_NIL
)
var out := slot_value.duplicate()
out.clear()
var in_dict: Dictionary = value
for raw_key in in_dict.keys():
var key_prefix := (
'key "%s"' % str(raw_key) if prefix.is_empty()
else '%s key "%s"' % [prefix, str(raw_key)]
)
var key: Variant = _coerce_typed_dict_key(raw_key, key_type, label, key_prefix)
if key is Dictionary:
## Scalar-only key coercion never returns a legit Dictionary key,
## so a Dictionary here is unambiguously the error envelope.
return key
var raw_value: Variant = in_dict[raw_key]
var coerced: Variant
if value_type == TYPE_NIL:
## Untyped value side (e.g. Dictionary[String, Variant]).
coerced = raw_value
elif value_type == TYPE_OBJECT:
coerced = _coerce_object_element(raw_value, key_prefix)
if coerced is Dictionary:
return coerced
if coerced != null and not _object_conforms(
coerced,
slot_value.get_typed_value_class_name(),
slot_value.get_typed_value_script(),
):
var conform_err := ErrorCodes.make(
ErrorCodes.WRONG_TYPE,
"value is %s, which is not a %s" % [
(coerced as Object).get_class(),
_object_type_label(
slot_value.get_typed_value_class_name(),
slot_value.get_typed_value_script(),
),
],
)
return ErrorCodes.prefix_message(conform_err, key_prefix)
else:
coerced = _coerce_value(raw_value, value_type)
var value_err := _check_coerced(coerced, value_type, key_prefix)
if value_err != null:
return value_err
if coerced == null:
var null_err := ErrorCodes.make(
ErrorCodes.WRONG_TYPE,
"cannot store null as a %s value in %s" % [type_string(value_type), label],
)
return ErrorCodes.prefix_message(null_err, key_prefix)
out[key] = coerced
if out.size() != in_dict.size():
## Two input keys collapsing onto one coerced key ("1" and "01" both
## parse to int 1) would silently lose an entry — refuse instead.
var collide_err := ErrorCodes.make(
ErrorCodes.WRONG_TYPE,
"%s keys collide after coercion (%d of %d entries landed)" % [
label, out.size(), in_dict.size(),
],
)
return ErrorCodes.prefix_message(collide_err, prefix)
return out
## Coerce one JSON-object key onto a typed Dictionary's key slot. JSON keys
## are always Strings, so int/float/StringName key types accept exactly the
## strings that parse cleanly; everything else fails closed naming the key.
## Object/compound key types are unreachable from JSON and refuse loudly.
static func _coerce_typed_dict_key(
raw_key: Variant, key_type: int, label: String, key_prefix: String
) -> Variant:
if key_type == TYPE_NIL or typeof(raw_key) == key_type:
return raw_key
if raw_key is String:
var key_str := raw_key as String
match key_type:
TYPE_STRING_NAME:
return StringName(key_str)
TYPE_INT:
if key_str.is_valid_int():
return int(key_str)
TYPE_FLOAT:
if key_str.is_valid_float():
return float(key_str)
## String key that didn't parse: JSON object keys are ALWAYS strings,
## so blaming the String-ness would imply the caller could somehow
## send a non-string key — name the expected key type instead.
var parse_err := ErrorCodes.make(
ErrorCodes.WRONG_TYPE,
"key does not parse as %s (the %s key type)" % [type_string(key_type), label],
)
return ErrorCodes.prefix_message(parse_err, key_prefix)
if raw_key is float and key_type == TYPE_INT and is_equal_approx(raw_key, roundf(raw_key)):
## Whole JSON numbers arrive as floats through some non-JSON callers.
return int(raw_key)
var err := ErrorCodes.make(
ErrorCodes.WRONG_TYPE,
"cannot use %s as a %s key" % [type_string(typeof(raw_key)), label],
)
return ErrorCodes.prefix_message(err, key_prefix)
## "Dictionary[String, int]" / "Dictionary[int, Texture2D]" — for error
## messages. Untyped sides read as Variant.
static func _typed_dictionary_label(slot_value: Dictionary) -> String:
var key_label := "Variant"
if slot_value.is_typed_key():
key_label = (
_object_type_label(
slot_value.get_typed_key_class_name(), slot_value.get_typed_key_script()
)
if slot_value.get_typed_key_builtin() == TYPE_OBJECT
else type_string(slot_value.get_typed_key_builtin())
)
var value_label := "Variant"
if slot_value.is_typed_value():
value_label = (
_object_type_label(
slot_value.get_typed_value_class_name(), slot_value.get_typed_value_script()
)
if slot_value.get_typed_value_builtin() == TYPE_OBJECT
else type_string(slot_value.get_typed_value_builtin())
)
return "Dictionary[%s, %s]" % [key_label, value_label]
## Class/script display name for an object-typed constraint side.
static func _object_type_label(cls_name: StringName, script: Variant) -> String:
var cls := String(cls_name)
if script is Script and not String((script as Script).get_global_name()).is_empty():
cls = String((script as Script).get_global_name())
return cls if not cls.is_empty() else "Object"
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
# Optional token-reducing filter: `fields` restricts the response to a
# named subset. Defaults off (empty), so existing callers see the full
# dump unchanged. The MCP tool types this as a list, but batch_execute and
# raw callers bypass that, so validate the shape here before iterating.
var fields_param: Variant = params.get("fields", [])
if fields_param == null:
fields_param = []
if not (fields_param is Array):
return ErrorCodes.make(
ErrorCodes.INVALID_PARAMS,
"'fields' must be an array of property names, got %s (%s)" % [
type_string(typeof(fields_param)), str(fields_param),
],
)
var field_filter := {}
for f in fields_param:
## Property names are strings on the wire; anything else is a
## malformed filter (e.g. [123] or [["fov"]]) — reject rather than
## silently stringify into a filter that matches nothing (#123/#126:
## strict within the accepted shape). StringName is allowed for
## editor-side callers.
if not (f is String or f is StringName):
return ErrorCodes.make(
ErrorCodes.INVALID_PARAMS,
"'fields' elements must be property-name strings, got %s in %s" % [
type_string(typeof(f)), str(fields_param),
],
)
field_filter[str(f)] = true
var use_field_filter := not field_filter.is_empty()
var properties: Array[Dictionary] = []
var editor_property_count := 0
var matched_fields := {}
for prop in node.get_property_list():
var usage: int = prop.get("usage", 0)
if not (usage & PROPERTY_USAGE_EDITOR):
continue
editor_property_count += 1
if use_field_filter:
if not field_filter.has(prop.name):
continue
matched_fields[prop.name] = true
# Null reads are values, not omissions: `script` on an unscripted node
# and unset Resource slots (mesh, material, …) read back null and must
# appear as "value": null with their declared type, so callers can tell
# "Object-typed, currently unset" from "doesn't exist" (#771).
properties.append({
"name": prop.name,
"type": type_string(prop.type),
"value": _serialize_value(node.get(prop.name)),
})
# Requested names that matched no editor-visible property — distinguishes
# "you asked for something that doesn't exist" from "exists and is null".
var unknown_fields: Array[String] = []
for f in field_filter:
if not matched_fields.has(f):
unknown_fields.append(f)
return {
"data": {
"path": node_path,
"node_type": node.get_class(),
"properties": properties,
"count": properties.size(),
# Total editor-visible properties before field filtering, so a
# caller that passed `fields` knows how many were withheld.
# Invariant: an unfiltered call returns every editor-visible
# property, so count == total_count; only the `fields` filter
# can make count < total_count.
"total_count": editor_property_count,
"unknown_fields": unknown_fields,
}
}
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)