230 lines
10 KiB
GDScript
230 lines
10 KiB
GDScript
@tool
|
|
extends RefCounted
|
|
## resource.* command handlers — load, write (create/update upsert), delete for .tres/.res files.
|
|
|
|
const Modules := preload("res://addons/godot_mcp_toolkit/core/modules.gd")
|
|
const Coerce = Modules.Coerce
|
|
const FileGuard = Modules.FileGuard
|
|
const Untrusted = Modules.Untrusted
|
|
const Helpers = Modules.CommandHelpers
|
|
|
|
const RESOURCE_SKIP_PROPERTIES: Array[String] = [
|
|
"image", "mesh_arrays", "surface_arrays", "_data",
|
|
]
|
|
|
|
|
|
static func register(registry: MCPToolkitCommandRegistry, _server: Node) -> void:
|
|
registry.add("resource.load", func(parameters: Dictionary) -> Dictionary:
|
|
return _cmd_resource_load(parameters)
|
|
, MCPToolkitCommandOptions.new().mark_read_only().mark_scene_independent())
|
|
registry.add("resource.write", func(parameters: Dictionary) -> Dictionary:
|
|
return await _cmd_resource_write(parameters)
|
|
, MCPToolkitCommandOptions.new().mark_scene_independent())
|
|
registry.add("resource.delete", func(parameters: Dictionary) -> Dictionary:
|
|
return await _cmd_resource_delete(parameters)
|
|
, MCPToolkitCommandOptions.new().mark_scene_independent())
|
|
|
|
|
|
# -- Helpers ------------------------------------------------------------------
|
|
|
|
|
|
static func _property_names_of(object: Object) -> Dictionary:
|
|
var names := {}
|
|
for property in object.get_property_list():
|
|
var property_name := str(property.get("name", ""))
|
|
if not property_name.is_empty():
|
|
names[property_name] = true
|
|
return names
|
|
|
|
|
|
static func _apply_resource_properties(
|
|
resource: Resource, properties: Dictionary, resource_class: String,
|
|
) -> Array[String]:
|
|
var warnings: Array[String] = []
|
|
var valid := _property_names_of(resource)
|
|
for key in properties.keys():
|
|
var key_string := str(key)
|
|
var raw_value = properties[key]
|
|
var missing := Coerce.check_resource_paths(raw_value)
|
|
if missing != "":
|
|
warnings.append(
|
|
"property '%s': resource not found at %s; value left unchanged" % [key_string, missing])
|
|
continue
|
|
var coerced = Coerce.coerce_value(raw_value)
|
|
if typeof(coerced) == TYPE_DICTIONARY and (coerced as Dictionary).has("_coerce_error"):
|
|
warnings.append(
|
|
"property '%s': %s; value left unchanged" % [key_string, str(coerced["_coerce_error"])])
|
|
continue
|
|
# Compound paths (e.g. "sources/0", "tiles/0:0/0") route through
|
|
# Object._set() which many built-in types (TileSet, AnimationLibrary)
|
|
# use for sub-resource slots not exposed in get_property_list().
|
|
if "/" in key_string:
|
|
var before = resource.get(key_string)
|
|
resource.set(key_string, coerced)
|
|
var after = resource.get(key_string)
|
|
if typeof(after) == typeof(before) and after == before:
|
|
if resource_class == "ShaderMaterial" and key_string.begins_with("shader_parameter/"):
|
|
# shader_parameter/<name> is an inspector-only projection; Object.set()
|
|
# on it does not reliably route to set_shader_parameter, and no-ops when
|
|
# the uniform isn't declared or no shader is assigned.
|
|
warnings.append(
|
|
"property '%s' on ShaderMaterial: set() had no effect. Set shader uniforms via set_shader_parameter(name, value); ensure the ShaderMaterial has a shader assigned and the uniform is declared. The shader_parameter/<name> path is inspector-only." % key_string)
|
|
else:
|
|
warnings.append(
|
|
"property '%s' on %s: set() had no effect (may need a different API)" % [key_string, resource_class])
|
|
elif not valid.has(key_string):
|
|
warnings.append(
|
|
"property '%s' unknown on %s; value ignored" % [key_string, resource_class])
|
|
else:
|
|
resource.set(key_string, coerced)
|
|
return warnings
|
|
|
|
|
|
# -- Commands -----------------------------------------------------------------
|
|
|
|
|
|
static func _cmd_resource_load(parameters: Dictionary) -> Dictionary:
|
|
var err = MCPToolkitError.require(parameters, ["file_path"])
|
|
if err != null:
|
|
return err
|
|
var file_path := str(parameters.get("file_path", ""))
|
|
var guard := FileGuard.resolve_safe(file_path)
|
|
if guard["error"] != null:
|
|
return MCPToolkitError.fail("PATH_DENIED", str(guard["reason"]))
|
|
if not ResourceLoader.exists(file_path):
|
|
return MCPToolkitError.fail("NOT_FOUND", "resource not found: %s" % file_path, MCPToolkitError.HINT_FILE_PATH)
|
|
var resource := ResourceLoader.load(file_path)
|
|
if resource == null:
|
|
return MCPToolkitError.fail("LOAD_FAILED",
|
|
"ResourceLoader returned null for %s" % file_path)
|
|
var resource_class := resource.get_class()
|
|
var properties := {}
|
|
for property in resource.get_property_list():
|
|
var usage: int = int(property.get("usage", 0))
|
|
if not (usage & PROPERTY_USAGE_EDITOR):
|
|
continue
|
|
var property_name := str(property.get("name", ""))
|
|
if property_name.is_empty() or property_name.begins_with("_"):
|
|
continue
|
|
if property_name in RESOURCE_SKIP_PROPERTIES:
|
|
continue
|
|
properties[property_name] = Coerce.serialize_value(resource.get(property_name))
|
|
var metadata := {}
|
|
if resource is Texture2D:
|
|
metadata["width"] = resource.get_width()
|
|
metadata["height"] = resource.get_height()
|
|
return MCPToolkitSuccess.ok({
|
|
"class": resource_class,
|
|
"path": file_path,
|
|
"properties": Untrusted.wrap(
|
|
"resource", file_path, JSON.stringify(properties)),
|
|
"metadata": metadata,
|
|
})
|
|
|
|
|
|
static func _cmd_resource_write(parameters: Dictionary) -> Dictionary:
|
|
var err = MCPToolkitError.require(parameters, ["file_path"])
|
|
if err != null:
|
|
return err
|
|
var file_path := str(parameters.get("file_path", ""))
|
|
var guard := FileGuard.resolve_safe(file_path)
|
|
if guard["error"] != null:
|
|
return MCPToolkitError.fail("PATH_DENIED", str(guard["reason"]))
|
|
var extension := file_path.get_extension().to_lower()
|
|
if not (extension in ["tres", "res"]):
|
|
return MCPToolkitError.fail("INVALID_PATH",
|
|
"resource.write only writes .tres/.res files (got %s); use script.write for .gd/.cs" % file_path)
|
|
var properties: Dictionary = parameters.get("properties", {}) \
|
|
if typeof(parameters.get("properties", {})) == TYPE_DICTIONARY else {}
|
|
if FileAccess.file_exists(file_path):
|
|
var resource := ResourceLoader.load(file_path)
|
|
if resource == null:
|
|
return MCPToolkitError.fail("NOT_A_RESOURCE",
|
|
"file at %s is not a readable Resource" % file_path)
|
|
var resource_class := resource.get_class()
|
|
var warnings := _apply_resource_properties(resource, properties, resource_class)
|
|
# type only selects the class when creating; an existing file keeps its own
|
|
# class. Disclose a passed type so it doesn't read as a silent retype.
|
|
if parameters.has("type"):
|
|
warnings.append("ignored type '%s' — resource already exists; type only applies when creating" % str(parameters.get("type", "")))
|
|
var save_error := ResourceSaver.save(resource, file_path)
|
|
if save_error != OK:
|
|
return MCPToolkitError.fail("SAVE_FAILED",
|
|
"ResourceSaver.save returned %d (path=%s)" % [save_error, file_path])
|
|
# Refresh cache so subsequent ResourceRef loads get the updated version
|
|
ResourceLoader.load(file_path, "", ResourceLoader.CACHE_MODE_REPLACE)
|
|
var update_index := await Helpers.ensure_file_indexed(file_path)
|
|
return MCPToolkitSuccess.ok({
|
|
"path": file_path,
|
|
"resource_class": resource_class,
|
|
"warnings": warnings,
|
|
"indexed": update_index["indexed"],
|
|
})
|
|
var resource_class := str(parameters.get("type", ""))
|
|
if resource_class.is_empty():
|
|
return MCPToolkitError.fail("NOT_FOUND",
|
|
"resource not found at %s; provide 'type' to create it" % file_path, MCPToolkitError.HINT_FILE_PATH)
|
|
var dir_result := Helpers.ensure_parent_dir(file_path, "resource.write")
|
|
if dir_result.has("error"):
|
|
return dir_result
|
|
var dirs_created: bool = dir_result["dirs_created"]
|
|
var rk := Helpers.resolve_class_kind(resource_class)
|
|
var resolved_kind: String = rk["kind"]
|
|
var global_entry: Dictionary = rk["entry"]
|
|
if resolved_kind.is_empty():
|
|
return MCPToolkitError.fail("INVALID_CLASS",
|
|
"unknown class %s; check ClassDB or ProjectSettings global class list" % resource_class, MCPToolkitError.HINT_CLASS_NAME)
|
|
if not Helpers.class_descends_from(resource_class, "Resource"):
|
|
return MCPToolkitError.fail("NOT_A_RESOURCE",
|
|
"%s is not a Resource subclass (base chain: %s)" % [
|
|
resource_class, Helpers.class_base_chain(resource_class)])
|
|
var resource: Resource = null
|
|
if resolved_kind == "native":
|
|
resource = ClassDB.instantiate(resource_class)
|
|
else:
|
|
var script_path := str(global_entry.get("path", ""))
|
|
var script = load(script_path)
|
|
if script == null:
|
|
return MCPToolkitError.fail("INVALID_CLASS",
|
|
"could not load script for %s at %s" % [resource_class, script_path])
|
|
resource = script.new()
|
|
if resource == null:
|
|
return MCPToolkitError.fail("INVALID_CLASS",
|
|
"instantiation returned null for %s" % resource_class)
|
|
var warnings := _apply_resource_properties(resource, properties, resource_class)
|
|
var save_error := ResourceSaver.save(resource, file_path)
|
|
if save_error != OK:
|
|
return MCPToolkitError.fail("SAVE_FAILED",
|
|
"ResourceSaver.save returned %d (path=%s)" % [save_error, file_path])
|
|
# Refresh cache so subsequent ResourceRef loads get the new resource
|
|
ResourceLoader.load(file_path, "", ResourceLoader.CACHE_MODE_REPLACE)
|
|
var create_index := await Helpers.ensure_file_indexed(file_path)
|
|
var create_result := {
|
|
"status": "created",
|
|
"path": file_path,
|
|
"resource_class": resource_class,
|
|
"warnings": warnings,
|
|
"indexed": create_index["indexed"],
|
|
}
|
|
if dirs_created:
|
|
create_result["dirs_created"] = true
|
|
return MCPToolkitSuccess.ok(create_result)
|
|
|
|
|
|
static func _cmd_resource_delete(parameters: Dictionary) -> Dictionary:
|
|
var err = MCPToolkitError.require(parameters, ["file_path"])
|
|
if err != null:
|
|
return err
|
|
var file_path := str(parameters.get("file_path", ""))
|
|
var guard := FileGuard.resolve_safe(file_path)
|
|
if guard["error"] != null:
|
|
return MCPToolkitError.fail("PATH_DENIED", str(guard["reason"]))
|
|
var extension := file_path.get_extension().to_lower()
|
|
if not (extension in ["tres", "res"]):
|
|
return MCPToolkitError.fail("INVALID_PATH",
|
|
"resource.delete only removes .tres or .res files (got %s); use scene.delete for .tscn, script.delete for .gd/.cs/.gdshader/.gdshaderinc, or a different tool for other file types" % file_path)
|
|
if not FileAccess.file_exists(file_path):
|
|
return MCPToolkitError.fail("NOT_FOUND", "no file at %s" % file_path, MCPToolkitError.HINT_FILE_PATH)
|
|
var delete_result: Dictionary = await Helpers.delete_res_file_and_deindex(file_path)
|
|
return delete_result
|