@tool extends RefCounted const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd") const ClassIntrospection := preload("res://addons/godot_ai/utils/class_introspection.gd") ## Handles resource search, inspection, and assignment to nodes. const NodeHandler := preload("res://addons/godot_ai/handlers/node_handler.gd") var _undo_redo: EditorUndoRedoManager var _connection: McpConnection func _init(undo_redo: EditorUndoRedoManager, connection: McpConnection = null) -> void: _undo_redo = undo_redo _connection = connection func search_resources(params: Dictionary) -> Dictionary: var type_filter: String = params.get("type", "") var path_filter: String = params.get("path", "") if type_filter.is_empty() and path_filter.is_empty(): return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "At least one filter (type, path) is required") var efs := EditorInterface.get_resource_filesystem() if efs == null: return ErrorCodes.make(ErrorCodes.EDITOR_NOT_READY, "EditorFileSystem not available") var results: Array[Dictionary] = [] _scan_resources(efs.get_filesystem(), type_filter, path_filter, results) return {"data": {"resources": results, "count": results.size()}} func _scan_resources(dir: EditorFileSystemDirectory, type_filter: String, path_filter: String, out: Array[Dictionary]) -> void: for i in dir.get_file_count(): var file_path := dir.get_file_path(i) var file_type := dir.get_file_type(i) var matches := true if not type_filter.is_empty(): # Check if the file type matches or is a subclass of the requested type if file_type != type_filter and not ClassDB.is_parent_class(file_type, type_filter): matches = false if matches and not path_filter.is_empty(): if file_path.to_lower().find(path_filter.to_lower()) == -1: matches = false if matches: out.append({ "path": file_path, "type": file_type, }) for i in dir.get_subdir_count(): _scan_resources(dir.get_subdir(i), type_filter, path_filter, out) func load_resource(params: Dictionary) -> Dictionary: var path: String = params.get("path", "") if path.is_empty(): return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: path") var path_err = McpPathValidator.loadable_error(path, "path") if path_err != null: return path_err if not ResourceLoader.exists(path): return ErrorCodes.make(ErrorCodes.RESOURCE_NOT_FOUND, "Resource not found: %s" % path) var res: Resource = load(path) if res == null: return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Failed to load resource: %s" % path) var properties: Array[Dictionary] = [] for prop in res.get_property_list(): var usage: int = prop.get("usage", 0) if not (usage & PROPERTY_USAGE_EDITOR): continue var value = res.get(prop.name) if value == null and prop.type != TYPE_NIL: continue properties.append({ "name": prop.name, "type": type_string(prop.type), "value": NodeHandler._serialize_value(value), }) return { "data": { "path": path, "type": res.get_class(), "properties": properties, "property_count": properties.size(), } } func assign_resource(params: Dictionary) -> Dictionary: var node_path: String = params.get("path", "") var property: String = params.get("property", "") var resource_path: String = params.get("resource_path", "") if node_path.is_empty(): return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: path") if property.is_empty(): return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: property") if resource_path.is_empty(): return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: resource_path") var rpath_err = McpPathValidator.loadable_error(resource_path, "resource_path") if rpath_err != null: return rpath_err var _resolved := McpNodeValidator.resolve_or_error(node_path, "node_path") if _resolved.has("error"): return _resolved var node: Node = _resolved.node var _scene_root: Node = _resolved.scene_root # Verify property exists var found := false for prop in node.get_property_list(): if prop.name == property: found = true break if not found: return ErrorCodes.make(ErrorCodes.PROPERTY_NOT_ON_CLASS, McpPropertyErrors.build_message(node, property)) if not ResourceLoader.exists(resource_path): return ErrorCodes.make(ErrorCodes.RESOURCE_NOT_FOUND, "Resource not found: %s" % resource_path) var res: Resource = load(resource_path) if res == null: return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Failed to load resource: %s" % resource_path) var old_value = node.get(property) _undo_redo.create_action("MCP: Assign %s to %s.%s" % [resource_path.get_file(), node.name, property]) _undo_redo.add_do_property(node, property, res) _undo_redo.add_undo_property(node, property, old_value) _undo_redo.commit_action() return { "data": { "path": node_path, "property": property, "resource_path": resource_path, "resource_type": res.get_class(), "undoable": true, } } ## Instantiate a built-in Resource subclass, optionally apply `properties`, ## and either assign it to a node slot (undoable) or save it to a .tres file ## (not undoable — mirrors material_create). Exactly one home is required; ## a resource with no home would be GC'd after the handler returns. func create_resource(params: Dictionary) -> Dictionary: var type_str: String = params.get("type", "") if type_str.is_empty(): return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: type") var properties: Dictionary = params.get("properties", {}) var node_path: String = params.get("path", "") var property: String = params.get("property", "") var resource_path: String = params.get("resource_path", "") var overwrite: bool = params.get("overwrite", false) var home_err := McpResourceIO.validate_home(params) if home_err != null: return home_err var has_file_target := not resource_path.is_empty() var class_err := _validate_resource_class(type_str) if class_err != null: return class_err var instance := ClassDB.instantiate(type_str) if instance == null: return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Failed to instantiate %s" % type_str) if not (instance is Resource): return ErrorCodes.make( ErrorCodes.INTERNAL_ERROR, "Instantiated %s but result is not a Resource (got %s)" % [type_str, instance.get_class()] ) var res: Resource = instance if not properties.is_empty(): var apply_err := _apply_resource_properties(res, properties) if apply_err != null: return apply_err if has_file_target: return _save_created_resource(res, type_str, resource_path, overwrite, properties.size()) return _assign_created_resource(res, type_str, node_path, property, properties.size()) ## Validate that `type_str` names a concrete Resource subclass that we can ## instantiate. Returns an error dict on failure, or null on success. static func _validate_resource_class(type_str: String) -> Variant: if not ClassDB.class_exists(type_str): return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Unknown resource type: %s" % type_str) if ClassDB.is_parent_class(type_str, "Node"): return ErrorCodes.make( ErrorCodes.WRONG_TYPE, "%s is a Node type, not a Resource — use node_create instead" % type_str ) if not ClassDB.is_parent_class(type_str, "Resource"): var parent := ClassDB.get_parent_class(type_str) return ErrorCodes.make( ErrorCodes.WRONG_TYPE, "%s is not a Resource type (extends %s)" % [type_str, parent] ) if not ClassDB.can_instantiate(type_str): return ErrorCodes.make( ErrorCodes.WRONG_TYPE, "%s is abstract and cannot be instantiated — use a concrete subclass (e.g. BoxMesh, BoxShape3D, StyleBoxFlat)" % type_str ) return null ## Apply a dict of property values to a freshly-instantiated Resource, ## reusing NodeHandler's coercion so Vector3/Color/etc. dicts land typed. ## Returns null on success or an error dict on failure. static func _apply_resource_properties(res: Resource, properties: Dictionary) -> Variant: var prop_types := {} for prop in res.get_property_list(): prop_types[prop.name] = prop.get("type", TYPE_NIL) for key in properties.keys(): if not prop_types.has(key): var valid: Array[String] = [] for prop in res.get_property_list(): if prop.get("usage", 0) & PROPERTY_USAGE_EDITOR: valid.append(prop.name) valid.sort() var err := ErrorCodes.make( ErrorCodes.PROPERTY_NOT_ON_CLASS, "Property '%s' not found on %s. Call resource_get_info('%s') to list available properties." % [key, res.get_class(), res.get_class()] ) err["error"]["data"] = {"valid_properties": valid} return err var target_type: int = prop_types[key] if target_type == TYPE_NIL: target_type = typeof(res.get(key)) var v = properties[key] if target_type == TYPE_OBJECT and v is String: if v == "": v = null else: var vpath_err = McpPathValidator.loadable_error(v, "property '%s'" % key) if vpath_err != null: return vpath_err var loaded := ResourceLoader.load(v) if loaded == null: return ErrorCodes.make( ErrorCodes.INVALID_PARAMS, "Resource not found at path '%s' for property '%s'" % [v, key] ) v = loaded elif target_type == TYPE_OBJECT and v is Dictionary and v.has("__class__"): # Nested shortcut: the same {"__class__": "X", ...} form that # node_handler.set_property accepts, now also supported here so # resource_create/environment_create callers can populate # sub-resource slots (ShaderMaterial.shader, etc.) in one shot. var sub_type: String = v.get("__class__", "") var class_err := _validate_resource_class(sub_type) if class_err != null: return class_err var sub_instance := ClassDB.instantiate(sub_type) if sub_instance == null or not (sub_instance is Resource): return ErrorCodes.make( ErrorCodes.INTERNAL_ERROR, "Failed to instantiate %s as a Resource for property '%s'" % [sub_type, key] ) var sub_res: Resource = sub_instance var remaining: Dictionary = (v as Dictionary).duplicate() remaining.erase("__class__") if not remaining.is_empty(): var nested_err := _apply_resource_properties(sub_res, remaining) if nested_err != null: return nested_err v = sub_res else: v = NodeHandler._coerce_value(v, target_type) ## Mirror set_property's coerce check: wrong-shape dicts (#123) and ## non-dict inputs that don't land as the target compound Variant ## (#191) both error here instead of writing zero-filled Variants. var coerce_err := NodeHandler._check_coerced(v, target_type, "Property '%s'" % key) if coerce_err != null: return coerce_err res.set(key, v) return null func _assign_created_resource(res: Resource, type_str: String, node_path: String, property: String, applied_count: int) -> Dictionary: var _resolved := McpNodeValidator.resolve_or_error(node_path, "node_path") if _resolved.has("error"): return _resolved var node: Node = _resolved.node var _scene_root: Node = _resolved.scene_root var found := false var prop_type: int = TYPE_NIL for prop in node.get_property_list(): if prop.name == property: found = true prop_type = prop.get("type", TYPE_NIL) break if not found: return ErrorCodes.make( ErrorCodes.PROPERTY_NOT_ON_CLASS, "Property '%s' not found on %s" % [property, node.get_class()] ) if prop_type != TYPE_NIL and prop_type != TYPE_OBJECT: return ErrorCodes.make( ErrorCodes.PROPERTY_NOT_ON_CLASS, "Property '%s' on %s is not an Object slot (type %s)" % [property, node.get_class(), type_string(prop_type)] ) var old_value = node.get(property) _undo_redo.create_action("MCP: Create %s for %s.%s" % [type_str, node.name, property]) _undo_redo.add_do_property(node, property, res) _undo_redo.add_undo_property(node, property, old_value) _undo_redo.add_do_reference(res) _undo_redo.commit_action() return { "data": { "path": node_path, "property": property, "type": type_str, "resource_class": res.get_class(), "properties_applied": applied_count, "undoable": true, } } func _save_created_resource(res: Resource, type_str: String, resource_path: String, overwrite: bool, applied_count: int) -> Dictionary: return McpResourceIO.save_to_disk(res, resource_path, overwrite, "Resource", { "type": type_str, "resource_class": res.get_class(), "properties_applied": applied_count, }, _connection) ## Introspect a Resource class — return its editor-visible properties, parent, ## whether it's abstract, and (for abstract bases) the list of concrete ## subclasses that resource_create can instantiate. Read-only. func get_resource_info(params: Dictionary) -> Dictionary: var type_str: String = params.get("type", "") if type_str.is_empty(): return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: type") if not ClassDB.class_exists(type_str): return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Unknown resource type: %s" % type_str) if ClassDB.is_parent_class(type_str, "Node"): return ErrorCodes.make( ErrorCodes.WRONG_TYPE, "%s is a Node type, not a Resource — use node_* tools for node introspection" % type_str ) if not ClassDB.is_parent_class(type_str, "Resource") and type_str != "Resource": var parent := ClassDB.get_parent_class(type_str) return ErrorCodes.make( ErrorCodes.WRONG_TYPE, "%s is not a Resource type (extends %s)" % [type_str, parent] ) var can_instantiate: bool = ClassDB.can_instantiate(type_str) var class_info := ClassIntrospection.build(type_str, { "sections": ["properties"], "include_inherited": true, "include_inheritors": not can_instantiate, "limit": 0, }) var data: Dictionary = { "type": type_str, "parent_class": class_info.parent_class, "can_instantiate": can_instantiate, "is_abstract": not can_instantiate, "properties": class_info.properties, "property_count": class_info.property_count, } # For abstract bases (Shape3D, Material, Texture, StyleBox, ...) surface # the concrete Resource subclasses an agent could try next. if not can_instantiate: data["concrete_subclasses"] = class_info.concrete_inheritors return {"data": data}