From 8e68ef595f87d04d090f8bd582e67ac411554e45 Mon Sep 17 00:00:00 2001 From: David Sarno Date: Thu, 29 Jan 2026 04:03:16 -0800 Subject: [PATCH 1/3] Add create_child parameter to manage_prefabs modify_contents Enables adding child GameObjects to existing prefabs via headless editing. Supports single object or array for batch creation in one save operation. Features: - Create children with primitive types (Cube, Sphere, etc.) - Set position, rotation, scale on new children - Add components to children - Specify parent within prefab hierarchy for nested children - Set tag, layer, and active state Co-Authored-By: Claude Opus 4.5 --- .../Editor/Tools/Prefabs/ManagePrefabs.cs | 175 ++++++++++++++++++ Server/src/services/tools/manage_prefabs.py | 33 ++++ 2 files changed, 208 insertions(+) diff --git a/MCPForUnity/Editor/Tools/Prefabs/ManagePrefabs.cs b/MCPForUnity/Editor/Tools/Prefabs/ManagePrefabs.cs index 9b4df7be8..f1435721f 100644 --- a/MCPForUnity/Editor/Tools/Prefabs/ManagePrefabs.cs +++ b/MCPForUnity/Editor/Tools/Prefabs/ManagePrefabs.cs @@ -723,9 +723,184 @@ private static (bool modified, ErrorResponse error) ApplyModificationsToPrefabOb } } + // Create child GameObjects (supports single object or array) + JToken createChildToken = @params["createChild"] ?? @params["create_child"]; + if (createChildToken != null) + { + // Handle array of children + if (createChildToken is JArray childArray) + { + foreach (var childToken in childArray) + { + var childResult = CreateSingleChildInPrefab(childToken, targetGo, prefabRoot); + if (childResult.error != null) + { + return (false, childResult.error); + } + if (childResult.created) + { + modified = true; + } + } + } + else + { + // Handle single child object + var childResult = CreateSingleChildInPrefab(createChildToken, targetGo, prefabRoot); + if (childResult.error != null) + { + return (false, childResult.error); + } + if (childResult.created) + { + modified = true; + } + } + } + return (modified, null); } + /// + /// Creates a single child GameObject within the prefab contents. + /// + private static (bool created, ErrorResponse error) CreateSingleChildInPrefab(JToken createChildToken, GameObject defaultParent, GameObject prefabRoot) + { + JObject childParams; + if (createChildToken is JObject obj) + { + childParams = obj; + } + else + { + return (false, new ErrorResponse("'create_child' must be an object with child properties.")); + } + + // Required: name + string childName = childParams["name"]?.ToString(); + if (string.IsNullOrEmpty(childName)) + { + return (false, new ErrorResponse("'create_child.name' is required.")); + } + + // Optional: parent (defaults to the target object) + string parentName = childParams["parent"]?.ToString(); + Transform parentTransform = defaultParent.transform; + if (!string.IsNullOrEmpty(parentName)) + { + GameObject parentGo = FindInPrefabContents(prefabRoot, parentName); + if (parentGo == null) + { + return (false, new ErrorResponse($"Parent '{parentName}' not found in prefab for create_child.")); + } + parentTransform = parentGo.transform; + } + + // Create the GameObject + GameObject newChild; + string primitiveType = childParams["primitiveType"]?.ToString() ?? childParams["primitive_type"]?.ToString(); + if (!string.IsNullOrEmpty(primitiveType)) + { + try + { + PrimitiveType type = (PrimitiveType)Enum.Parse(typeof(PrimitiveType), primitiveType, true); + newChild = GameObject.CreatePrimitive(type); + newChild.name = childName; + } + catch (ArgumentException) + { + return (false, new ErrorResponse($"Invalid primitive type: '{primitiveType}'. Valid types: {string.Join(", ", Enum.GetNames(typeof(PrimitiveType)))}")); + } + } + else + { + newChild = new GameObject(childName); + } + + // Set parent + newChild.transform.SetParent(parentTransform, false); + + // Apply transform properties + Vector3? position = VectorParsing.ParseVector3(childParams["position"]); + Vector3? rotation = VectorParsing.ParseVector3(childParams["rotation"]); + Vector3? scale = VectorParsing.ParseVector3(childParams["scale"]); + + if (position.HasValue) + { + newChild.transform.localPosition = position.Value; + } + if (rotation.HasValue) + { + newChild.transform.localEulerAngles = rotation.Value; + } + if (scale.HasValue) + { + newChild.transform.localScale = scale.Value; + } + + // Add components + JArray componentsToAdd = childParams["componentsToAdd"] as JArray ?? childParams["components_to_add"] as JArray; + if (componentsToAdd != null) + { + foreach (var compToken in componentsToAdd) + { + string typeName = compToken.Type == JTokenType.String + ? compToken.ToString() + : (compToken as JObject)?["typeName"]?.ToString(); + + if (!string.IsNullOrEmpty(typeName)) + { + if (!ComponentResolver.TryResolve(typeName, out Type componentType, out string error)) + { + // Clean up partially created child + UnityEngine.Object.DestroyImmediate(newChild); + return (false, new ErrorResponse($"Component type '{typeName}' not found for create_child: {error}")); + } + newChild.AddComponent(componentType); + } + } + } + + // Set tag if specified + string tag = childParams["tag"]?.ToString(); + if (!string.IsNullOrEmpty(tag)) + { + try + { + newChild.tag = tag; + } + catch (Exception ex) + { + McpLog.Warn($"[ManagePrefabs] Failed to set tag '{tag}' on child '{childName}': {ex.Message}"); + } + } + + // Set layer if specified + string layerName = childParams["layer"]?.ToString(); + if (!string.IsNullOrEmpty(layerName)) + { + int layerId = LayerMask.NameToLayer(layerName); + if (layerId != -1) + { + newChild.layer = layerId; + } + else + { + McpLog.Warn($"[ManagePrefabs] Invalid layer '{layerName}' for child '{childName}'."); + } + } + + // Set active state + bool? setActive = childParams["setActive"]?.ToObject() ?? childParams["set_active"]?.ToObject(); + if (setActive.HasValue) + { + newChild.SetActive(setActive.Value); + } + + McpLog.Info($"[ManagePrefabs] Created child '{childName}' under '{parentTransform.name}' in prefab."); + return (true, null); + } + #endregion #region Hierarchy Builder diff --git a/Server/src/services/tools/manage_prefabs.py b/Server/src/services/tools/manage_prefabs.py index 90dd127d7..39e5c8888 100644 --- a/Server/src/services/tools/manage_prefabs.py +++ b/Server/src/services/tools/manage_prefabs.py @@ -25,6 +25,10 @@ "Manages Unity Prefab assets via headless operations (no UI, no prefab stages). " "Actions: get_info, get_hierarchy, create_from_gameobject, modify_contents. " "Use modify_contents for headless prefab editing - ideal for automated workflows. " + "Use create_child parameter with modify_contents to add child GameObjects to a prefab " + "(single object or array for batch creation in one save). " + "Example: create_child=[{\"name\": \"Child1\", \"primitive_type\": \"Sphere\", \"position\": [1,0,0]}, " + "{\"name\": \"Child2\", \"primitive_type\": \"Cube\", \"parent\": \"Child1\"}]. " "Use manage_asset action=search filterType=Prefab to list prefabs." ), annotations=ToolAnnotations( @@ -59,6 +63,7 @@ async def manage_prefabs( parent: Annotated[str, "New parent object name/path within prefab for modify_contents."] | None = None, components_to_add: Annotated[list[str], "Component types to add in modify_contents."] | None = None, components_to_remove: Annotated[list[str], "Component types to remove in modify_contents."] | None = None, + create_child: Annotated[dict[str, Any] | list[dict[str, Any]], "Create child GameObject(s) in the prefab. Single object or array of objects, each with: name (required), parent (optional, defaults to target), primitive_type (optional: Cube, Sphere, Capsule, Cylinder, Plane, Quad), position, rotation, scale, components_to_add, tag, layer, set_active."] | None = None, ) -> dict[str, Any]: # Back-compat: map 'name' → 'target' for create_from_gameobject (Unity accepts both) if action == "create_from_gameobject" and target is None and name is not None: @@ -143,6 +148,34 @@ async def manage_prefabs( params["componentsToAdd"] = components_to_add if components_to_remove is not None: params["componentsToRemove"] = components_to_remove + if create_child is not None: + # Normalize vector fields within create_child (handles single object or array) + def normalize_child_params(child: dict, index: int | None = None) -> tuple[dict, str | None]: + child_params = dict(child) + prefix = f"create_child[{index}]" if index is not None else "create_child" + for vec_field in ("position", "rotation", "scale"): + if vec_field in child_params and child_params[vec_field] is not None: + vec_val, vec_err = normalize_vector3(child_params[vec_field], f"{prefix}.{vec_field}") + if vec_err: + return None, vec_err + child_params[vec_field] = vec_val + return child_params, None + + if isinstance(create_child, list): + # Array of children + normalized_children = [] + for i, child in enumerate(create_child): + child_params, err = normalize_child_params(child, i) + if err: + return {"success": False, "message": err} + normalized_children.append(child_params) + params["createChild"] = normalized_children + else: + # Single child object + child_params, err = normalize_child_params(create_child) + if err: + return {"success": False, "message": err} + params["createChild"] = child_params # Send command to Unity response = await send_with_unity_instance( From bc65dbcde3e364f85b898f1f4974d9d61cb6389b Mon Sep 17 00:00:00 2001 From: David Sarno Date: Thu, 29 Jan 2026 04:07:42 -0800 Subject: [PATCH 2/3] Address code review feedback for create_child validation - Fix type hint to `tuple[dict | None, str | None]` to match actual returns - Add explicit dict validation with clear error message including actual type - Error on invalid component entries instead of silently ignoring them - Return ErrorResponse for invalid tag/layer instead of just logging warnings Co-Authored-By: Claude Opus 4.5 --- .../Editor/Tools/Prefabs/ManagePrefabs.cs | 36 ++++++++++--------- Server/src/services/tools/manage_prefabs.py | 6 ++-- 2 files changed, 24 insertions(+), 18 deletions(-) diff --git a/MCPForUnity/Editor/Tools/Prefabs/ManagePrefabs.cs b/MCPForUnity/Editor/Tools/Prefabs/ManagePrefabs.cs index f1435721f..6df9d47bf 100644 --- a/MCPForUnity/Editor/Tools/Prefabs/ManagePrefabs.cs +++ b/MCPForUnity/Editor/Tools/Prefabs/ManagePrefabs.cs @@ -842,22 +842,27 @@ private static (bool created, ErrorResponse error) CreateSingleChildInPrefab(JTo JArray componentsToAdd = childParams["componentsToAdd"] as JArray ?? childParams["components_to_add"] as JArray; if (componentsToAdd != null) { - foreach (var compToken in componentsToAdd) + for (int i = 0; i < componentsToAdd.Count; i++) { + var compToken = componentsToAdd[i]; string typeName = compToken.Type == JTokenType.String ? compToken.ToString() : (compToken as JObject)?["typeName"]?.ToString(); - if (!string.IsNullOrEmpty(typeName)) + if (string.IsNullOrEmpty(typeName)) { - if (!ComponentResolver.TryResolve(typeName, out Type componentType, out string error)) - { - // Clean up partially created child - UnityEngine.Object.DestroyImmediate(newChild); - return (false, new ErrorResponse($"Component type '{typeName}' not found for create_child: {error}")); - } - newChild.AddComponent(componentType); + // Clean up partially created child + UnityEngine.Object.DestroyImmediate(newChild); + return (false, new ErrorResponse($"create_child.components_to_add[{i}] must be a string or object with 'typeName' field, got {compToken.Type}")); + } + + if (!ComponentResolver.TryResolve(typeName, out Type componentType, out string error)) + { + // Clean up partially created child + UnityEngine.Object.DestroyImmediate(newChild); + return (false, new ErrorResponse($"Component type '{typeName}' not found for create_child: {error}")); } + newChild.AddComponent(componentType); } } @@ -871,7 +876,8 @@ private static (bool created, ErrorResponse error) CreateSingleChildInPrefab(JTo } catch (Exception ex) { - McpLog.Warn($"[ManagePrefabs] Failed to set tag '{tag}' on child '{childName}': {ex.Message}"); + UnityEngine.Object.DestroyImmediate(newChild); + return (false, new ErrorResponse($"Failed to set tag '{tag}' on child '{childName}': {ex.Message}")); } } @@ -880,14 +886,12 @@ private static (bool created, ErrorResponse error) CreateSingleChildInPrefab(JTo if (!string.IsNullOrEmpty(layerName)) { int layerId = LayerMask.NameToLayer(layerName); - if (layerId != -1) - { - newChild.layer = layerId; - } - else + if (layerId == -1) { - McpLog.Warn($"[ManagePrefabs] Invalid layer '{layerName}' for child '{childName}'."); + UnityEngine.Object.DestroyImmediate(newChild); + return (false, new ErrorResponse($"Invalid layer '{layerName}' for child '{childName}'. Use a valid layer name.")); } + newChild.layer = layerId; } // Set active state diff --git a/Server/src/services/tools/manage_prefabs.py b/Server/src/services/tools/manage_prefabs.py index 39e5c8888..15df5f803 100644 --- a/Server/src/services/tools/manage_prefabs.py +++ b/Server/src/services/tools/manage_prefabs.py @@ -150,9 +150,11 @@ async def manage_prefabs( params["componentsToRemove"] = components_to_remove if create_child is not None: # Normalize vector fields within create_child (handles single object or array) - def normalize_child_params(child: dict, index: int | None = None) -> tuple[dict, str | None]: - child_params = dict(child) + def normalize_child_params(child: Any, index: int | None = None) -> tuple[dict | None, str | None]: prefix = f"create_child[{index}]" if index is not None else "create_child" + if not isinstance(child, dict): + return None, f"{prefix} must be a dict with child properties (name, primitive_type, position, etc.), got {type(child).__name__}" + child_params = dict(child) for vec_field in ("position", "rotation", "scale"): if vec_field in child_params and child_params[vec_field] is not None: vec_val, vec_err = normalize_vector3(child_params[vec_field], f"{prefix}.{vec_field}") From 1669413f46901c53721b10bba9be93d3e6335cc0 Mon Sep 17 00:00:00 2001 From: David Sarno Date: Thu, 29 Jan 2026 04:13:29 -0800 Subject: [PATCH 3/3] Add unit tests for create_child prefab functionality Tests cover: - Single child with primitive type - Empty GameObject (no primitive_type) - Multiple children from array (batch creation) - Nested parenting within prefab - Error handling for invalid inputs Co-Authored-By: Claude Opus 4.5 --- .../EditMode/Tools/ManagePrefabsCrudTests.cs | 188 ++++++++++++++++++ 1 file changed, 188 insertions(+) diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManagePrefabsCrudTests.cs b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManagePrefabsCrudTests.cs index da6c5063d..b4949224d 100644 --- a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManagePrefabsCrudTests.cs +++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManagePrefabsCrudTests.cs @@ -532,6 +532,194 @@ public void ModifyContents_PreventsHierarchyLoops() } } + [Test] + public void ModifyContents_CreateChild_AddsSingleChildWithPrimitive() + { + string prefabPath = CreateTestPrefab("CreateChildTest"); + + try + { + var result = ToJObject(ManagePrefabs.HandleCommand(new JObject + { + ["action"] = "modify_contents", + ["prefabPath"] = prefabPath, + ["createChild"] = new JObject + { + ["name"] = "NewSphere", + ["primitive_type"] = "Sphere", + ["position"] = new JArray(1f, 2f, 3f), + ["scale"] = new JArray(0.5f, 0.5f, 0.5f) + } + })); + + Assert.IsTrue(result.Value("success")); + + GameObject reloaded = AssetDatabase.LoadAssetAtPath(prefabPath); + Transform child = reloaded.transform.Find("NewSphere"); + Assert.IsNotNull(child, "Child should exist"); + Assert.AreEqual(new Vector3(1f, 2f, 3f), child.localPosition); + Assert.AreEqual(new Vector3(0.5f, 0.5f, 0.5f), child.localScale); + Assert.IsNotNull(child.GetComponent(), "Sphere primitive should have SphereCollider"); + } + finally + { + SafeDeleteAsset(prefabPath); + } + } + + [Test] + public void ModifyContents_CreateChild_AddsEmptyGameObject() + { + string prefabPath = CreateTestPrefab("EmptyChildTest"); + + try + { + var result = ToJObject(ManagePrefabs.HandleCommand(new JObject + { + ["action"] = "modify_contents", + ["prefabPath"] = prefabPath, + ["createChild"] = new JObject + { + ["name"] = "EmptyChild", + ["position"] = new JArray(0f, 5f, 0f) + } + })); + + Assert.IsTrue(result.Value("success")); + + GameObject reloaded = AssetDatabase.LoadAssetAtPath(prefabPath); + Transform child = reloaded.transform.Find("EmptyChild"); + Assert.IsNotNull(child, "Empty child should exist"); + Assert.AreEqual(new Vector3(0f, 5f, 0f), child.localPosition); + // Empty GO should only have Transform + Assert.AreEqual(1, child.GetComponents().Length, "Empty child should only have Transform"); + } + finally + { + SafeDeleteAsset(prefabPath); + } + } + + [Test] + public void ModifyContents_CreateChild_AddsMultipleChildrenFromArray() + { + string prefabPath = CreateTestPrefab("MultiChildTest"); + + try + { + var result = ToJObject(ManagePrefabs.HandleCommand(new JObject + { + ["action"] = "modify_contents", + ["prefabPath"] = prefabPath, + ["createChild"] = new JArray + { + new JObject { ["name"] = "Child1", ["primitive_type"] = "Cube", ["position"] = new JArray(1f, 0f, 0f) }, + new JObject { ["name"] = "Child2", ["primitive_type"] = "Sphere", ["position"] = new JArray(-1f, 0f, 0f) }, + new JObject { ["name"] = "Child3", ["position"] = new JArray(0f, 1f, 0f) } + } + })); + + Assert.IsTrue(result.Value("success")); + + GameObject reloaded = AssetDatabase.LoadAssetAtPath(prefabPath); + Assert.IsNotNull(reloaded.transform.Find("Child1"), "Child1 should exist"); + Assert.IsNotNull(reloaded.transform.Find("Child2"), "Child2 should exist"); + Assert.IsNotNull(reloaded.transform.Find("Child3"), "Child3 should exist"); + Assert.IsNotNull(reloaded.transform.Find("Child1").GetComponent(), "Child1 should be Cube"); + Assert.IsNotNull(reloaded.transform.Find("Child2").GetComponent(), "Child2 should be Sphere"); + } + finally + { + SafeDeleteAsset(prefabPath); + } + } + + [Test] + public void ModifyContents_CreateChild_SupportsNestedParenting() + { + string prefabPath = CreateNestedTestPrefab("NestedCreateChildTest"); + + try + { + var result = ToJObject(ManagePrefabs.HandleCommand(new JObject + { + ["action"] = "modify_contents", + ["prefabPath"] = prefabPath, + ["createChild"] = new JObject + { + ["name"] = "NewGrandchild", + ["parent"] = "Child1", + ["primitive_type"] = "Capsule" + } + })); + + Assert.IsTrue(result.Value("success")); + + GameObject reloaded = AssetDatabase.LoadAssetAtPath(prefabPath); + Transform newChild = reloaded.transform.Find("Child1/NewGrandchild"); + Assert.IsNotNull(newChild, "NewGrandchild should be under Child1"); + Assert.IsNotNull(newChild.GetComponent(), "Should be Capsule primitive"); + } + finally + { + SafeDeleteAsset(prefabPath); + } + } + + [Test] + public void ModifyContents_CreateChild_ReturnsErrorForInvalidInput() + { + string prefabPath = CreateTestPrefab("InvalidChildTest"); + + try + { + // Missing required 'name' field + var missingName = ToJObject(ManagePrefabs.HandleCommand(new JObject + { + ["action"] = "modify_contents", + ["prefabPath"] = prefabPath, + ["createChild"] = new JObject + { + ["primitive_type"] = "Cube" + } + })); + Assert.IsFalse(missingName.Value("success")); + Assert.IsTrue(missingName.Value("error").Contains("name")); + + // Invalid parent + var invalidParent = ToJObject(ManagePrefabs.HandleCommand(new JObject + { + ["action"] = "modify_contents", + ["prefabPath"] = prefabPath, + ["createChild"] = new JObject + { + ["name"] = "TestChild", + ["parent"] = "NonexistentParent" + } + })); + Assert.IsFalse(invalidParent.Value("success")); + Assert.IsTrue(invalidParent.Value("error").Contains("not found")); + + // Invalid primitive type + var invalidPrimitive = ToJObject(ManagePrefabs.HandleCommand(new JObject + { + ["action"] = "modify_contents", + ["prefabPath"] = prefabPath, + ["createChild"] = new JObject + { + ["name"] = "TestChild", + ["primitive_type"] = "InvalidType" + } + })); + Assert.IsFalse(invalidPrimitive.Value("success")); + Assert.IsTrue(invalidPrimitive.Value("error").Contains("Invalid primitive type")); + } + finally + { + SafeDeleteAsset(prefabPath); + } + } + #endregion #region Error Handling