diff --git a/MCPForUnity/Editor/Tools/ManageComponents.cs b/MCPForUnity/Editor/Tools/ManageComponents.cs index 3fef4f305..a794e77d8 100644 --- a/MCPForUnity/Editor/Tools/ManageComponents.cs +++ b/MCPForUnity/Editor/Tools/ManageComponents.cs @@ -12,7 +12,7 @@ namespace MCPForUnity.Editor.Tools { /// /// Tool for managing components on GameObjects. - /// Actions: add, remove, set_property + /// Actions: add, remove, set_property, get_referenceable, set_reference, batch_wire /// /// This is a focused tool for component lifecycle operations. /// For reading component data, use the unity://scene/gameobject/{id}/components resource. @@ -35,7 +35,7 @@ public static object HandleCommand(JObject @params) string action = ParamCoercion.CoerceString(@params["action"], null)?.ToLowerInvariant(); if (string.IsNullOrEmpty(action)) { - return new ErrorResponse("'action' parameter is required (add, remove, set_property)."); + return new ErrorResponse("'action' parameter is required (add, remove, set_property, get_referenceable, set_reference, batch_wire)."); } // Target resolution @@ -54,7 +54,10 @@ public static object HandleCommand(JObject @params) "add" => AddComponent(@params, targetToken, searchMethod), "remove" => RemoveComponent(@params, targetToken, searchMethod), "set_property" => SetProperty(@params, targetToken, searchMethod), - _ => new ErrorResponse($"Unknown action: '{action}'. Supported actions: add, remove, set_property") + "get_referenceable" => GetReferenceable(@params, targetToken, searchMethod), + "set_reference" => SetReference(@params, targetToken, searchMethod), + "batch_wire" => BatchWire(@params, targetToken, searchMethod), + _ => new ErrorResponse($"Unknown action: '{action}'. Supported actions: add, remove, set_property, get_referenceable, set_reference, batch_wire") }; } catch (Exception e) @@ -268,6 +271,245 @@ private static object SetProperty(JObject @params, JToken targetToken, string se } } + private static object GetReferenceable(JObject @params, JToken targetToken, string searchMethod) + { + if (!TryGetComponentAndObjectReferenceProperty(@params, targetToken, searchMethod, "get_referenceable", + out GameObject targetGo, out Component component, out SerializedObject serializedObject, out SerializedProperty property, out Type expectedType, out ErrorResponse error)) + { + return error; + } + + bool includeScene = ParamCoercion.CoerceBool(@params["include_scene"] ?? @params["includeScene"], true); + bool includeAssets = ParamCoercion.CoerceBool(@params["include_assets"] ?? @params["includeAssets"], true); + int limit = ParamCoercion.CoerceInt(@params["limit"], 0); + + var sceneObjects = includeScene + ? FindReferenceableSceneObjects(expectedType, limit).ToList() + : new List(); + var assets = includeAssets + ? FindReferenceableAssets(expectedType, limit).ToList() + : new List(); + + return new + { + success = true, + message = $"Referenceable targets resolved for property '{property.propertyPath}' on '{component.GetType().Name}'.", + data = new + { + expected_type = expectedType?.FullName, + current_value = DescribeObjectReference(property.objectReferenceValue), + scene_objects = sceneObjects, + assets = assets + } + }; + } + + private static object SetReference(JObject @params, JToken targetToken, string searchMethod) + { + if (!TryGetComponentAndObjectReferenceProperty(@params, targetToken, searchMethod, "set_reference", + out GameObject targetGo, out Component component, out SerializedObject serializedObject, out SerializedProperty property, out Type expectedType, out ErrorResponse error)) + { + return error; + } + + var validation = ValidateReferenceAssignment(component, property.propertyPath, expectedType, @params); + if (!validation.Success) + { + return new ErrorResponse(validation.Error); + } + + var previousValue = DescribeObjectReference(property.objectReferenceValue); + + ApplyReferenceToProperty(component, serializedObject, property, validation.ResolvedObject); + EditorUtility.SetDirty(component); + MarkOwningSceneDirty(targetGo); + + return new + { + success = true, + message = $"Reference '{property.propertyPath}' updated on component '{component.GetType().Name}' on '{targetGo.name}'.", + data = new + { + property = property.propertyPath, + previous_value = previousValue, + new_value = DescribeObjectReference(validation.ResolvedObject) + } + }; + } + + private static object BatchWire(JObject @params, JToken targetToken, string searchMethod) + { + GameObject targetGo = FindTarget(targetToken, searchMethod); + if (targetGo == null) + { + return new ErrorResponse($"Target GameObject ('{targetToken}') not found using method '{searchMethod ?? "default"}'."); + } + + string componentType = ParamCoercion.CoerceString(@params["componentType"] ?? @params["component_type"], null); + if (string.IsNullOrEmpty(componentType)) + { + return new ErrorResponse("'componentType' parameter is required for 'batch_wire' action."); + } + + Type type = UnityTypeResolver.ResolveComponent(componentType); + if (type == null) + { + return new ErrorResponse($"Component type '{componentType}' not found."); + } + + Component component = targetGo.GetComponent(type); + if (component == null) + { + return new ErrorResponse($"Component '{componentType}' not found on '{targetGo.name}'."); + } + + JArray references = @params["references"] as JArray; + if (references == null || !references.HasValues) + { + return new ErrorResponse("'references' array is required for 'batch_wire' action."); + } + + bool atomic = ParamCoercion.CoerceBool(@params["atomic"], true); + var validations = new List(); + var results = new List(); + + foreach (JToken referenceToken in references) + { + JObject referenceParams = referenceToken as JObject; + if (referenceParams == null) + { + results.Add(new BatchWireResult + { + Success = false, + Error = "Each reference entry must be an object." + }); + continue; + } + + string propertyName = ParamCoercion.CoerceString(referenceParams["property_name"] ?? referenceParams["property"], null); + if (string.IsNullOrEmpty(propertyName)) + { + results.Add(new BatchWireResult + { + Success = false, + Error = "Each reference entry requires 'property_name'." + }); + continue; + } + + Type expectedType = GetFieldType(component, propertyName); + if (expectedType == null) + { + results.Add(new BatchWireResult + { + Property = propertyName, + Success = false, + Error = $"Property '{propertyName}' was not found or is not an object reference." + }); + continue; + } + + var validation = ValidateReferenceAssignment(component, propertyName, expectedType, referenceParams); + validations.Add(validation); + results.Add(new BatchWireResult + { + Property = propertyName, + Success = validation.Success, + Error = validation.Error, + NewValue = validation.Success ? DescribeObjectReference(validation.ResolvedObject) : null + }); + } + + if (atomic && results.Any(r => !r.Success)) + { + return new + { + success = false, + message = "Batch wire validation failed. No references were applied.", + data = new + { + total = references.Count, + succeeded = 0, + failed = results.Count(r => !r.Success), + results = results.Select(r => r.ToResponse()) + } + }; + } + + int undoGroup = Undo.GetCurrentGroup(); + Undo.SetCurrentGroupName($"Batch wire references on {component.GetType().Name}"); + int succeeded = 0; + int failed = 0; + var serializedObject = new SerializedObject(component); + + try + { + foreach (var validation in validations) + { + if (!validation.Success) + { + failed++; + continue; + } + + serializedObject.Update(); + var property = serializedObject.FindProperty(validation.PropertyName); + if (property == null || property.propertyType != SerializedPropertyType.ObjectReference) + { + var result = results.FirstOrDefault(r => r.Property == validation.PropertyName); + if (result != null) + { + result.Success = false; + result.Error = $"Property '{validation.PropertyName}' was not found or is not an object reference during apply."; + result.NewValue = null; + } + failed++; + continue; + } + + ApplyReferenceToProperty(component, serializedObject, property, validation.ResolvedObject); + var successResult = results.FirstOrDefault(r => r.Property == validation.PropertyName); + if (successResult != null) + { + successResult.Success = true; + successResult.Error = null; + successResult.NewValue = DescribeObjectReference(property.objectReferenceValue); + } + succeeded++; + } + + if (succeeded > 0) + { + EditorUtility.SetDirty(component); + MarkOwningSceneDirty(targetGo); + } + } + finally + { + Undo.CollapseUndoOperations(undoGroup); + } + + if (!atomic) + { + failed = results.Count(r => !r.Success); + } + + return new + { + success = failed == 0, + message = failed == 0 + ? $"Batch wired {succeeded} reference(s) on component '{component.GetType().Name}'." + : $"Batch wire completed with {failed} failure(s) on component '{component.GetType().Name}'.", + data = new + { + total = references.Count, + succeeded = succeeded, + failed = failed, + results = results.Select(r => r.ToResponse()) + } + }; + } + #endregion #region Helpers @@ -386,6 +628,357 @@ private static string TrySetProperty(Component component, string propertyName, J return error; } + private static bool TryGetComponentAndObjectReferenceProperty(JObject @params, JToken targetToken, string searchMethod, string actionName, + out GameObject targetGo, out Component component, out SerializedObject serializedObject, out SerializedProperty property, out Type expectedType, out ErrorResponse error) + { + targetGo = null; + component = null; + serializedObject = null; + property = null; + expectedType = null; + error = null; + + targetGo = FindTarget(targetToken, searchMethod); + if (targetGo == null) + { + error = new ErrorResponse($"Target GameObject ('{targetToken}') not found using method '{searchMethod ?? "default"}'."); + return false; + } + + string componentType = ParamCoercion.CoerceString(@params["componentType"] ?? @params["component_type"], null); + if (string.IsNullOrEmpty(componentType)) + { + error = new ErrorResponse($"'componentType' parameter is required for '{actionName}' action."); + return false; + } + + Type type = UnityTypeResolver.ResolveComponent(componentType); + if (type == null) + { + error = new ErrorResponse($"Component type '{componentType}' not found."); + return false; + } + + component = targetGo.GetComponent(type); + if (component == null) + { + error = new ErrorResponse($"Component '{componentType}' not found on '{targetGo.name}'."); + return false; + } + + string propertyName = ParamCoercion.CoerceString(@params["property"] ?? @params["property_name"], null); + if (string.IsNullOrEmpty(propertyName)) + { + error = new ErrorResponse($"'property' parameter is required for '{actionName}' action."); + return false; + } + + serializedObject = new SerializedObject(component); + property = serializedObject.FindProperty(propertyName); + if (property == null) + { + error = new ErrorResponse($"Property '{propertyName}' not found on component '{component.GetType().Name}'."); + return false; + } + + if (property.propertyType != SerializedPropertyType.ObjectReference) + { + error = new ErrorResponse($"Property '{propertyName}' on component '{component.GetType().Name}' is not an object reference."); + return false; + } + + expectedType = GetFieldType(component, propertyName); + if (expectedType == null) + { + error = new ErrorResponse($"Unable to determine reference type for property '{propertyName}' on component '{component.GetType().Name}'."); + return false; + } + + return true; + } + + /// + /// Applies a resolved object reference to a SerializedProperty with Undo support. + /// Shared by SetReference and BatchWire to avoid duplication. + /// + private static void ApplyReferenceToProperty(Component component, SerializedObject serializedObject, SerializedProperty property, UnityEngine.Object resolvedObject) + { + Undo.RecordObject(component, $"Set reference {property.propertyPath}"); + property.objectReferenceValue = resolvedObject; + serializedObject.ApplyModifiedProperties(); + } + + private static Type GetFieldType(Component component, string propertyName) + { + var so = new SerializedObject(component); + var prop = so.FindProperty(propertyName); + if (prop == null || prop.propertyType != SerializedPropertyType.ObjectReference) + return null; + + BindingFlags flags = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.IgnoreCase; + + // Handle array element paths like "targets.Array.data[0]" + string fieldName = propertyName; + bool isArrayElement = propertyName.Contains(".Array.data["); + if (isArrayElement) + { + // Extract the root field name before .Array.data[ + fieldName = propertyName.Substring(0, propertyName.IndexOf(".Array.data[")); + } + + string normalizedName = ParamCoercion.NormalizePropertyName(fieldName); + var field = component.GetType().GetField(fieldName, flags) + ?? component.GetType().GetField(normalizedName, flags); + + if (field == null) + return null; + + Type fieldType = field.FieldType; + + // For array/list elements, extract the element type + if (isArrayElement) + { + if (fieldType.IsArray) + return fieldType.GetElementType(); + if (fieldType.IsGenericType && fieldType.GetGenericTypeDefinition() == typeof(List<>)) + return fieldType.GetGenericArguments()[0]; + } + + return fieldType; + } + + private static UnityEngine.Object ResolveReference(JObject refParams) + { + int instanceId = ParamCoercion.CoerceInt(refParams["reference_instance_id"], 0); + if (instanceId != 0) + return GameObjectLookup.ResolveInstanceID(instanceId); + + string assetPath = ParamCoercion.CoerceString(refParams["reference_asset_path"], null); + if (!string.IsNullOrEmpty(assetPath)) + return AssetDatabase.LoadAssetAtPath(assetPath); + + string refPath = ParamCoercion.CoerceString(refParams["reference_path"], null); + if (!string.IsNullOrEmpty(refPath)) + return GameObjectLookup.FindByTarget(new JValue(refPath), "by_path", true) ?? GameObject.Find(refPath); + + return null; + } + + private static IEnumerable FindReferenceableSceneObjects(Type expectedType, int limit) + { + int count = 0; + foreach (var gameObject in GameObjectLookup.GetAllSceneObjects(true)) + { + var candidate = ConvertResolvedObject(gameObject, expectedType); + if (candidate == null) + continue; + + yield return DescribeObjectReference(candidate); + count++; + if (limit > 0 && count >= limit) + yield break; + } + } + + private static IEnumerable FindReferenceableAssets(Type expectedType, int limit) + { + int count = 0; + var seen = new HashSet(); + + foreach (string guid in AssetDatabase.FindAssets(BuildAssetSearchFilter(expectedType))) + { + string assetPath = AssetDatabase.GUIDToAssetPath(guid); + foreach (var candidate in LoadReferenceableAssetsAtPath(assetPath, expectedType)) + { + if (candidate == null) + continue; + + int instanceId = candidate.GetInstanceID(); + if (!seen.Add(instanceId)) + continue; + + yield return DescribeObjectReference(candidate); + count++; + if (limit > 0 && count >= limit) + yield break; + } + } + } + + private static string BuildAssetSearchFilter(Type expectedType) + { + if (expectedType == null) + return string.Empty; + + if (typeof(Component).IsAssignableFrom(expectedType)) + return "t:Prefab"; + + return $"t:{expectedType.Name}"; + } + + private static IEnumerable LoadReferenceableAssetsAtPath(string assetPath, Type expectedType) + { + if (typeof(Component).IsAssignableFrom(expectedType)) + { + GameObject prefab = AssetDatabase.LoadAssetAtPath(assetPath); + if (prefab == null) + yield break; + + Component component = prefab.GetComponent(expectedType); + if (component != null) + yield return component; + yield break; + } + + foreach (var asset in AssetDatabase.LoadAllAssetsAtPath(assetPath)) + { + if (asset != null && expectedType.IsAssignableFrom(asset.GetType())) + yield return asset; + } + } + + private static ReferenceAssignmentValidation ValidateReferenceAssignment(Component component, string propertyName, Type expectedType, JObject refParams) + { + var serializedObject = new SerializedObject(component); + var property = serializedObject.FindProperty(propertyName); + if (property == null || property.propertyType != SerializedPropertyType.ObjectReference) + { + return ReferenceAssignmentValidation.Fail(propertyName, $"Property '{propertyName}' was not found or is not an object reference."); + } + + bool clear = ParamCoercion.CoerceBool(refParams["clear"], false); + if (clear) + { + return ReferenceAssignmentValidation.Ok(propertyName, null); + } + + if (!HasReferenceSelector(refParams)) + { + return ReferenceAssignmentValidation.Fail(propertyName, $"Property '{propertyName}' requires one of: reference_path, reference_asset_path, reference_instance_id, or clear."); + } + + UnityEngine.Object resolved = ResolveReference(refParams); + if (resolved == null) + { + return ReferenceAssignmentValidation.Fail(propertyName, $"Failed to resolve reference target for property '{propertyName}'."); + } + + UnityEngine.Object converted = ConvertResolvedObject(resolved, expectedType); + if (converted == null) + { + return ReferenceAssignmentValidation.Fail(propertyName, + $"Resolved reference '{resolved.name}' is not assignable to '{expectedType.FullName}' for property '{propertyName}'."); + } + + return ReferenceAssignmentValidation.Ok(propertyName, converted); + } + + private static bool HasReferenceSelector(JObject refParams) + { + return refParams["reference_path"] != null + || refParams["reference_asset_path"] != null + || refParams["reference_instance_id"] != null + || ParamCoercion.CoerceBool(refParams["clear"], false); + } + + private static UnityEngine.Object ConvertResolvedObject(UnityEngine.Object resolved, Type expectedType) + { + if (resolved == null || expectedType == null) + return null; + + if (expectedType.IsAssignableFrom(resolved.GetType())) + return resolved; + + if (resolved is GameObject gameObject) + { + if (expectedType == typeof(GameObject)) + return gameObject; + + if (typeof(Component).IsAssignableFrom(expectedType)) + return gameObject.GetComponent(expectedType); + } + + if (resolved is Component component) + { + if (expectedType == typeof(GameObject)) + return component.gameObject; + + if (expectedType.IsAssignableFrom(component.GetType())) + return component; + } + + return null; + } + + private static object DescribeObjectReference(UnityEngine.Object obj) + { + if (obj == null) + return null; + + string assetPath = AssetDatabase.GetAssetPath(obj); + GameObject gameObject = obj as GameObject; + if (obj is Component component) + { + gameObject = component.gameObject; + } + + return new + { + instance_id = obj.GetInstanceID(), + name = obj.name, + type = obj.GetType().FullName, + path = gameObject != null ? GameObjectLookup.GetGameObjectPath(gameObject) : null, + asset_path = string.IsNullOrEmpty(assetPath) ? null : assetPath + }; + } + + private sealed class ReferenceAssignmentValidation + { + public string PropertyName { get; private set; } + public bool Success { get; private set; } + public string Error { get; private set; } + public UnityEngine.Object ResolvedObject { get; private set; } + + public static ReferenceAssignmentValidation Ok(string propertyName, UnityEngine.Object resolvedObject) + { + return new ReferenceAssignmentValidation + { + PropertyName = propertyName, + Success = true, + ResolvedObject = resolvedObject + }; + } + + public static ReferenceAssignmentValidation Fail(string propertyName, string error) + { + return new ReferenceAssignmentValidation + { + PropertyName = propertyName, + Success = false, + Error = error + }; + } + } + + private sealed class BatchWireResult + { + public string Property { get; set; } + public bool Success { get; set; } + public string Error { get; set; } + public object NewValue { get; set; } + + public object ToResponse() + { + return new + { + property = Property, + success = Success, + error = Error, + new_value = NewValue + }; + } + } + #endregion } } diff --git a/Server/src/services/tools/manage_components.py b/Server/src/services/tools/manage_components.py index f99470c62..ee4bc0dbe 100644 --- a/Server/src/services/tools/manage_components.py +++ b/Server/src/services/tools/manage_components.py @@ -1,8 +1,8 @@ """ Tool for managing components on GameObjects in Unity. -Supports add, remove, and set_property operations. +Supports add, remove, set_property, get_referenceable, set_reference, and batch_wire operations. """ -from typing import Annotated, Any, Literal +from typing import Annotated, Any, Literal, Optional from fastmcp import Context from services.registry import mcp_for_unity_tool @@ -15,8 +15,9 @@ @mcp_for_unity_tool( description=( - "Add, remove, or set properties on components attached to GameObjects. " - "Actions: add, remove, set_property. Requires target (instance ID or name) and component_type. " + "Add, remove, set properties, inspect referenceable targets, or wire object references on " + "components attached to GameObjects. Actions: add, remove, set_property, get_referenceable, " + "set_reference, batch_wire. Requires target (instance ID or name) and component_type. " "For READING component data, use the mcpforunity://scene/gameobject/{id}/components resource " "or mcpforunity://scene/gameobject/{id}/component/{name} for a single component. " "For creating/deleting GameObjects themselves, use manage_gameobject instead." @@ -25,8 +26,10 @@ async def manage_components( ctx: Context, action: Annotated[ - Literal["add", "remove", "set_property"], - "Action to perform: add (add component), remove (remove component), set_property (set component property)" + Literal["add", "remove", "set_property", "get_referenceable", "set_reference", "batch_wire"], + "Action to perform: add (add component), remove (remove component), set_property (set component property), " + "get_referenceable (list valid object reference targets), set_reference (assign or clear an object reference), " + "batch_wire (assign multiple object references)" ], target: Annotated[ str | int, @@ -54,6 +57,42 @@ async def manage_components( dict[str, Any] | str, "Dictionary of property names to values. Example: {\"mass\": 5.0, \"useGravity\": false}" ] | None = None, + reference_path: Annotated[ + Optional[str], + "Path to the reference target GameObject (for set_reference or batch_wire)" + ] = None, + reference_asset_path: Annotated[ + Optional[str], + "Asset path to the reference target asset (for set_reference or batch_wire)" + ] = None, + reference_instance_id: Annotated[ + Optional[int], + "Instance ID of the reference target object (for set_reference or batch_wire)" + ] = None, + references: Annotated[ + Optional[list[dict]], + "Batch wiring list for batch_wire, each item containing property_name and one reference selector" + ] = None, + include_scene: Annotated[ + Optional[bool], + "Whether to include matching scene objects for get_referenceable" + ] = True, + include_assets: Annotated[ + Optional[bool], + "Whether to include matching project assets for get_referenceable" + ] = True, + limit: Annotated[ + Optional[int], + "Maximum number of get_referenceable results to return" + ] = None, + clear: Annotated[ + Optional[bool], + "Clear the object reference instead of assigning a new target" + ] = None, + atomic: Annotated[ + Optional[bool], + "Whether batch_wire should validate all references before applying any changes" + ] = True, ) -> dict[str, Any]: """ Manage components on GameObjects. @@ -62,6 +101,9 @@ async def manage_components( - add: Add a new component to a GameObject - remove: Remove a component from a GameObject - set_property: Set one or more properties on a component + - get_referenceable: List valid reference targets for an object reference property + - set_reference: Assign or clear an object reference property + - batch_wire: Assign or clear multiple object reference properties Examples: - Add Rigidbody: action="add", target="Player", component_type="Rigidbody" @@ -78,7 +120,7 @@ async def manage_components( if not action: return { "success": False, - "message": "Missing required parameter 'action'. Valid actions: add, remove, set_property" + "message": "Missing required parameter 'action'. Valid actions: add, remove, set_property, get_referenceable, set_reference, batch_wire" } if not target: @@ -122,6 +164,33 @@ async def manage_components( if action == "add" and properties: params["properties"] = properties + if action in ("get_referenceable", "set_reference", "batch_wire") and property: + params["property"] = property + + if action in ("set_reference", "batch_wire"): + if reference_path is not None: + params["reference_path"] = reference_path + if reference_asset_path is not None: + params["reference_asset_path"] = reference_asset_path + if reference_instance_id is not None: + params["reference_instance_id"] = reference_instance_id + if clear is not None: + params["clear"] = clear + + if action == "get_referenceable": + if include_scene is not None: + params["include_scene"] = include_scene + if include_assets is not None: + params["include_assets"] = include_assets + if limit is not None: + params["limit"] = limit + + if action == "batch_wire": + if references is not None: + params["references"] = references + if atomic is not None: + params["atomic"] = atomic + response = await send_with_unity_instance( async_send_command_with_retry, unity_instance,