@tool extends "res://addons/godot_mcp/commands/base_command.gd" const NodeUtils := preload("res://addons/godot_mcp/utils/node_utils.gd") const PropertyParser := preload("res://addons/godot_mcp/utils/property_parser.gd") func get_commands() -> Dictionary: return { "add_node": _add_node, "delete_node": _delete_node, "duplicate_node": _duplicate_node, "move_node": _move_node, "update_property": _update_property, "get_node_properties": _get_node_properties, "add_resource": _add_resource, "set_anchor_preset": _set_anchor_preset, "rename_node": _rename_node, "connect_signal": _connect_signal, "disconnect_signal": _disconnect_signal, "get_node_groups": _get_node_groups, "set_node_groups": _set_node_groups, "find_nodes_in_group": _find_nodes_in_group, } func _find_script_by_class_name(class_name_str: String) -> Script: # Search project files for a script with matching class_name var global_classes: Array = ProjectSettings.get_global_class_list() for entry: Dictionary in global_classes: if entry.get("class", "") == class_name_str: var path: String = entry.get("path", "") if not path.is_empty(): return load(path) as Script return null func _add_node(params: Dictionary) -> Dictionary: var result := require_string(params, "type") if result[1] != null: return result[1] var type: String = result[0] var parent_path: String = optional_string(params, "parent_path", ".") var node_name: String = optional_string(params, "name", "") var properties: Dictionary = params.get("properties", {}) var root := get_edited_root() if root == null: return error_no_scene() var parent := find_node_by_path(parent_path) if parent == null: return error_not_found("Parent node '%s'" % parent_path, "Use get_scene_tree to see available nodes") var node: Node var custom_script: Script = null if ClassDB.class_exists(type): node = ClassDB.instantiate(type) else: # Try to find a script with matching class_name custom_script = _find_script_by_class_name(type) if custom_script == null: return error_invalid_params("Unknown node type: '%s'. Not found in ClassDB or as a script class_name. Use list_scripts to see available script classes." % type) var base_type: String = custom_script.get_instance_base_type() if not ClassDB.class_exists(base_type): return error_invalid_params("Script '%s' extends '%s' which is not a valid node type" % [type, base_type]) node = ClassDB.instantiate(base_type) node.set_script(custom_script) if not node_name.is_empty(): node.name = node_name # Apply properties for prop_name: String in properties: var prop_exists := false for prop in node.get_property_list(): if prop["name"] == prop_name: prop_exists = true break if prop_exists: var current: Variant = node.get(prop_name) var target_type := typeof(current) node.set(prop_name, PropertyParser.parse_value(properties[prop_name], target_type)) var undo_redo := get_undo_redo() undo_redo.create_action("MCP: Add %s" % type) undo_redo.add_do_method(parent, "add_child", node) undo_redo.add_do_method(node, "set_owner", root) undo_redo.add_do_reference(node) undo_redo.add_undo_method(parent, "remove_child", node) undo_redo.commit_action() return success({ "node_path": str(root.get_path_to(node)), "type": type, "name": str(node.name), }) func _delete_node(params: Dictionary) -> Dictionary: var result := require_string(params, "node_path") if result[1] != null: return result[1] var node_path: String = result[0] var root := get_edited_root() if root == null: return error_no_scene() var node := find_node_by_path(node_path) if node == null: return error_not_found("Node '%s'" % node_path, "Use get_scene_tree to see available nodes") if node == root: return error_invalid_params("Cannot delete the root node") var parent := node.get_parent() var node_name := str(node.name) var undo_redo := get_undo_redo() 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) undo_redo.add_undo_method(node, "set_owner", root) undo_redo.add_undo_reference(node) undo_redo.commit_action() return success({"deleted": node_name}) func _duplicate_node(params: Dictionary) -> Dictionary: var result := require_string(params, "node_path") if result[1] != null: return result[1] var node_path: String = result[0] var new_name: String = optional_string(params, "name", "") var root := get_edited_root() if root == null: return error_no_scene() var node := find_node_by_path(node_path) if node == null: return error_not_found("Node '%s'" % node_path, "Use get_scene_tree to see available nodes") if new_name.is_empty(): new_name = str(node.name) + "_copy" var dup := node.duplicate() dup.name = new_name var parent := node.get_parent() var undo_redo := get_undo_redo() undo_redo.create_action("MCP: Duplicate %s" % node.name) undo_redo.add_do_method(parent, "add_child", dup) undo_redo.add_do_method(dup, "set_owner", root) undo_redo.add_do_reference(dup) undo_redo.add_undo_method(parent, "remove_child", dup) undo_redo.commit_action() NodeUtils.set_owner_recursive(dup, root) return success({ "original": str(root.get_path_to(node)), "duplicate": str(root.get_path_to(dup)), "name": str(dup.name), }) func _move_node(params: Dictionary) -> Dictionary: var result := require_string(params, "node_path") if result[1] != null: return result[1] var node_path: String = result[0] var result2 := require_string(params, "new_parent_path") if result2[1] != null: return result2[1] var new_parent_path: String = result2[0] var root := get_edited_root() if root == null: return error_no_scene() var node := find_node_by_path(node_path) if node == null: return error_not_found("Node '%s'" % node_path, "Use get_scene_tree to see available nodes") if node == root: return error_invalid_params("Cannot move the root node") var new_parent := find_node_by_path(new_parent_path) if new_parent == null: return error_not_found("Target parent '%s'" % new_parent_path, "Use get_scene_tree to see available nodes") # Check we're not moving a node into its own subtree if new_parent == node or node.is_ancestor_of(new_parent): return error_invalid_params("Cannot move a node into its own subtree") var old_parent := node.get_parent() var undo_redo := get_undo_redo() undo_redo.create_action("MCP: Move %s" % node.name) undo_redo.add_do_method(old_parent, "remove_child", node) undo_redo.add_do_method(new_parent, "add_child", node) undo_redo.add_do_method(node, "set_owner", root) undo_redo.add_undo_method(new_parent, "remove_child", node) undo_redo.add_undo_method(old_parent, "add_child", node) undo_redo.add_undo_method(node, "set_owner", root) undo_redo.commit_action() NodeUtils.set_owner_recursive(node, root) return success({ "node": str(node.name), "old_parent": str(root.get_path_to(old_parent)), "new_parent": str(root.get_path_to(new_parent)), "new_path": str(root.get_path_to(node)), }) func _update_property(params: Dictionary) -> Dictionary: var result := require_string(params, "node_path") if result[1] != null: return result[1] var node_path: String = result[0] var result2 := require_string(params, "property") if result2[1] != null: return result2[1] var property: String = result2[0] if not params.has("value"): return error_invalid_params("Missing required parameter: value") var value: Variant = params["value"] var root := get_edited_root() if root == null: return error_no_scene() var node := find_node_by_path(node_path) if node == null: return error_not_found("Node '%s'" % node_path, "Use get_scene_tree to see available nodes") # Check property exists if not property in node: var available: Array = [] for prop in node.get_property_list(): if prop["usage"] & PROPERTY_USAGE_EDITOR: available.append(prop["name"]) return error_not_found("Property '%s' on %s" % [property, node.get_class()], "Available: %s" % str(available.slice(0, 20))) var old_value: Variant = node.get(property) var target_type := typeof(old_value) var parsed_value: Variant = PropertyParser.parse_value(value, target_type) # Handle @export node references (e.g. @export var hud: HUD) # typeof() returns TYPE_NIL when unset or TYPE_OBJECT when set, # neither resolves a string path to a node — check the property hint instead if value is String: for prop in node.get_property_list(): if prop["name"] == property and prop["hint"] == PROPERTY_HINT_NODE_TYPE: var target_node: Node = node.get_node_or_null(NodePath(value)) if target_node == null: target_node = root.get_node_or_null(NodePath(value)) if target_node == null: return error_not_found("Node '%s'" % value, "Could not resolve node path for property '%s'" % property) parsed_value = target_node break var undo_redo := get_undo_redo() undo_redo.create_action("MCP: Set %s.%s" % [node.name, property]) undo_redo.add_do_property(node, property, parsed_value) undo_redo.add_undo_property(node, property, old_value) undo_redo.commit_action() return success({ "node": str(root.get_path_to(node)), "property": property, "old_value": PropertyParser.serialize_value(old_value), "new_value": PropertyParser.serialize_value(node.get(property)), }) func _get_node_properties(params: Dictionary) -> Dictionary: var result := require_string(params, "node_path") if result[1] != null: return result[1] var node_path: String = result[0] var root := get_edited_root() if root == null: return error_no_scene() var node := find_node_by_path(node_path) if node == null: return error_not_found("Node '%s'" % node_path, "Use get_scene_tree to see available nodes") var category: String = optional_string(params, "category", "") var props := NodeUtils.get_node_properties_dict(node) # Filter by category if specified if not category.is_empty(): var filtered: Dictionary = {} for key: String in props: if key.begins_with(category): filtered[key] = props[key] props = filtered return success({ "node_path": str(root.get_path_to(node)), "type": node.get_class(), "properties": props, }) func _add_resource(params: Dictionary) -> Dictionary: var result := require_string(params, "node_path") if result[1] != null: return result[1] var node_path: String = result[0] var result2 := require_string(params, "property") if result2[1] != null: return result2[1] var property: String = result2[0] var result3 := require_string(params, "resource_type") if result3[1] != null: return result3[1] var resource_type: String = result3[0] var root := get_edited_root() if root == null: return error_no_scene() var node := find_node_by_path(node_path) if node == null: return error_not_found("Node '%s'" % node_path, "Use get_scene_tree to see available nodes") if not ClassDB.class_exists(resource_type): return error_invalid_params("Unknown resource type: %s" % resource_type) if not ClassDB.is_parent_class(resource_type, "Resource"): return error_invalid_params("'%s' is not a Resource type" % resource_type) var resource: Resource = ClassDB.instantiate(resource_type) if resource == null: return error_internal("Failed to create resource: %s" % resource_type) # Apply resource properties if provided var resource_props: Dictionary = params.get("resource_properties", {}) for prop_name: String in resource_props: if prop_name in resource: var current: Variant = resource.get(prop_name) resource.set(prop_name, PropertyParser.parse_value(resource_props[prop_name], typeof(current))) var old_value: Variant = node.get(property) if property in node else null var undo_redo := get_undo_redo() undo_redo.create_action("MCP: Add %s to %s" % [resource_type, node.name]) undo_redo.add_do_property(node, property, resource) undo_redo.add_undo_property(node, property, old_value) undo_redo.commit_action() return success({ "node_path": str(root.get_path_to(node)), "property": property, "resource_type": resource_type, }) func _set_anchor_preset(params: Dictionary) -> Dictionary: var result := require_string(params, "node_path") if result[1] != null: return result[1] var node_path: String = result[0] var result2 := require_string(params, "preset") if result2[1] != null: return result2[1] var preset_name: String = result2[0] var root := get_edited_root() if root == null: return error_no_scene() var node := find_node_by_path(node_path) if node == null: return error_not_found("Node '%s'" % node_path, "Use get_scene_tree to see available nodes") if not node is Control: return error_invalid_params("Node '%s' is not a Control (is %s)" % [node_path, node.get_class()]) var control: Control = node var presets := { "top_left": Control.PRESET_TOP_LEFT, "top_right": Control.PRESET_TOP_RIGHT, "bottom_left": Control.PRESET_BOTTOM_LEFT, "bottom_right": Control.PRESET_BOTTOM_RIGHT, "center_left": Control.PRESET_CENTER_LEFT, "center_top": Control.PRESET_CENTER_TOP, "center_right": Control.PRESET_CENTER_RIGHT, "center_bottom": Control.PRESET_CENTER_BOTTOM, "center": Control.PRESET_CENTER, "left_wide": Control.PRESET_LEFT_WIDE, "top_wide": Control.PRESET_TOP_WIDE, "right_wide": Control.PRESET_RIGHT_WIDE, "bottom_wide": Control.PRESET_BOTTOM_WIDE, "vcenter_wide": Control.PRESET_VCENTER_WIDE, "hcenter_wide": Control.PRESET_HCENTER_WIDE, "full_rect": Control.PRESET_FULL_RECT, } if not presets.has(preset_name): return error_invalid_params("Unknown preset: '%s'. Available: %s" % [preset_name, presets.keys()]) var keep_offsets: bool = optional_bool(params, "keep_offsets", false) var undo_redo := get_undo_redo() undo_redo.create_action("MCP: Set anchor preset on %s" % node.name) # Store old values var old_anchors := [control.anchor_left, control.anchor_top, control.anchor_right, control.anchor_bottom] var old_offsets := [control.offset_left, control.offset_top, control.offset_right, control.offset_bottom] var target: Control = control.duplicate() as Control target.set_anchors_and_offsets_preset(presets[preset_name], Control.PRESET_MODE_KEEP_SIZE if keep_offsets else Control.PRESET_MODE_MINSIZE) undo_redo.add_do_property(control, "anchor_left", target.anchor_left) undo_redo.add_do_property(control, "anchor_top", target.anchor_top) undo_redo.add_do_property(control, "anchor_right", target.anchor_right) undo_redo.add_do_property(control, "anchor_bottom", target.anchor_bottom) undo_redo.add_do_property(control, "offset_left", target.offset_left) undo_redo.add_do_property(control, "offset_top", target.offset_top) undo_redo.add_do_property(control, "offset_right", target.offset_right) undo_redo.add_do_property(control, "offset_bottom", target.offset_bottom) undo_redo.add_undo_property(control, "anchor_left", old_anchors[0]) undo_redo.add_undo_property(control, "anchor_top", old_anchors[1]) undo_redo.add_undo_property(control, "anchor_right", old_anchors[2]) undo_redo.add_undo_property(control, "anchor_bottom", old_anchors[3]) undo_redo.add_undo_property(control, "offset_left", old_offsets[0]) undo_redo.add_undo_property(control, "offset_top", old_offsets[1]) undo_redo.add_undo_property(control, "offset_right", old_offsets[2]) undo_redo.add_undo_property(control, "offset_bottom", old_offsets[3]) target.free() undo_redo.commit_action() return success({"node_path": str(root.get_path_to(control)), "preset": preset_name}) func _rename_node(params: Dictionary) -> Dictionary: var result := require_string(params, "node_path") if result[1] != null: return result[1] var node_path: String = result[0] var result2 := require_string(params, "new_name") if result2[1] != null: return result2[1] var new_name: String = result2[0] var root := get_edited_root() if root == null: return error_no_scene() var node := find_node_by_path(node_path) if node == null: return error_not_found("Node '%s'" % node_path, "Use get_scene_tree to see available nodes") var old_name: String = node.name var undo_redo := get_undo_redo() 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 success({"old_name": old_name, "new_name": str(node.name), "node_path": str(root.get_path_to(node))}) func _connect_signal(params: Dictionary) -> Dictionary: var result := require_string(params, "source_path") if result[1] != null: return result[1] var source_path: String = result[0] var result2 := require_string(params, "signal_name") if result2[1] != null: return result2[1] var signal_name: String = result2[0] var result3 := require_string(params, "target_path") if result3[1] != null: return result3[1] var target_path: String = result3[0] var result4 := require_string(params, "method_name") if result4[1] != null: return result4[1] var method_name: String = result4[0] var root := get_edited_root() if root == null: return error_no_scene() var source := find_node_by_path(source_path) if source == null: return error_not_found("Source node '%s'" % source_path) var target := find_node_by_path(target_path) if target == null: return error_not_found("Target node '%s'" % target_path) if not source.has_signal(signal_name): return error_invalid_params("Signal '%s' not found on %s" % [signal_name, source.get_class()]) if source.is_connected(signal_name, Callable(target, method_name)): return success({"already_connected": true, "signal": signal_name}) var callable := Callable(target, method_name) var undo_redo := get_undo_redo() undo_redo.create_action("MCP: Connect signal") undo_redo.add_do_method(source, "connect", signal_name, callable) undo_redo.add_undo_method(source, "disconnect", signal_name, callable) undo_redo.commit_action() return success({ "source": str(root.get_path_to(source)), "signal": signal_name, "target": str(root.get_path_to(target)), "method": method_name, "connected": true, }) func _disconnect_signal(params: Dictionary) -> Dictionary: var result := require_string(params, "source_path") if result[1] != null: return result[1] var source_path: String = result[0] var result2 := require_string(params, "signal_name") if result2[1] != null: return result2[1] var signal_name: String = result2[0] var result3 := require_string(params, "target_path") if result3[1] != null: return result3[1] var target_path: String = result3[0] var result4 := require_string(params, "method_name") if result4[1] != null: return result4[1] var method_name: String = result4[0] var root := get_edited_root() if root == null: return error_no_scene() var source := find_node_by_path(source_path) if source == null: return error_not_found("Source node '%s'" % source_path) var target := find_node_by_path(target_path) if target == null: return error_not_found("Target node '%s'" % target_path) if not source.is_connected(signal_name, Callable(target, method_name)): return success({"was_connected": false}) var callable := Callable(target, method_name) var undo_redo := get_undo_redo() undo_redo.create_action("MCP: Disconnect signal") undo_redo.add_do_method(source, "disconnect", signal_name, callable) undo_redo.add_undo_method(source, "connect", signal_name, callable) undo_redo.commit_action() return success({ "source": str(root.get_path_to(source)), "signal": signal_name, "target": str(root.get_path_to(target)), "method": method_name, "disconnected": true, }) func _get_node_groups(params: Dictionary) -> Dictionary: var result := require_string(params, "node_path") if result[1] != null: return result[1] var node_path: String = result[0] var root := get_edited_root() if root == null: return error_no_scene() var node := find_node_by_path(node_path) if node == null: return error_not_found("Node '%s'" % node_path, "Use get_scene_tree to see available nodes") var groups: Array = [] for group: StringName in node.get_groups(): var g := str(group) # Filter out internal groups (start with _) if not g.begins_with("_"): groups.append(g) return success({ "node_path": str(root.get_path_to(node)), "groups": groups, "count": groups.size(), }) func _set_node_groups(params: Dictionary) -> Dictionary: var result := require_string(params, "node_path") if result[1] != null: return result[1] var node_path: String = result[0] if not params.has("groups") or not params["groups"] is Array: return error_invalid_params("'groups' array is required") var desired_groups: Array = params["groups"] var root := get_edited_root() if root == null: return error_no_scene() var node := find_node_by_path(node_path) if node == null: return error_not_found("Node '%s'" % node_path, "Use get_scene_tree to see available nodes") # Get current non-internal groups var current_groups: Array = [] for group: StringName in node.get_groups(): var g := str(group) if not g.begins_with("_"): current_groups.append(g) var added: Array = [] var removed: Array = [] for group: String in current_groups: if group not in desired_groups: removed.append(group) for group in desired_groups: var g: String = str(group) if g not in current_groups: added.append(g) if not added.is_empty() or not removed.is_empty(): var undo_redo := get_undo_redo() undo_redo.create_action("MCP: Set node groups") for group: String in removed: undo_redo.add_do_method(node, "remove_from_group", group) undo_redo.add_undo_method(node, "add_to_group", group, true) for group: String in added: 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 success({ "node_path": str(root.get_path_to(node)), "groups": desired_groups, "added": added, "removed": removed, }) func _find_nodes_in_group(params: Dictionary) -> Dictionary: var result := require_string(params, "group") if result[1] != null: return result[1] var group_name: String = result[0] var root := get_edited_root() if root == null: return error_no_scene() var matches: Array = [] _find_in_group_recursive(root, root, group_name, matches) return success({ "group": group_name, "nodes": matches, "count": matches.size(), }) func _find_in_group_recursive(node: Node, root: Node, group_name: String, matches: Array) -> void: if node.is_in_group(group_name): matches.append({ "name": node.name, "path": str(root.get_path_to(node)), "type": node.get_class(), }) for child in node.get_children(): _find_in_group_recursive(child, root, group_name, matches)