@tool extends "res://addons/godot_mcp/commands/base_command.gd" const PropertyParser := preload("res://addons/godot_mcp/utils/property_parser.gd") func get_commands() -> Dictionary: return { "find_nodes_by_type": _find_nodes_by_type, "find_signal_connections": _find_signal_connections, "batch_set_property": _batch_set_property, "batch_add_nodes": _batch_add_nodes, "find_node_references": _find_node_references, "get_scene_dependencies": _get_scene_dependencies, "cross_scene_set_property": _cross_scene_set_property, } func _find_nodes_by_type(params: Dictionary) -> Dictionary: var result := require_string(params, "type") if result[1] != null: return result[1] var type_name: String = result[0] var root := get_edited_root() if root == null: return error_no_scene() var recursive: bool = optional_bool(params, "recursive", true) var matches: Array = [] _search_by_type(root, type_name, recursive, matches) return success({"type": type_name, "matches": matches, "count": matches.size()}) func _search_by_type(node: Node, type_name: String, recursive: bool, matches: Array) -> void: if node.is_class(type_name) or node.get_class() == type_name: var root := get_edited_root() matches.append({ "name": node.name, "path": str(root.get_path_to(node)), "type": node.get_class(), }) if recursive: for child in node.get_children(): _search_by_type(child, type_name, recursive, matches) func _find_signal_connections(params: Dictionary) -> Dictionary: var root := get_edited_root() if root == null: return error_no_scene() var signal_filter: String = optional_string(params, "signal_name", "") var node_filter: String = optional_string(params, "node_path", "") var connections: Array = [] _collect_signals(root, root, signal_filter, node_filter, connections) return success({"connections": connections, "count": connections.size()}) func _collect_signals(node: Node, root: Node, signal_filter: String, node_filter: String, connections: Array) -> void: var node_path := str(root.get_path_to(node)) if node_filter.is_empty() or node_path.contains(node_filter): for sig_info in node.get_signal_list(): var sig_name: String = sig_info["name"] if not signal_filter.is_empty() and not sig_name.contains(signal_filter): continue for conn in node.get_signal_connection_list(sig_name): connections.append({ "source": node_path, "signal": sig_name, "target": str(root.get_path_to(conn["callable"].get_object())), "method": conn["callable"].get_method(), }) for child in node.get_children(): _collect_signals(child, root, signal_filter, node_filter, connections) func _batch_set_property(params: Dictionary) -> Dictionary: var result := require_string(params, "type") if result[1] != null: return result[1] var type_name: 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 = params["value"] # Parse value string if value is String: var s: String = value var expr := Expression.new() if expr.parse(s) == OK: var parsed = expr.execute() if parsed != null: value = parsed var root := get_edited_root() if root == null: return error_no_scene() var affected: Array = [] var changes: Array = [] _batch_collect_property_changes(root, root, type_name, property, value, affected, changes) if not changes.is_empty(): _apply_property_changes_with_undo(changes, property, "MCP: Batch set %s" % property) return success({"property": property, "affected": affected, "count": affected.size()}) func _batch_collect_property_changes(node: Node, root: Node, type_name: String, property: String, value: Variant, affected: Array, changes: Array) -> void: if node.is_class(type_name) or node.get_class() == type_name: if property in node: affected.append(str(root.get_path_to(node))) changes.append({ "node": node, "old_value": node.get(property), "new_value": value, }) for child in node.get_children(): _batch_collect_property_changes(child, root, type_name, property, value, affected, changes) func _batch_add_nodes(params: Dictionary) -> Dictionary: if not params.has("nodes") or not params["nodes"] is Array: return error_invalid_params("Missing required parameter: nodes (Array)") var nodes_data: Array = params["nodes"] if nodes_data.is_empty(): return error_invalid_params("nodes array is empty") var root := get_edited_root() if root == null: return error_no_scene() var created: Array = [] var errors: Array = [] for i: int in nodes_data.size(): var entry: Dictionary = nodes_data[i] if not entry.has("type") or not entry["type"] is String: errors.append({"index": i, "error": "Missing or invalid 'type'"}) continue var type: String = entry["type"] if not ClassDB.class_exists(type): errors.append({"index": i, "error": "Unknown node type: %s" % type}) continue var parent_path: String = entry.get("parent_path", ".") if entry.has("parent_path") and entry["parent_path"] is String else "." var node_name: String = entry.get("name", "") if entry.has("name") and entry["name"] is String else "" var properties: Dictionary = entry.get("properties", {}) if entry.has("properties") and entry["properties"] is Dictionary else {} var parent := find_node_by_path(parent_path) if parent == null: errors.append({"index": i, "error": "Parent node '%s' not found" % parent_path}) continue var node: Node = ClassDB.instantiate(type) if not node_name.is_empty(): node.name = node_name 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)) add_child_with_undo(parent, node, root, "MCP: Batch add %s" % type) created.append({ "index": i, "type": type, "name": str(node.name), "parent": parent_path, "node_path": str(root.get_path_to(node)), }) var result := {"created": created, "count": created.size()} if not errors.is_empty(): result["errors"] = errors return success(result) func _find_node_references(params: Dictionary) -> Dictionary: var result := require_string(params, "pattern") if result[1] != null: return result[1] var pattern: String = result[0] # Search through all .tscn and .gd files for references var matches: Array = [] _search_files_for_pattern("res://", pattern, matches, 100) return success({"pattern": pattern, "matches": matches, "count": matches.size()}) func _search_files_for_pattern(path: String, pattern: String, matches: Array, max_results: int) -> void: if matches.size() >= max_results: return var dir := DirAccess.open(path) if dir == null: return dir.list_dir_begin() var file_name := dir.get_next() while not file_name.is_empty() and matches.size() < max_results: if file_name.begins_with("."): file_name = dir.get_next() continue var full_path := path.path_join(file_name) if dir.current_is_dir(): _search_files_for_pattern(full_path, pattern, matches, max_results) elif file_name.get_extension() in ["tscn", "gd", "tres", "gdshader"]: var file := FileAccess.open(full_path, FileAccess.READ) if file: var content := file.get_as_text() file.close() if content.contains(pattern): # Find line numbers var lines := content.split("\n") var line_matches: Array = [] for i in lines.size(): if lines[i].contains(pattern): line_matches.append(i + 1) if line_matches.size() >= 5: break matches.append({ "file": full_path, "lines": line_matches, }) file_name = dir.get_next() dir.list_dir_end() func _cross_scene_set_property(params: Dictionary) -> Dictionary: var result := require_string(params, "type") if result[1] != null: return result[1] var type_name: 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 = params["value"] # Parse value string if value is String: var expr := Expression.new() if expr.parse(value) == OK: var parsed = expr.execute() if parsed != null: value = parsed var path_filter: String = optional_string(params, "path_filter", "res://") var exclude_addons: bool = optional_bool(params, "exclude_addons", true) var force: bool = optional_bool(params, "force", false) var dry_run: bool = optional_bool(params, "dry_run", not force) if not dry_run and not force: return error_invalid_params("cross_scene_set_property requires force=true when dry_run=false") var scenes_affected: Array = [] var skipped_open_scenes: Array = [] var total_nodes: int = 0 var scene_files: Array = [] _collect_scene_files(path_filter, scene_files, exclude_addons) for scene_path: String in scene_files: var normalized_scene_path := normalize_project_path(scene_path) if is_scene_path_open(normalized_scene_path): if is_active_scene_path(normalized_scene_path) and force and not dry_run: var root := get_edited_root() var live_changes: Array = [] var live_affected_nodes: Array = [] _cross_scene_collect_changes(root, root, type_name, property, value, live_affected_nodes, live_changes) if not live_changes.is_empty(): _apply_property_changes_with_undo(live_changes, property, "MCP: Cross-scene set %s" % property) scenes_affected.append({ "scene": normalized_scene_path, "nodes": live_affected_nodes, "count": live_affected_nodes.size(), "mode": "live_open_scene", }) total_nodes += live_affected_nodes.size() else: var reason := "open scene skipped during dry_run" if dry_run else "open scene is not the active editor scene" skipped_open_scenes.append({"scene": normalized_scene_path, "reason": reason}) continue var packed: PackedScene = ResourceLoader.load(scene_path) as PackedScene if packed == null: continue var instance: Node = packed.instantiate() if instance == null: continue var affected_nodes: Array = [] var changes: Array = [] _cross_scene_collect_changes(instance, instance, type_name, property, value, affected_nodes, changes) if not changes.is_empty(): if not dry_run: var guard := guard_offline_scene_save(normalized_scene_path) if not guard.is_empty(): instance.free() return guard for change: Dictionary in changes: (change["node"] as Node).set(property, value) # Pack and save var new_packed := PackedScene.new() var pack_err := new_packed.pack(instance) if pack_err != OK: instance.free() return error_internal("Failed to pack scene '%s': %s" % [normalized_scene_path, error_string(pack_err)]) var save_err := ResourceSaver.save(new_packed, normalized_scene_path) if save_err != OK: instance.free() return error_internal("Failed to save scene '%s': %s" % [normalized_scene_path, error_string(save_err)]) scenes_affected.append({ "scene": normalized_scene_path, "nodes": affected_nodes, "count": affected_nodes.size(), "mode": "dry_run" if dry_run else "offline_saved", }) total_nodes += affected_nodes.size() instance.free() # Rescan filesystem so editor picks up changes if not scenes_affected.is_empty(): EditorInterface.get_resource_filesystem().scan() return success({ "type": type_name, "property": property, "dry_run": dry_run, "force": force, "scenes_affected": scenes_affected, "skipped_open_scenes": skipped_open_scenes, "total_scenes": scenes_affected.size(), "total_nodes": total_nodes, "message": "Dry run only. Re-run with force=true and dry_run=false to write closed scenes and live-edit the active open scene." if dry_run else "Changes applied.", }) func _collect_scene_files(path: String, files: Array, exclude_addons: bool) -> void: var dir := DirAccess.open(path) if dir == null: return dir.list_dir_begin() var file_name := dir.get_next() while not file_name.is_empty(): if file_name.begins_with("."): file_name = dir.get_next() continue var full_path := path.path_join(file_name) if dir.current_is_dir(): if exclude_addons and file_name == "addons": file_name = dir.get_next() continue _collect_scene_files(full_path, files, exclude_addons) elif file_name.get_extension() == "tscn": files.append(full_path) file_name = dir.get_next() dir.list_dir_end() func _cross_scene_collect_changes(node: Node, root: Node, type_name: String, property: String, value: Variant, affected: Array, changes: Array) -> void: if node.is_class(type_name) or node.get_class() == type_name: if property in node: affected.append(str(root.get_path_to(node))) changes.append({ "node": node, "old_value": node.get(property), "new_value": value, }) for child in node.get_children(): _cross_scene_collect_changes(child, root, type_name, property, value, affected, changes) func _apply_property_changes_with_undo(changes: Array, property: String, action_name: String) -> void: var undo_redo := get_undo_redo() undo_redo.create_action(action_name) for change: Dictionary in changes: var node: Node = change["node"] undo_redo.add_do_property(node, property, change["new_value"]) undo_redo.add_undo_property(node, property, change["old_value"]) undo_redo.commit_action() func _get_scene_dependencies(params: Dictionary) -> Dictionary: var result := require_string(params, "path") if result[1] != null: return result[1] var path: String = result[0] if not FileAccess.file_exists(path): return error_not_found("File '%s'" % path) var deps := ResourceLoader.get_dependencies(path) var dependencies: Array = [] for dep: String in deps: # Format: "path::type" var parts := dep.split("::") dependencies.append({ "path": parts[0] if parts.size() > 0 else dep, "type": parts[2] if parts.size() > 2 else "", }) return success({"path": path, "dependencies": dependencies, "count": dependencies.size()})