diff --git a/UnityMcpBridge/Editor/Tools/ManageGameObject.cs b/UnityMcpBridge/Editor/Tools/ManageGameObject.cs index 691ade6..75c4a62 100644 --- a/UnityMcpBridge/Editor/Tools/ManageGameObject.cs +++ b/UnityMcpBridge/Editor/Tools/ManageGameObject.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; using System.Linq; using System.Reflection; +using Newtonsoft.Json; // Added for JsonSerializationException using Newtonsoft.Json.Linq; using UnityEditor; using UnityEditor.SceneManagement; @@ -21,6 +22,11 @@ namespace UnityMcpBridge.Editor.Tools public static object HandleCommand(JObject @params) { + // --- DEBUG --- Log the raw parameter value --- + // JToken rawIncludeFlag = @params["includeNonPublicSerialized"]; + // Debug.Log($"[HandleCommand Debug] Raw includeNonPublicSerialized parameter: Type={rawIncludeFlag?.Type.ToString() ?? "Null"}, Value={rawIncludeFlag?.ToString() ?? "N/A"}"); + // --- END DEBUG --- + string action = @params["action"]?.ToString().ToLower(); if (string.IsNullOrEmpty(action)) { @@ -37,6 +43,13 @@ namespace UnityMcpBridge.Editor.Tools string layer = @params["layer"]?.ToString(); JToken parentToken = @params["parent"]; + // --- Add parameter for controlling non-public field inclusion --- + // Reverting to original logic, assuming external system will be fixed to send the parameter correctly. + bool includeNonPublicSerialized = @params["includeNonPublicSerialized"]?.ToObject() ?? true; // Default to true + // Revised: Explicitly check for null, default to false if null/missing. -- REMOVED + // bool includeNonPublicSerialized = @params["includeNonPublicSerialized"] != null && @params["includeNonPublicSerialized"].ToObject(); + // --- End add parameter --- + // --- Prefab Redirection Check --- string targetPath = targetToken?.Type == JTokenType.String ? targetToken.ToString() : null; @@ -125,7 +138,8 @@ namespace UnityMcpBridge.Editor.Tools return Response.Error( "'target' parameter required for get_components." ); - return GetComponentsFromTarget(getCompTarget, searchMethod); + // Pass the includeNonPublicSerialized flag here + return GetComponentsFromTarget(getCompTarget, searchMethod, includeNonPublicSerialized); case "add_component": return AddComponentToTarget(@params, targetToken, searchMethod); case "remove_component": @@ -865,7 +879,7 @@ namespace UnityMcpBridge.Editor.Tools return Response.Success($"Found {results.Count} GameObject(s).", results); } - private static object GetComponentsFromTarget(string target, string searchMethod) + private static object GetComponentsFromTarget(string target, string searchMethod, bool includeNonPublicSerialized) { GameObject targetGo = FindObjectInternal(target, searchMethod); if (targetGo == null) @@ -878,7 +892,8 @@ namespace UnityMcpBridge.Editor.Tools try { Component[] components = targetGo.GetComponents(); - var componentData = components.Select(c => GetComponentData(c)).ToList(); + // Pass the flag to GetComponentData + var componentData = components.Select(c => GetComponentData(c, includeNonPublicSerialized)).ToList(); return Response.Success( $"Retrieved {componentData.Count} components from '{targetGo.name}'.", componentData @@ -1815,6 +1830,7 @@ namespace UnityMcpBridge.Editor.Tools string materialPath = token["path"]?.ToString(); if (!string.IsNullOrEmpty(materialPath)) { +#if UNITY_EDITOR // AssetDatabase is editor-only // Load the material by path Material material = AssetDatabase.LoadAssetAtPath(materialPath); if (material != null) @@ -1836,9 +1852,14 @@ namespace UnityMcpBridge.Editor.Tools ); return null; } +#else + Debug.LogWarning("[ConvertJTokenToType] Material loading by path is only supported in the Unity Editor."); + return null; +#endif } // If no path is specified, could be a dynamic material or instance set by reference + // In a build, we can't load by path, so we rely on direct reference or null. return null; } @@ -1970,6 +1991,7 @@ namespace UnityMcpBridge.Editor.Tools string assetPath = token.ToString(); if (!string.IsNullOrEmpty(assetPath)) { +#if UNITY_EDITOR // AssetDatabase is editor-only // Attempt to load the asset from the provided path using the target type UnityEngine.Object loadedAsset = AssetDatabase.LoadAssetAtPath( assetPath, @@ -1983,10 +2005,13 @@ namespace UnityMcpBridge.Editor.Tools { // Log a warning if the asset could not be found at the path Debug.LogWarning( - $"[ConvertJTokenToType] Could not load asset of type '{targetType.Name}' from path: '{assetPath}'. Make sure the path is correct and the asset exists." - ); + $"[ConvertJTokenToType] Could not load asset of type '{targetType.Name}' from path: '{assetPath}'. Make sure the path is correct and the asset exists."); return null; } +#else + Debug.LogWarning($"[ConvertJTokenToType] Asset loading by path ('{assetPath}') is only supported in the Unity Editor."); + return null; +#endif } else { @@ -2181,77 +2206,174 @@ namespace UnityMcpBridge.Editor.Tools }; } + // --- Metadata Caching for Reflection --- + private class CachedMetadata + { + public readonly List SerializableProperties; + public readonly List SerializableFields; + + public CachedMetadata(List properties, List fields) + { + SerializableProperties = properties; + SerializableFields = fields; + } + } + // Key becomes Tuple + private static readonly Dictionary, CachedMetadata> _metadataCache = new Dictionary, CachedMetadata>(); + // --- End Metadata Caching --- + /// /// Creates a serializable representation of a Component, attempting to serialize - /// public properties and fields using reflection. + /// public properties and fields using reflection, with caching and control over non-public fields. /// - private static object GetComponentData(Component c) + // Add the flag parameter here + private static object GetComponentData(Component c, bool includeNonPublicSerializedFields = true) { if (c == null) return null; + Type componentType = c.GetType(); + + // TEMP: Clear cache for testing again -- REMOVING + // _metadataCache.Clear(); var data = new Dictionary { - { "typeName", c.GetType().FullName }, + { "typeName", componentType.FullName }, { "instanceID", c.GetInstanceID() } }; - var serializableProperties = new Dictionary(); - Type componentType = c.GetType(); - // Include NonPublic flags for fields, keep Public for properties initially - BindingFlags fieldFlags = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance; - BindingFlags propFlags = BindingFlags.Public | BindingFlags.Instance; - - // Process Properties (Still only public for properties) - // Using propFlags here - foreach (var propInfo in componentType.GetProperties(propFlags)) + // --- Get Cached or Generate Metadata (using new cache key) --- + // _metadataCache.Clear(); // TEMP: Clear cache for testing - REMOVED + Tuple cacheKey = new Tuple(componentType, includeNonPublicSerializedFields); + if (!_metadataCache.TryGetValue(cacheKey, out CachedMetadata cachedData)) { - // Skip indexers and write-only properties, and skip the transform property as it's handled by GetGameObjectData - if (!propInfo.CanRead || propInfo.GetIndexParameters().Length > 0 || propInfo.Name == "transform") continue; + // ---- ADD THIS ---- + // UnityEngine.Debug.Log($"[MCP Cache Test] Metadata MISS for Type: {componentType.FullName}, IncludeNonPublic: {includeNonPublicSerializedFields}. Generating..."); + // ----------------- + var propertiesToCache = new List(); + var fieldsToCache = new List(); +//test + // Traverse the hierarchy from the component type up to MonoBehaviour + Type currentType = componentType; + while (currentType != null && currentType != typeof(MonoBehaviour) && currentType != typeof(object)) + { + // Get properties declared only at the current type level + BindingFlags propFlags = BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly; + foreach (var propInfo in currentType.GetProperties(propFlags)) + { + // Basic filtering (readable, not indexer, not transform which is handled elsewhere) + if (!propInfo.CanRead || propInfo.GetIndexParameters().Length > 0 || propInfo.Name == "transform") continue; + // Add if not already added (handles overrides - keep the most derived version) + if (!propertiesToCache.Any(p => p.Name == propInfo.Name)) { + propertiesToCache.Add(propInfo); + } + } + + // Get fields declared only at the current type level (both public and non-public) + BindingFlags fieldFlags = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly; + var declaredFields = currentType.GetFields(fieldFlags); + + // Process the declared Fields for caching + foreach (var fieldInfo in declaredFields) + { + if (fieldInfo.Name.EndsWith("k__BackingField")) continue; // Skip backing fields + + // Add if not already added (handles hiding - keep the most derived version) + if (fieldsToCache.Any(f => f.Name == fieldInfo.Name)) continue; + + bool shouldInclude = false; + if (includeNonPublicSerializedFields) + { + // If TRUE, include Public OR NonPublic with [SerializeField] + shouldInclude = fieldInfo.IsPublic || (fieldInfo.IsPrivate && fieldInfo.IsDefined(typeof(SerializeField), inherit: false)); + } + else // includeNonPublicSerializedFields is FALSE + { + // If FALSE, include ONLY if it is explicitly Public. + shouldInclude = fieldInfo.IsPublic; + } + + if (shouldInclude) + { + fieldsToCache.Add(fieldInfo); + } + } + + // Move to the base type + currentType = currentType.BaseType; + } + // --- End Hierarchy Traversal --- + + // REMOVED Original non-hierarchical property/field gathering logic + /* + BindingFlags propFlags = BindingFlags.Public | BindingFlags.Instance; + BindingFlags fieldFlags = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance; + + foreach (var propInfo in componentType.GetProperties(propFlags)) { ... } + var allQueriedFields = componentType.GetFields(fieldFlags); + foreach (var fieldInfo in allQueriedFields) { ... } + */ + + cachedData = new CachedMetadata(propertiesToCache, fieldsToCache); + _metadataCache[cacheKey] = cachedData; // Add to cache with combined key + } + // ---- ADD THIS ---- + // UnityEngine.Debug.Log($"[MCP Cache Test] Metadata HIT for Type: {componentType.FullName}, IncludeNonPublic: {includeNonPublicSerializedFields}. Using cache."); + // ----------------- + // --- End Get Cached or Generate Metadata --- + + // --- Use cached metadata (no changes needed here) --- + var serializablePropertiesOutput = new Dictionary(); + // Use cached properties + foreach (var propInfo in cachedData.SerializableProperties) + { + // --- Skip known obsolete/problematic Component shortcut properties --- + string propName = propInfo.Name; + if (propName == "rigidbody" || propName == "rigidbody2D" || propName == "camera" || + propName == "light" || propName == "animation" || propName == "constantForce" || + propName == "renderer" || propName == "audio" || propName == "networkView" || + propName == "collider" || propName == "collider2D" || propName == "hingeJoint" || + propName == "particleSystem" || + // Also skip potentially problematic Matrix properties prone to cycles/errors + propName == "worldToLocalMatrix" || propName == "localToWorldMatrix") + { + continue; // Skip these properties + } + // --- End Skip --- try { object value = propInfo.GetValue(c); - string propName = propInfo.Name; + // string propName = propInfo.Name; // Moved up Type propType = propInfo.PropertyType; - - AddSerializableValue(serializableProperties, propName, propType, value); + AddSerializableValue(serializablePropertiesOutput, propName, propType, value); } catch (Exception ex) { - Debug.LogWarning($"Could not read property {propInfo.Name} on {componentType.Name}: {ex.Message}"); + Debug.LogWarning($"Could not read property {propName} on {componentType.Name}: {ex.Message}"); } } - // Process Fields (Include NonPublic) - // Using fieldFlags here - foreach (var fieldInfo in componentType.GetFields(fieldFlags)) + // Use cached fields + foreach (var fieldInfo in cachedData.SerializableFields) { - // Skip backing fields for properties (common pattern) - if (fieldInfo.Name.EndsWith("k__BackingField")) continue; - - // Only include public fields or non-public fields with [SerializeField] - // Check if the field is explicitly marked with SerializeField or if it's public - bool isSerializable = fieldInfo.IsPublic || fieldInfo.IsDefined(typeof(SerializeField), inherit: false); // inherit: false is typical for SerializeField - - if (!isSerializable) continue; // Skip if not public and not explicitly serialized - try { object value = fieldInfo.GetValue(c); string fieldName = fieldInfo.Name; Type fieldType = fieldInfo.FieldType; - - AddSerializableValue(serializableProperties, fieldName, fieldType, value); + AddSerializableValue(serializablePropertiesOutput, fieldName, fieldType, value); } catch (Exception ex) { + // Corrected: Use fieldInfo.Name here as fieldName is out of scope Debug.LogWarning($"Could not read field {fieldInfo.Name} on {componentType.Name}: {ex.Message}"); } } + // --- End Use cached metadata --- - if (serializableProperties.Count > 0) + if (serializablePropertiesOutput.Count > 0) { - data["properties"] = serializableProperties; // Add the collected properties + data["properties"] = serializablePropertiesOutput; } return data; @@ -2282,16 +2404,23 @@ namespace UnityMcpBridge.Editor.Tools { var obj = value as UnityEngine.Object; if (obj != null) { - // Use dynamic or a helper class for flexible properties if adding assetPath var refData = new Dictionary { { "name", obj.name }, { "instanceID", obj.GetInstanceID() }, { "typeName", obj.GetType().FullName } }; + // Attempt to get asset path and GUID +#if UNITY_EDITOR // AssetDatabase is editor-only string assetPath = AssetDatabase.GetAssetPath(obj); if (!string.IsNullOrEmpty(assetPath)) { refData["assetPath"] = assetPath; + // Add GUID if asset path exists + string guid = AssetDatabase.AssetPathToGUID(assetPath); + if (!string.IsNullOrEmpty(guid)) { + refData["guid"] = guid; + } } +#endif dict[name] = refData; } else { @@ -2302,16 +2431,46 @@ namespace UnityMcpBridge.Editor.Tools else if (type == typeof(List)) { dict[name] = value as List; // Directly serializable } - else if (type.IsGenericType && type.GetGenericTypeDefinition() == typeof(List<>)) { - // Could attempt to serialize lists of primitives/structs/references here if needed - dict[name] = $"[Skipped List<{type.GetGenericArguments()[0].Name}>]"; + // Explicit handling for List + else if (type == typeof(List)) { + var vectorList = value as List; + if (vectorList != null) { + // Serialize each Vector3 into a list of dictionaries + var serializableList = vectorList.Select(v => new Dictionary { + { "x", v.x }, + { "y", v.y }, + { "z", v.z } + }).ToList(); + dict[name] = serializableList; + } else { + dict[name] = null; // Or an empty list, or an error message + } } - else if (type.IsArray) { - dict[name] = $"[Skipped Array<{type.GetElementType().Name}>]"; - } - // Skip other complex types for now + // Attempt to serialize other complex types using JToken else { - dict[name] = $"[Skipped complex type: {type.FullName}]"; + // UnityEngine.Debug.Log($"[MCP Debug] Attempting JToken serialization for field: {name} (Type: {type.FullName})"); // Removed this debug log + try + { + // Let Newtonsoft.Json attempt to serialize the value into a JToken + JToken jValue = JToken.FromObject(value); + // We store the JToken itself; the final JSON serialization will handle it. + // Important: Avoid potential cycles by not serializing excessively deep objects here. + // JToken.FromObject handles basic cycle detection, but complex scenarios might still occur. + // Consider adding depth limits if necessary. + dict[name] = jValue; + } + catch (JsonSerializationException jsonEx) + { + // Handle potential serialization issues (e.g., cycles, unsupported types) + Debug.LogWarning($"[AddSerializableValue] Could not serialize complex type '{type.FullName}' for property '{name}' using JToken: {jsonEx.Message}. Storing skip message."); + dict[name] = $"[Serialization Error: {type.FullName} - {jsonEx.Message}]"; + } + catch (Exception ex) + { + // Catch other unexpected errors during serialization + Debug.LogWarning($"[AddSerializableValue] Unexpected error serializing complex type '{type.FullName}' for property '{name}' using JToken: {ex.Message}"); + dict[name] = $"[Serialization Error: {type.FullName} - Unexpected]"; + } } } } diff --git a/UnityMcpBridge/Editor/UnityMcpBridge.cs b/UnityMcpBridge/Editor/UnityMcpBridge.cs index 9276c05..a0b112b 100644 --- a/UnityMcpBridge/Editor/UnityMcpBridge.cs +++ b/UnityMcpBridge/Editor/UnityMcpBridge.cs @@ -267,6 +267,7 @@ namespace UnityMcpBridge.Editor // Normal JSON command processing Command command = JsonConvert.DeserializeObject(commandText); + if (command == null) { var nullCommandResponse = new diff --git a/UnityMcpServer/src/tools/manage_gameobject.py b/UnityMcpServer/src/tools/manage_gameobject.py index a65331f..0f4c9bf 100644 --- a/UnityMcpServer/src/tools/manage_gameobject.py +++ b/UnityMcpServer/src/tools/manage_gameobject.py @@ -35,6 +35,7 @@ def register_manage_gameobject_tools(mcp: FastMCP): search_inactive: bool = False, # -- Component Management Arguments -- component_name: str = None, + includeNonPublicSerialized: bool = None, # Controls serialization of private [SerializeField] fields ) -> Dict[str, Any]: """Manages GameObjects: create, modify, delete, find, and component operations. @@ -59,6 +60,7 @@ def register_manage_gameobject_tools(mcp: FastMCP): Action-specific arguments (e.g., position, rotation, scale for create/modify; component_name for component actions; search_term, find_all for 'find'). + includeNonPublicSerialized: If True, includes private fields marked [SerializeField] in component data. Returns: Dictionary with operation results ('success', 'message', 'data'). @@ -91,7 +93,8 @@ def register_manage_gameobject_tools(mcp: FastMCP): "findAll": find_all, "searchInChildren": search_in_children, "searchInactive": search_inactive, - "componentName": component_name + "componentName": component_name, + "includeNonPublicSerialized": includeNonPublicSerialized } params = {k: v for k, v in params.items() if v is not None} diff --git a/UnityMcpServer/src/uv.lock b/UnityMcpServer/src/uv.lock index 2f8a4d5..bc3e54c 100644 --- a/UnityMcpServer/src/uv.lock +++ b/UnityMcpServer/src/uv.lock @@ -321,8 +321,8 @@ wheels = [ ] [[package]] -name = "unity-mcp" -version = "1.0.1" +name = "unitymcpserver" +version = "2.0.0" source = { editable = "." } dependencies = [ { name = "httpx" },