@tool extends "res://addons/godot_mcp/commands/base_command.gd" func get_commands() -> Dictionary: return { "list_scripts": _list_scripts, "read_script": _read_script, "create_script": _create_script, "edit_script": _edit_script, "attach_script": _attach_script, "get_open_scripts": _get_open_scripts, "validate_script": _validate_script, } func _guard_script_file_path(path: String, operation: String) -> Dictionary: var ext := path.get_extension().to_lower() if ext in ["gd", "cs"]: return {} return error( -32602, "%s only supports script files (.gd, .cs): %s" % [operation, normalize_project_path(path)], { "path": normalize_project_path(path), "extension": ext, "suggestion": "Use scene commands for .tscn/.scn files and shader commands for shader resources.", } ) func _list_scripts(params: Dictionary) -> Dictionary: var path: String = optional_string(params, "path", "res://") var recursive: bool = optional_bool(params, "recursive", true) var scripts: Array = [] _find_scripts(path, recursive, scripts) return success({"scripts": scripts, "count": scripts.size()}) func _find_scripts(path: String, recursive: bool, scripts: Array) -> 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 recursive: _find_scripts(full_path, recursive, scripts) elif file_name.get_extension() in ["gd", "cs", "gdshader"]: var info := {"path": full_path, "type": file_name.get_extension()} # Get basic file info var file := FileAccess.open(full_path, FileAccess.READ) if file: info["size"] = file.get_length() # Read first line for class/extends info var first_line := file.get_line().strip_edges() if first_line.begins_with("class_name "): info["class_name"] = first_line.substr(11).strip_edges() elif first_line.begins_with("extends "): info["extends"] = first_line.substr(8).strip_edges() file.close() scripts.append(info) file_name = dir.get_next() dir.list_dir_end() func _read_script(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("Script '%s'" % path) var file := FileAccess.open(path, FileAccess.READ) if file == null: return error_internal("Cannot read script: %s" % error_string(FileAccess.get_open_error())) var content := file.get_as_text() var line_count := content.count("\n") + 1 file.close() return success({ "path": path, "content": content, "line_count": line_count, "size": content.length(), }) func _create_script(params: Dictionary) -> Dictionary: var result := require_string(params, "path") if result[1] != null: return result[1] var path: String = result[0] var path_guard := _guard_script_file_path(path, "create_script") if not path_guard.is_empty(): return path_guard var content: String = optional_string(params, "content", "") var base_class: String = optional_string(params, "extends", "Node") var class_name_str: String = optional_string(params, "class_name", "") var force: bool = optional_bool(params, "force", false) var guard := guard_text_resource_write(path, force) if not guard.is_empty(): return guard # Generate template if no content provided if content.is_empty(): var lines: PackedStringArray = [] if not class_name_str.is_empty(): lines.append("class_name %s" % class_name_str) lines.append("extends %s" % base_class) lines.append("") lines.append("") lines.append("func _ready() -> void:") lines.append("\tpass") lines.append("") content = "\n".join(lines) # Ensure directory exists var dir_path := path.get_base_dir() if not DirAccess.dir_exists_absolute(dir_path): DirAccess.make_dir_recursive_absolute(dir_path) var file := FileAccess.open(path, FileAccess.WRITE) if file == null: return error_internal("Cannot create script: %s" % error_string(FileAccess.get_open_error())) file.store_string(content) file.close() EditorInterface.get_resource_filesystem().scan() # Pre-load so the script is available immediately if ResourceLoader.exists(path): var script = load(path) if script is Script: script.reload(true) return success({"path": path, "created": true}) func _edit_script(params: Dictionary) -> Dictionary: var result := require_string(params, "path") if result[1] != null: return result[1] var path: String = result[0] var path_guard := _guard_script_file_path(path, "edit_script") if not path_guard.is_empty(): return path_guard if not FileAccess.file_exists(path): return error_not_found("Script '%s'" % path) var force: bool = optional_bool(params, "force", false) var guard := guard_text_resource_write(path, force) if not guard.is_empty(): return guard # Read current content var file := FileAccess.open(path, FileAccess.READ) if file == null: return error_internal("Cannot read script: %s" % error_string(FileAccess.get_open_error())) var content := file.get_as_text() file.close() var changes_made := 0 # Support search-and-replace if params.has("replacements") and params["replacements"] is Array: var replacements: Array = params["replacements"] for replacement in replacements: if replacement is Dictionary: var search: String = replacement.get("search", "") var replace: String = replacement.get("replace", "") if not search.is_empty(): var use_regex: bool = replacement.get("regex", false) if use_regex: var regex := RegEx.new() var err := regex.compile(search) if err == OK: var new_content := regex.sub(content, replace, true) if new_content != content: content = new_content changes_made += 1 else: if content.contains(search): content = content.replace(search, replace) changes_made += 1 # Support 1-based inclusive line range replacement elif params.has("content") and (params.has("start_line") or params.has("end_line")): if not params.has("start_line"): return error_invalid_params("start_line is required when end_line is provided") var start_line: int = int(params["start_line"]) var end_line: int = int(params.get("end_line", start_line)) var lines := content.split("\n") if start_line < 1: return error_invalid_params("start_line must be >= 1") if end_line < start_line: return error_invalid_params("end_line must be >= start_line") if start_line > lines.size(): return error_invalid_params("start_line is beyond the end of the file") if end_line > lines.size(): return error_invalid_params("end_line is beyond the end of the file") var replacement_lines := str(params["content"]).split("\n") var start_index := start_line - 1 var remove_count := end_line - start_line + 1 for _i in range(remove_count): lines.remove_at(start_index) for i in range(replacement_lines.size()): lines.insert(start_index + i, replacement_lines[i]) content = "\n".join(lines) changes_made = 1 # Support full content replacement elif params.has("content"): content = str(params["content"]) changes_made = 1 # Support insert at line elif params.has("insert_at_line") and params.has("text"): var line_num: int = int(params["insert_at_line"]) var text: String = str(params["text"]) var lines := content.split("\n") line_num = clampi(line_num, 0, lines.size()) lines.insert(line_num, text) content = "\n".join(lines) changes_made = 1 if changes_made == 0: return success({"path": path, "changes_made": 0, "message": "No changes applied"}) # Write back file = FileAccess.open(path, FileAccess.WRITE) if file == null: return error_internal("Cannot write script: %s" % error_string(FileAccess.get_open_error())) file.store_string(content) file.close() # Reload the script resource so the editor picks up changes immediately _reload_script(path) return success({"path": path, "changes_made": changes_made}) ## Force-reload a script so the editor reflects disk changes immediately. func _reload_script(path: String) -> void: # First, trigger a filesystem scan so Godot knows the file changed EditorInterface.get_resource_filesystem().scan() # If the script is already loaded in memory, reload it if ResourceLoader.exists(path): var script = load(path) if script is Script: script.reload(true) # If the script is open in the script editor, the reload above updates it. # But we also need to notify the editor to refresh its error indicators. EditorInterface.get_script_editor().notification(Control.NOTIFICATION_VISIBILITY_CHANGED) func _attach_script(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, "script_path") if result2[1] != null: return result2[1] var script_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 not FileAccess.file_exists(script_path): return error_not_found("Script '%s'" % script_path) var script: Script = load(script_path) if script == null: return error_internal("Failed to load script: %s" % script_path) var old_script: Variant = node.get_script() var undo_redo := get_undo_redo() undo_redo.create_action("MCP: Attach script to %s" % node.name) undo_redo.add_do_method(node, "set_script", script) undo_redo.add_undo_method(node, "set_script", old_script) undo_redo.commit_action() return success({ "node_path": str(root.get_path_to(node)), "script_path": script_path, "attached": true, }) func _validate_script(params: Dictionary) -> Dictionary: var result := require_string(params, "path") if result[1] != null: return result[1] var path: String = result[0] var path_guard := _guard_script_file_path(path, "validate_script") if not path_guard.is_empty(): return path_guard if not FileAccess.file_exists(path): return error_not_found("Script '%s'" % path) var file := FileAccess.open(path, FileAccess.READ) if file == null: return error_internal("Cannot read script: %s" % error_string(FileAccess.get_open_error())) var source_code := file.get_as_text() file.close() var script := GDScript.new() script.source_code = source_code var err := script.reload() if err == OK: return success({"path": path, "valid": true, "message": "Script compiles successfully"}) return success({ "path": path, "valid": false, "error_code": err, "error_string": error_string(err), "message": "Compilation failed. Use get_output_log or get_editor_errors for details.", }) func _get_open_scripts(_params: Dictionary) -> Dictionary: var script_editor := EditorInterface.get_script_editor() var open_scripts: Array = [] for script_base in script_editor.get_open_scripts(): var info := { "path": script_base.resource_path, "type": script_base.get_class(), } open_scripts.append(info) return success({"scripts": open_scripts, "count": open_scripts.size()})