@tool extends "res://addons/godot_mcp/commands/base_command.gd" func get_commands() -> Dictionary: return { "find_unused_resources": _find_unused_resources, "analyze_signal_flow": _analyze_signal_flow, "analyze_scene_complexity": _analyze_scene_complexity, "find_script_references": _find_script_references, "detect_circular_dependencies": _detect_circular_dependencies, "get_project_statistics": _get_project_statistics, } # ============================================================================= # find_unused_resources # ============================================================================= ## Scan project for resources not referenced by any .tscn, .gd, or .tres file. func _find_unused_resources(params: Dictionary) -> Dictionary: var path: String = optional_string(params, "path", "res://") var include_addons: bool = optional_bool(params, "include_addons", false) # Step 1: Collect all resource files var resource_extensions: Array = ["tres", "tscn", "png", "jpg", "jpeg", "svg", "wav", "ogg", "mp3", "ttf", "otf", "gdshader", "material", "theme", "stylebox", "font", "anim"] var all_resources: Array = [] _collect_files_by_ext(path, resource_extensions, all_resources, include_addons) # Step 2: Collect all referencing files (.tscn, .gd, .tres) var ref_extensions: Array = ["tscn", "gd", "tres", "cfg", "godot"] var ref_files: Array = [] _collect_files_by_ext(path, ref_extensions, ref_files, include_addons) # Step 3: Build a set of all referenced paths var referenced: Dictionary = {} # path -> true for ref_file in ref_files: var content := _read_file_text(ref_file as String) if content.is_empty(): continue # Find res:// paths in file content var idx := 0 while idx < content.length(): var found := content.find("res://", idx) if found == -1: break # Extract the path (up to quote, space, or end of line) var end := found + 6 while end < content.length(): var c := content[end] if c == '"' or c == "'" or c == ' ' or c == '\n' or c == '\r' or c == ')' or c == ']' or c == '}': break end += 1 var ref_path := content.substr(found, end - found) referenced[ref_path] = true idx = end # Step 4: Find unreferenced resources var unused: Array = [] for res_path in all_resources: var p: String = res_path if not referenced.has(p): # Also check without uid:// prefix variants — some references use uid unused.append(p) return success({ "unused_resources": unused, "unused_count": unused.size(), "total_resources_scanned": all_resources.size(), "total_files_checked": ref_files.size(), }) # ============================================================================= # analyze_signal_flow # ============================================================================= ## Map all signal connections in the currently edited scene. func _analyze_signal_flow(params: Dictionary) -> Dictionary: var root := get_edited_root() if root == null: return error_no_scene() var nodes_data: Array = [] _collect_signal_data(root, root, nodes_data) return success({ "scene": root.scene_file_path, "nodes": nodes_data, "total_nodes": nodes_data.size(), }) func _collect_signal_data(node: Node, root: Node, out: Array) -> void: var node_path := str(root.get_path_to(node)) var signals_emitted: Array = [] var signals_connected_to: Array = [] # Get all signals this node defines for sig in node.get_signal_list(): var sig_name: String = sig["name"] var connections := node.get_signal_connection_list(sig_name) if connections.size() > 0: var targets: Array = [] for conn in connections: var callable: Callable = conn["callable"] var target_node: Node = callable.get_object() as Node var target_path := "" if target_node != null: target_path = str(root.get_path_to(target_node)) targets.append({ "target_node": target_path, "method": callable.get_method(), }) # Also record on the target side signals_connected_to.append({ "from_node": node_path, "signal": sig_name, "method": callable.get_method(), }) signals_emitted.append({ "signal": sig_name, "targets": targets, }) # Only include nodes that have signal activity if signals_emitted.size() > 0 or signals_connected_to.size() > 0: out.append({ "name": node.name, "path": node_path, "type": node.get_class(), "signals_emitted": signals_emitted, "signals_connected_to": signals_connected_to, }) for child in node.get_children(): _collect_signal_data(child, root, out) # ============================================================================= # analyze_scene_complexity # ============================================================================= ## Analyze a scene's complexity: node count, depth, types, scripts, potential issues. func _analyze_scene_complexity(params: Dictionary) -> Dictionary: var scene_path: String = optional_string(params, "path", "") var root: Node = null if scene_path.is_empty(): root = get_edited_root() if root == null: return error_no_scene() scene_path = root.scene_file_path else: if not ResourceLoader.exists(scene_path): return error_not_found("Scene '%s'" % scene_path) var packed := ResourceLoader.load(scene_path) as PackedScene if packed == null: return error_internal("Failed to load scene: %s" % scene_path) root = packed.instantiate() var total_nodes := 0 var max_depth := 0 var types: Dictionary = {} # class_name -> count var scripts_attached: Array = [] var resources_used: Dictionary = {} # resource path -> count var issues: Array = [] _analyze_node(root, root, 0, total_nodes, max_depth, types, scripts_attached, resources_used) # Count totals from recursive walk total_nodes = _count_nodes_recursive(root) max_depth = _get_max_depth(root, 0) # Detect potential issues if total_nodes > 1000: issues.append({"severity": "warning", "message": "Scene has %d nodes (>1000). Consider splitting into sub-scenes." % total_nodes}) elif total_nodes > 500: issues.append({"severity": "info", "message": "Scene has %d nodes (>500). Monitor performance." % total_nodes}) if max_depth > 15: issues.append({"severity": "warning", "message": "Max nesting depth is %d (>15). Deep hierarchies can be hard to maintain." % max_depth}) elif max_depth > 10: issues.append({"severity": "info", "message": "Max nesting depth is %d (>10)." % max_depth}) # If we instantiated the scene ourselves, free it if not scene_path.is_empty() and root != get_edited_root(): root.queue_free() return success({ "scene_path": scene_path, "total_nodes": total_nodes, "max_depth": max_depth, "nodes_by_type": types, "scripts_attached": scripts_attached, "unique_resource_count": resources_used.size(), "issues": issues, }) func _analyze_node(node: Node, root: Node, depth: int, total_nodes: int, max_depth: int, types: Dictionary, scripts: Array, resources: Dictionary) -> void: var type_name := node.get_class() types[type_name] = types.get(type_name, 0) + 1 if node.get_script() != null: var script: Script = node.get_script() var script_path := script.resource_path if not script_path.is_empty(): scripts.append({ "node": str(root.get_path_to(node)), "script": script_path, }) for child in node.get_children(): _analyze_node(child, root, depth + 1, total_nodes, max_depth, types, scripts, resources) func _count_nodes_recursive(node: Node) -> int: var count := 1 for child in node.get_children(): count += _count_nodes_recursive(child) return count func _get_max_depth(node: Node, current_depth: int) -> int: var max_d := current_depth for child in node.get_children(): var child_depth := _get_max_depth(child, current_depth + 1) if child_depth > max_d: max_d = child_depth return max_d # ============================================================================= # find_script_references # ============================================================================= ## Find all places where a given script, class_name, or resource path is used. func _find_script_references(params: Dictionary) -> Dictionary: var result := require_string(params, "query") if result[1] != null: return result[1] var query: String = result[0] var path: String = optional_string(params, "path", "res://") var include_addons: bool = optional_bool(params, "include_addons", false) var search_extensions: Array = ["tscn", "gd", "tres", "cfg", "godot"] var search_files: Array = [] _collect_files_by_ext(path, search_extensions, search_files, include_addons) var references: Array = [] for file_path in search_files: var fp: String = file_path var content := _read_file_text(fp) if content.is_empty(): continue var lines := content.split("\n") var line_num := 0 for line in lines: line_num += 1 var l: String = line if l.contains(query): references.append({ "file": fp, "line": line_num, "content": l.strip_edges(), }) return success({ "query": query, "references": references, "reference_count": references.size(), "files_searched": search_files.size(), }) # ============================================================================= # detect_circular_dependencies # ============================================================================= ## Check for circular scene dependencies (.tscn files referencing each other). func _detect_circular_dependencies(params: Dictionary) -> Dictionary: var path: String = optional_string(params, "path", "res://") var include_addons: bool = optional_bool(params, "include_addons", false) # Step 1: Collect all .tscn files var tscn_files: Array = [] _collect_files_by_ext(path, ["tscn"], tscn_files, include_addons) # Step 2: Build dependency graph: scene_path -> [referenced_scene_paths] var dep_graph: Dictionary = {} # String -> Array[String] for tscn_path in tscn_files: var tp: String = tscn_path var content := _read_file_text(tp) if content.is_empty(): continue var deps: Array = [] for line in content.split("\n"): var l: String = line # Match [ext_resource ... path="res://..." ...] lines that reference .tscn if l.begins_with("[ext_resource") and ".tscn" in l: var path_start := l.find('path="') if path_start == -1: continue path_start += 6 # len('path="') var path_end := l.find('"', path_start) if path_end == -1: continue var ref_path := l.substr(path_start, path_end - path_start) if ref_path.ends_with(".tscn"): deps.append(ref_path) dep_graph[tp] = deps # Step 3: Detect cycles using DFS var cycles: Array = [] var visited: Dictionary = {} # path -> "unvisited" | "visiting" | "visited" for scene in dep_graph: visited[scene] = "unvisited" for scene in dep_graph: if visited[scene] == "unvisited": var path_stack: Array = [] _dfs_detect_cycle(scene as String, dep_graph, visited, path_stack, cycles) return success({ "scenes_checked": tscn_files.size(), "circular_dependencies": cycles, "has_circular": cycles.size() > 0, "dependency_graph": dep_graph, }) func _dfs_detect_cycle(node: String, graph: Dictionary, visited: Dictionary, path_stack: Array, cycles: Array) -> void: visited[node] = "visiting" path_stack.append(node) if graph.has(node): var deps: Array = graph[node] for dep in deps: var d: String = dep if not visited.has(d): # Scene referenced but not in our graph (might not exist or outside scope) continue if visited[d] == "visiting": # Found a cycle — extract it from the stack var cycle_start := path_stack.find(d) var cycle: Array = path_stack.slice(cycle_start) cycle.append(d) # Close the cycle cycles.append(cycle) elif visited[d] == "unvisited": _dfs_detect_cycle(d, graph, visited, path_stack, cycles) path_stack.pop_back() visited[node] = "visited" # ============================================================================= # get_project_statistics # ============================================================================= ## Overall project stats: file counts, script lines, scenes, resources, autoloads, plugins. func _get_project_statistics(params: Dictionary) -> Dictionary: var path: String = optional_string(params, "path", "res://") var include_addons: bool = optional_bool(params, "include_addons", false) var file_counts: Dictionary = {} # extension -> count var total_script_lines := 0 var scene_count := 0 var resource_count := 0 var total_files := 0 _collect_statistics(path, include_addons, file_counts) # Extract internal counters and remove them from the visible dict total_script_lines = int(file_counts.get("_total_script_lines", 0)) scene_count = int(file_counts.get("_scene_count", 0)) resource_count = int(file_counts.get("_resource_count", 0)) total_files = int(file_counts.get("_total_files", 0)) file_counts.erase("_total_script_lines") file_counts.erase("_scene_count") file_counts.erase("_resource_count") file_counts.erase("_total_files") # Autoloads var autoloads: Dictionary = {} for prop in ProjectSettings.get_property_list(): var prop_name: String = prop["name"] if prop_name.begins_with("autoload/"): autoloads[prop_name.substr(9)] = str(ProjectSettings.get_setting(prop_name)) # Enabled plugins var plugins: Array = [] var plugin_cfg_path := "res://addons" var enabled_plugins: PackedStringArray = ProjectSettings.get_setting( "editor_plugins/enabled", PackedStringArray() ) var plugin_dir := DirAccess.open(plugin_cfg_path) if plugin_dir != null: plugin_dir.list_dir_begin() var dir_name := plugin_dir.get_next() while not dir_name.is_empty(): if plugin_dir.current_is_dir() and not dir_name.begins_with("."): var cfg_path := plugin_cfg_path.path_join(dir_name).path_join("plugin.cfg") if FileAccess.file_exists(cfg_path): var plugin_path := "res://addons/%s/plugin.cfg" % dir_name plugins.append({ "name": dir_name, "enabled": plugin_path in enabled_plugins, }) dir_name = plugin_dir.get_next() plugin_dir.list_dir_end() return success({ "file_counts_by_extension": file_counts, "total_files": total_files, "total_script_lines": total_script_lines, "scene_count": scene_count, "resource_count": resource_count, "autoloads": autoloads, "plugins": plugins, }) func _collect_statistics(path: String, include_addons: bool, file_counts: Dictionary) -> 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 file_name == "addons" and not include_addons: file_name = dir.get_next() continue _collect_statistics(full_path, include_addons, file_counts) else: var ext := file_name.get_extension().to_lower() file_counts[ext] = file_counts.get(ext, 0) + 1 if ext == "gd": var content := _read_file_text(full_path) var line_count := content.count("\n") + 1 if not content.is_empty() else 0 # We can't modify int params, so we store in the dict file_counts["_total_script_lines"] = file_counts.get("_total_script_lines", 0) + line_count if ext == "tscn": file_counts["_scene_count"] = file_counts.get("_scene_count", 0) + 1 if ext in ["tres", "material", "theme", "stylebox", "font"]: file_counts["_resource_count"] = file_counts.get("_resource_count", 0) + 1 file_counts["_total_files"] = file_counts.get("_total_files", 0) + 1 file_name = dir.get_next() dir.list_dir_end() # ============================================================================= # Shared helpers # ============================================================================= ## Recursively collect files matching given extensions. func _collect_files_by_ext(path: String, extensions: Array, out: Array, include_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 file_name == "addons" and not include_addons: file_name = dir.get_next() continue _collect_files_by_ext(full_path, extensions, out, include_addons) else: var ext := file_name.get_extension().to_lower() if ext in extensions: out.append(full_path) file_name = dir.get_next() dir.list_dir_end() ## Read a file's text content. Returns empty string on failure. func _read_file_text(file_path: String) -> String: var file := FileAccess.open(file_path, FileAccess.READ) if file == null: return "" var content := file.get_as_text() file.close() return content