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

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