2026-10-09 12:49:07 +02:00

232 lines
10 KiB
GDScript
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
## folder.* command handlers — create and delete directories under res://.
const Modules := preload("res://addons/godot_mcp_toolkit/core/modules.gd")
const FileGuard = Modules.FileGuard
const Helpers = Modules.CommandHelpers
static func register(registry: MCPToolkitCommandRegistry, _server: Node) -> void:
registry.add("folder.create", func(parameters: Dictionary) -> Dictionary:
return _cmd_folder_create(parameters)
, MCPToolkitCommandOptions.new().mark_scene_independent())
registry.add("folder.delete", func(parameters: Dictionary) -> Dictionary:
return await _cmd_folder_delete(parameters)
, MCPToolkitCommandOptions.new().mark_scene_independent())
# -- Commands -----------------------------------------------------------------
static func _cmd_folder_create(parameters: Dictionary) -> Dictionary:
var err = MCPToolkitError.require(parameters, ["path"])
if err != null:
return err
var path := str(parameters.get("path", ""))
var guard := FileGuard.resolve_safe(path)
if guard["error"] != null:
return MCPToolkitError.fail("PATH_DENIED", str(guard["reason"]))
var pre_existed := DirAccess.dir_exists_absolute(path)
var error := DirAccess.make_dir_recursive_absolute(path)
if error != OK:
return MCPToolkitError.fail("CREATE_DIR_FAILED",
"DirAccess.make_dir_recursive_absolute returned %d (path=%s)" % [error, path])
var status := "returned" if pre_existed else "created"
return MCPToolkitSuccess.ok({"status": status, "path": path})
static func _cmd_folder_delete(parameters: Dictionary) -> Dictionary:
var err = MCPToolkitError.require(parameters, ["path"])
if err != null:
return err
var path := str(parameters.get("path", ""))
var recursive := bool(parameters.get("recursive", false))
var guard := FileGuard.resolve_safe(path)
if guard["error"] != null:
return MCPToolkitError.fail("PATH_DENIED", str(guard["reason"]))
if path == "res://" or path == "res:///" or path.get_base_dir() == "":
return MCPToolkitError.fail("FOLDER_PROTECTED",
"cannot delete the project root res://; narrow the path")
var normalized := path
if normalized.ends_with("/"):
normalized = normalized.substr(0, normalized.length() - 1)
if normalized == "res://addons" or normalized == "res://addons/godot_mcp_toolkit":
return MCPToolkitError.fail("FOLDER_PROTECTED",
"cannot delete res://addons or the toolkit plugin directory (%s); agent cannot remove its own host" % normalized)
if not DirAccess.dir_exists_absolute(path):
return MCPToolkitError.fail("NOT_FOUND", "no folder at %s" % path)
var normalized_with_slash := normalized + "/"
# Collect open scene tabs inside vs outside the target folder.
var open_scenes := EditorInterface.get_open_scenes()
var inside_scenes: Array[String] = []
var outside_scenes: Array[String] = []
for scene_path in open_scenes:
var sp := str(scene_path)
if sp == normalized or sp.begins_with(normalized_with_slash):
inside_scenes.append(sp)
else:
outside_scenes.append(sp)
var edited := Helpers.get_edited_root()
var active_path := ""
if edited != null:
active_path = str(edited.scene_file_path)
var active_inside := not active_path.is_empty() and (active_path == normalized or active_path.begins_with(normalized_with_slash))
# Scene tab handling strategy:
# - Exactly 1 scene inside + on 4.5+: close it via helper (single
# open+close cycle is safe; avoids phantom tab entirely).
# - Multiple scenes inside: cannot close in a loop (deferred-queue
# crash). Switch active away, return stale_tabs for agent follow-up
# via scene.close (MCP round-trip provides sequencing).
# - On 4.2–4.4: no close API — always switch-away + stale_tabs.
var stale_tabs: Array[String] = []
var warnings: Array[String] = []
var single_closed := false
# close_scene is 4.5+; on 4.2–4.4 there is no API to close scene tabs, so the
# stale-tab follow-up hint must not steer to the (absent) scene_close tool.
var can_close := EditorInterface.has_method("close_scene")
if inside_scenes.size() == 1 and can_close:
# Single scene — safe to close via helper (handles active or not).
var tab_result := await Helpers.close_scene_tab_safe(inside_scenes[0])
single_closed = tab_result.get("closed", false)
if not single_closed:
# no_api shouldn't happen (we checked has_method), but be safe.
stale_tabs = inside_scenes
elif inside_scenes.size() > 0:
# Multiple scenes (or 4.2–4.4): switch active away, list phantoms.
if active_inside:
if outside_scenes.is_empty():
return MCPToolkitError.fail("PATH_IN_USE",
"all open scene tabs are inside %s; open a scene outside the folder first via scene_open, then retry folder_delete" % path)
if not await Helpers.open_scene_deferred(outside_scenes[0]):
return MCPToolkitError.fail("TIMEOUT",
"could not switch active scene away from %s — filesystem scanning; retry shortly" % path)
stale_tabs = inside_scenes
if not can_close:
warnings.append(
"phantom tabs: Godot 4.2-4.4 has no API to close scene tabs — %d tab(s) inside %s will remain as phantoms until editor restart" % [inside_scenes.size(), path])
var script_editor := EditorInterface.get_script_editor()
if script_editor != null:
for open_script in script_editor.get_open_scripts():
if not (open_script is Resource):
continue
var resource_path := str((open_script as Resource).resource_path)
if resource_path.is_empty():
continue
if resource_path == normalized or resource_path.begins_with(normalized_with_slash):
return MCPToolkitError.fail("PATH_IN_USE",
"folder %s contains open script %s; close the script editor tab manually (no programmatic close API for script tabs), then retry folder_delete" % [
path, resource_path])
var directory := DirAccess.open(path)
if directory == null:
return MCPToolkitError.fail("INTERNAL",
"DirAccess.open(%s) returned null" % path)
var file_count := directory.get_files().size()
var subdir_count := directory.get_directories().size()
if (file_count + subdir_count) > 0 and not recursive:
return MCPToolkitError.fail("DIR_NOT_EMPTY",
"folder %s is not empty (contains %d files, %d subdirs); pass recursive:true to delete contents" % [
path, file_count, subdir_count])
var files_deleted := 0
var dirs_deleted := 0
if recursive and (file_count + subdir_count) > 0:
var result := _folder_delete_recursive(path)
files_deleted = int(result.get("files", 0))
dirs_deleted = int(result.get("dirs", 0))
if not bool(result.get("success", false)):
return MCPToolkitError.fail("DELETE_FAILED", str(result.get("error", "unknown")))
var parent_path := path.get_base_dir()
var parent_dir := DirAccess.open(parent_path)
if parent_dir == null:
return MCPToolkitError.fail("INTERNAL",
"DirAccess.open(%s) returned null" % parent_path)
var top_remove := parent_dir.remove(path.get_file())
if top_remove != OK:
return MCPToolkitError.fail("DELETE_FAILED",
"DirAccess.remove returned %d (path=%s)" % [top_remove, path])
if recursive and (file_count + subdir_count) > 0:
push_warning("[MCPTools] folder.delete recursive %s (%d files, %d subdirs)" % [
path, files_deleted, dirs_deleted])
# The target folder itself was just removed above — count it alongside any
# nested subdirectories the recursive pass deleted. Previously omitted, so a
# flat folder reported directories_deleted:0 despite the folder being gone.
dirs_deleted += 1
# Targeted deindex: update_file() on a directory path is a no-op in most
# Godot versions, so fall back to scan() for folder removal. Folder deletes
# are rare and the scan cost is acceptable.
var removal := await Helpers.ensure_file_removed(path)
var result := {
"path": path,
"recursive": recursive,
"files_deleted": files_deleted,
"directories_deleted": dirs_deleted,
"deindexed": removal["removed"],
}
if single_closed:
result["tab_closed"] = inside_scenes[0]
if not stale_tabs.is_empty():
result["stale_tabs"] = stale_tabs
if can_close:
result["hint"] = "Phantom scene tabs remain for %d file(s) inside the deleted folder. Close them one at a time via scene_close. Note: each scene_close may produce a _set_main_scene_state error in the editor console — this is benign Godot engine noise, safe to ignore." % stale_tabs.size()
else:
result["hint"] = "Phantom scene tabs remain for %d file(s) inside the deleted folder. Godot 4.2–4.4 has no API to close scene tabs — restart the editor to clear them." % stale_tabs.size()
if active_inside and not single_closed:
result["switched_to"] = outside_scenes[0]
if not warnings.is_empty():
result["warnings"] = warnings
return MCPToolkitSuccess.ok(result)
# -- Recursive delete helper --------------------------------------------------
static func _folder_delete_recursive(path: String) -> Dictionary:
var directory := DirAccess.open(path)
if directory == null:
return {"files": 0, "dirs": 0, "success": false,
"error": "DirAccess.open(%s) returned null" % path}
var files_removed := 0
var dirs_removed := 0
for file_name in directory.get_files():
if file_name.ends_with(".uid"):
continue
var full_res_path := path + "/" + file_name
var uid: int = ResourceLoader.get_resource_uid(full_res_path)
var remove_error := directory.remove(file_name)
if remove_error != OK:
return {"files": files_removed, "dirs": dirs_removed, "success": false,
"error": "DirAccess.remove %s/%s returned %d" % [path, file_name, remove_error]}
files_removed += 1
var uid_companion := file_name + ".uid"
if directory.file_exists(uid_companion):
directory.remove(uid_companion)
if uid != -1 and ResourceUID.has_id(uid):
ResourceUID.remove_id(uid)
for file_name in directory.get_files():
if file_name.ends_with(".uid"):
directory.remove(file_name)
for sub_name in directory.get_directories():
var sub_path := path + "/" + sub_name
var sub_result := _folder_delete_recursive(sub_path)
files_removed += int(sub_result.get("files", 0))
dirs_removed += int(sub_result.get("dirs", 0))
if not bool(sub_result.get("success", false)):
return {"files": files_removed, "dirs": dirs_removed, "success": false,
"error": str(sub_result.get("error", "unknown"))}
var remove_sub_error := directory.remove(sub_name)
if remove_sub_error != OK:
return {"files": files_removed, "dirs": dirs_removed, "success": false,
"error": "DirAccess.remove (subdir) %s returned %d" % [sub_path, remove_sub_error]}
dirs_removed += 1
return {"files": files_removed, "dirs": dirs_removed, "success": true, "error": ""}