using System; using System.Collections.Generic; using System.IO; using System.Linq; using MCPForUnity.Editor.Helpers; using Newtonsoft.Json.Linq; using UnityEditor; using UnityEditor.SceneManagement; using UnityEngine; using UnityEngine.SceneManagement; using MCPForUnity.Runtime.Helpers; namespace MCPForUnity.Editor.Tools.Prefabs { [McpForUnityTool("manage_prefabs", AutoRegister = false)] /// /// Tool to manage Unity Prefabs: create, inspect, modify, and open/save/close prefab stage. /// Supports both headless editing (modify_contents) and interactive prefab stage workflows. /// public static class ManagePrefabs { // Action constants private const string ACTION_CREATE_FROM_GAMEOBJECT = "create_from_gameobject"; private const string ACTION_GET_INFO = "get_info"; private const string ACTION_GET_HIERARCHY = "get_hierarchy"; private const string ACTION_MODIFY_CONTENTS = "modify_contents"; private const string ACTION_OPEN_PREFAB_STAGE = "open_prefab_stage"; private const string ACTION_SAVE_PREFAB_STAGE = "save_prefab_stage"; private const string ACTION_CLOSE_PREFAB_STAGE = "close_prefab_stage"; private const string SupportedActions = ACTION_CREATE_FROM_GAMEOBJECT + ", " + ACTION_GET_INFO + ", " + ACTION_GET_HIERARCHY + ", " + ACTION_MODIFY_CONTENTS + ", " + ACTION_OPEN_PREFAB_STAGE + ", " + ACTION_SAVE_PREFAB_STAGE + ", " + ACTION_CLOSE_PREFAB_STAGE; public static object HandleCommand(JObject @params) { if (@params == null) { return new ErrorResponse("Parameters cannot be null."); } string action = @params["action"]?.ToString()?.ToLowerInvariant(); if (string.IsNullOrEmpty(action)) { return new ErrorResponse($"Action parameter is required. Valid actions are: {SupportedActions}."); } try { switch (action) { case ACTION_CREATE_FROM_GAMEOBJECT: return CreatePrefabFromGameObject(@params); case ACTION_GET_INFO: return GetInfo(@params); case ACTION_GET_HIERARCHY: return GetHierarchy(@params); case ACTION_MODIFY_CONTENTS: return ModifyContents(@params); case ACTION_OPEN_PREFAB_STAGE: { string prefabPath = @params["prefabPath"]?.ToString() ?? @params["path"]?.ToString(); return OpenPrefabStage(prefabPath); } case ACTION_SAVE_PREFAB_STAGE: return SavePrefabStage(); case ACTION_CLOSE_PREFAB_STAGE: { bool saveBeforeClose = @params["saveBeforeClose"]?.ToObject() ?? false; return ClosePrefabStage(saveBeforeClose); } default: return new ErrorResponse($"Unknown action: '{action}'. Valid actions are: {SupportedActions}."); } } catch (Exception e) { McpLog.Error($"[ManagePrefabs] Action '{action}' failed: {e}"); return new ErrorResponse($"Internal error: {e.Message}"); } } #region Create Prefab from GameObject /// /// Creates a prefab asset from a GameObject in the scene. /// private static object CreatePrefabFromGameObject(JObject @params) { // 1. Validate and parse parameters var validation = ValidateCreatePrefabParams(@params); if (!validation.isValid) { return new ErrorResponse(validation.errorMessage); } string targetName = validation.targetName; string finalPath = validation.finalPath; bool includeInactive = validation.includeInactive; bool replaceExisting = validation.replaceExisting; bool unlinkIfInstance = validation.unlinkIfInstance; // 2. Find the source object GameObject sourceObject = FindSceneObjectByName(targetName, includeInactive); if (sourceObject == null) { return new ErrorResponse($"GameObject '{targetName}' not found in the active scene or prefab stage{(includeInactive ? " (including inactive objects)" : "")}."); } // 3. Validate source object state var objectValidation = ValidateSourceObjectForPrefab(sourceObject, unlinkIfInstance); if (!objectValidation.isValid) { return new ErrorResponse(objectValidation.errorMessage); } // 4. Check for path conflicts and track if file will be replaced bool fileExistedAtPath = AssetDatabase.LoadAssetAtPath(finalPath) != null; if (!replaceExisting && fileExistedAtPath) { finalPath = AssetDatabase.GenerateUniqueAssetPath(finalPath); McpLog.Info($"[ManagePrefabs] Generated unique path: {finalPath}"); } // 5. Ensure directory exists EnsureAssetDirectoryExists(finalPath); // 6. Unlink from existing prefab if needed if (unlinkIfInstance && objectValidation.shouldUnlink) { try { // UnpackPrefabInstance requires the prefab instance root, not a child object GameObject rootToUnlink = PrefabUtility.GetOutermostPrefabInstanceRoot(sourceObject); if (rootToUnlink != null) { PrefabUtility.UnpackPrefabInstance(rootToUnlink, PrefabUnpackMode.Completely, InteractionMode.AutomatedAction); McpLog.Info($"[ManagePrefabs] Unpacked prefab instance '{rootToUnlink.name}' before creating new prefab."); } } catch (Exception e) { return new ErrorResponse($"Failed to unlink prefab instance: {e.Message}"); } } // 7. Persist any runtime-only materials so they survive prefab serialization var persistResult = PersistRuntimeMaterials(sourceObject, finalPath); // 8. Create the prefab try { GameObject result = CreatePrefabAsset(sourceObject, finalPath, replaceExisting); if (result == null) { return new ErrorResponse($"Failed to create prefab asset at '{finalPath}'."); } // 9. Select the newly created instance Selection.activeGameObject = result; return new SuccessResponse( $"Prefab created at '{finalPath}' and instance linked.", new { prefabPath = finalPath, instanceId = result.GetInstanceIDCompat(), instanceName = result.name, wasUnlinked = unlinkIfInstance && objectValidation.shouldUnlink, wasReplaced = replaceExisting && fileExistedAtPath, componentCount = result.GetComponents().Length, childCount = result.transform.childCount, materialsPersisted = persistResult.count } ); } catch (Exception e) { McpLog.Error($"[ManagePrefabs] Error creating prefab at '{finalPath}': {e}"); return new ErrorResponse($"Error saving prefab asset: {e.Message}"); } } /// /// Validates parameters for creating a prefab from GameObject. /// private static (bool isValid, string errorMessage, string targetName, string finalPath, bool includeInactive, bool replaceExisting, bool unlinkIfInstance) ValidateCreatePrefabParams(JObject @params) { string targetName = @params["target"]?.ToString() ?? @params["name"]?.ToString(); if (string.IsNullOrEmpty(targetName)) { return (false, "'target' parameter is required for create_from_gameobject.", null, null, false, false, false); } string requestedPath = @params["prefabPath"]?.ToString(); if (string.IsNullOrWhiteSpace(requestedPath)) { return (false, "'prefabPath' parameter is required for create_from_gameobject.", targetName, null, false, false, false); } string sanitizedPath = AssetPathUtility.SanitizeAssetPath(requestedPath); if (sanitizedPath == null) { return (false, $"Invalid prefab path (path traversal detected): '{requestedPath}'", targetName, null, false, false, false); } if (string.IsNullOrEmpty(sanitizedPath)) { return (false, $"Invalid prefab path '{requestedPath}'. Path cannot be empty.", targetName, null, false, false, false); } if (!sanitizedPath.EndsWith(".prefab", StringComparison.OrdinalIgnoreCase)) { sanitizedPath += ".prefab"; } // Validate path is within Assets folder if (!sanitizedPath.StartsWith("Assets/", StringComparison.OrdinalIgnoreCase)) { return (false, $"Prefab path must be within the Assets folder. Got: '{sanitizedPath}'", targetName, null, false, false, false); } bool includeInactive = @params["searchInactive"]?.ToObject() ?? false; bool replaceExisting = @params["allowOverwrite"]?.ToObject() ?? false; bool unlinkIfInstance = @params["unlinkIfInstance"]?.ToObject() ?? false; return (true, null, targetName, sanitizedPath, includeInactive, replaceExisting, unlinkIfInstance); } /// /// Validates source object can be converted to prefab. /// private static (bool isValid, string errorMessage, bool shouldUnlink, string existingPrefabPath) ValidateSourceObjectForPrefab(GameObject sourceObject, bool unlinkIfInstance) { // Check if this is a Prefab Asset (the .prefab file itself in the editor) if (PrefabUtility.IsPartOfPrefabAsset(sourceObject)) { return (false, $"GameObject '{sourceObject.name}' is part of a prefab asset. " + "Open the prefab stage to save changes instead.", false, null); } // Check if this is already a Prefab Instance PrefabInstanceStatus status = PrefabUtility.GetPrefabInstanceStatus(sourceObject); if (status != PrefabInstanceStatus.NotAPrefab) { string existingPath = PrefabUtility.GetPrefabAssetPathOfNearestInstanceRoot(sourceObject); if (!unlinkIfInstance) { return (false, $"GameObject '{sourceObject.name}' is already linked to prefab '{existingPath}'. " + "Set 'unlinkIfInstance' to true to unlink it first, or modify the existing prefab instead.", false, existingPath); } // Needs to be unlinked return (true, null, true, existingPath); } return (true, null, false, null); } /// /// Creates a prefab asset from a GameObject. /// private static GameObject CreatePrefabAsset(GameObject sourceObject, string path, bool replaceExisting) { GameObject result = PrefabUtility.SaveAsPrefabAssetAndConnect( sourceObject, path, InteractionMode.AutomatedAction ); string action = replaceExisting ? "Replaced existing" : "Created new"; McpLog.Info($"[ManagePrefabs] {action} prefab at '{path}'."); if (result != null) { AssetDatabase.SaveAssets(); AssetDatabase.Refresh(); } return result; } /// /// Scans all Renderers in the hierarchy and persists any runtime-only materials /// (MaterialPropertyBlock overrides or in-memory instances from renderer.material) /// as .mat assets so they survive prefab serialization. /// private static (int count, List paths) PersistRuntimeMaterials(GameObject root, string prefabPath) { var renderers = root.GetComponentsInChildren(true); var persistedPaths = new List(); string prefabDir = Path.GetDirectoryName(prefabPath).Replace("\\", "/"); string materialsFolder = $"{prefabDir}/Materials"; foreach (var renderer in renderers) { Material[] sharedMats = renderer.sharedMaterials; bool changed = false; for (int slot = 0; slot < sharedMats.Length; slot++) { Material mat = sharedMats[slot]; // Case 1: Material is null but a property block has color data — // this happens after instance mode severs the asset link. // Case 2: Material exists but is not a persistent asset (runtime instance). bool isRuntimeInstance = mat != null && !EditorUtility.IsPersistent(mat); bool isNullWithPropertyBlock = mat == null && HasPropertyBlockColors(renderer, slot); bool isNullMaterial = mat == null && !isNullWithPropertyBlock; if (!isRuntimeInstance && !isNullWithPropertyBlock) continue; // Derive a unique asset path from the GameObject name and slot string goName = renderer.gameObject.name.Replace(" ", "_"); string suffix = slot > 0 ? $"_slot{slot}" : ""; string matPath = $"{materialsFolder}/{goName}{suffix}_mat.mat"; matPath = AssetPathUtility.SanitizeAssetPath(matPath); if (matPath == null) { McpLog.Warn($"[ManagePrefabs] Could not build safe material path for '{renderer.gameObject.name}', skipping."); continue; } // Ensure the Materials directory exists (recursive) EnsureAssetFolderExists(materialsFolder); Material persisted = AssetDatabase.LoadAssetAtPath(matPath); if (persisted == null) { // Create a new material with the correct shader for the active pipeline Shader shader = isRuntimeInstance && mat.shader != null ? mat.shader : RenderPipelineUtility.ResolveShader("Standard"); persisted = new Material(shader); AssetDatabase.CreateAsset(persisted, matPath); } // Copy properties from the runtime instance if available if (isRuntimeInstance) { persisted.CopyPropertiesFromMaterial(mat); EditorUtility.SetDirty(persisted); } else if (isNullWithPropertyBlock) { // Extract color from the property block and apply to the new material ApplyPropertyBlockToMaterial(renderer, slot, persisted); EditorUtility.SetDirty(persisted); } sharedMats[slot] = persisted; changed = true; persistedPaths.Add(matPath); McpLog.Info($"[ManagePrefabs] Persisted runtime material for '{renderer.gameObject.name}' slot {slot} → {matPath}"); } if (changed) { Undo.RecordObject(renderer, "Persist runtime materials for prefab"); renderer.sharedMaterials = sharedMats; // Clear any property blocks now that the material is persisted for (int slot = 0; slot < sharedMats.Length; slot++) { renderer.SetPropertyBlock(null, slot); } EditorUtility.SetDirty(renderer); } } if (persistedPaths.Count > 0) { AssetDatabase.SaveAssets(); McpLog.Info($"[ManagePrefabs] Persisted {persistedPaths.Count} runtime material(s) before prefab save."); } return (persistedPaths.Count, persistedPaths); } /// /// Recursively creates the folder hierarchy for the given asset path if it doesn't exist. /// private static void EnsureAssetFolderExists(string assetFolderPath) { if (AssetDatabase.IsValidFolder(assetFolderPath)) return; string[] parts = assetFolderPath.Replace('\\', '/').Split('/'); string current = parts[0]; // "Assets" for (int i = 1; i < parts.Length; i++) { string next = current + "/" + parts[i]; if (!AssetDatabase.IsValidFolder(next)) AssetDatabase.CreateFolder(current, parts[i]); current = next; } } private static bool HasPropertyBlockColors(Renderer renderer, int slot) { MaterialPropertyBlock block = new MaterialPropertyBlock(); renderer.GetPropertyBlock(block, slot); return !block.isEmpty; } /// /// Extracts color properties from a MaterialPropertyBlock and applies them to a material. /// private static void ApplyPropertyBlockToMaterial(Renderer renderer, int slot, Material mat) { MaterialPropertyBlock block = new MaterialPropertyBlock(); renderer.GetPropertyBlock(block, slot); // Try the standard color property names string[] colorProps = { "_BaseColor", "_Color" }; foreach (string prop in colorProps) { if (mat.HasProperty(prop) && block.HasColor(prop)) { mat.SetColor(prop, block.GetColor(prop)); } } } #endregion /// /// Ensures the directory for an asset path exists, creating it if necessary. /// private static void EnsureAssetDirectoryExists(string assetPath) { string directory = Path.GetDirectoryName(assetPath); if (string.IsNullOrEmpty(directory)) { return; } // Use Application.dataPath for more reliable path resolution // Application.dataPath points to the Assets folder (e.g., ".../ProjectName/Assets") string assetsPath = Application.dataPath; string projectRoot = Path.GetDirectoryName(assetsPath); string fullDirectory = Path.Combine(projectRoot, directory); if (!Directory.Exists(fullDirectory)) { Directory.CreateDirectory(fullDirectory); AssetDatabase.Refresh(ImportAssetOptions.ForceSynchronousImport); McpLog.Info($"[ManagePrefabs] Created directory: {directory}"); } } /// /// Finds a GameObject by name in the active scene or current prefab stage. /// private static GameObject FindSceneObjectByName(string name, bool includeInactive) { // First check if we're in Prefab Stage PrefabStage stage = PrefabStageUtility.GetCurrentPrefabStage(); if (stage?.prefabContentsRoot != null) { foreach (Transform transform in stage.prefabContentsRoot.GetComponentsInChildren(includeInactive)) { if (transform.name == name && (includeInactive || transform.gameObject.activeSelf)) { return transform.gameObject; } } } // Search in the active scene Scene activeScene = SceneManager.GetActiveScene(); foreach (GameObject root in activeScene.GetRootGameObjects()) { // Check the root object itself if (root.name == name && (includeInactive || root.activeSelf)) { return root; } // Check children foreach (Transform transform in root.GetComponentsInChildren(includeInactive)) { if (transform.name == name && (includeInactive || transform.gameObject.activeSelf)) { return transform.gameObject; } } } return null; } #region Read Operations /// /// Gets basic metadata information about a prefab asset. /// private static object GetInfo(JObject @params) { string prefabPath = @params["prefabPath"]?.ToString() ?? @params["path"]?.ToString(); if (string.IsNullOrEmpty(prefabPath)) { return new ErrorResponse("'prefabPath' parameter is required for get_info."); } string sanitizedPath = AssetPathUtility.SanitizeAssetPath(prefabPath); if (string.IsNullOrEmpty(sanitizedPath)) { return new ErrorResponse($"Invalid prefab path: '{prefabPath}'."); } GameObject prefabAsset = AssetDatabase.LoadAssetAtPath(sanitizedPath); if (prefabAsset == null) { return new ErrorResponse($"No prefab asset found at path '{sanitizedPath}'."); } string guid = PrefabUtilityHelper.GetPrefabGUID(sanitizedPath); PrefabAssetType assetType = PrefabUtility.GetPrefabAssetType(prefabAsset); string prefabTypeString = assetType.ToString(); var componentTypes = PrefabUtilityHelper.GetComponentTypeNames(prefabAsset); int childCount = PrefabUtilityHelper.CountChildrenRecursive(prefabAsset.transform); var (isVariant, parentPrefab, _) = PrefabUtilityHelper.GetVariantInfo(prefabAsset); return new SuccessResponse( $"Successfully retrieved prefab info.", new { assetPath = sanitizedPath, guid = guid, prefabType = prefabTypeString, rootObjectName = prefabAsset.name, rootComponentTypes = componentTypes, childCount = childCount, isVariant = isVariant, parentPrefab = parentPrefab } ); } /// /// Gets the hierarchical structure of a prefab asset. /// Returns all objects in the prefab for full client-side filtering and search. /// private static object GetHierarchy(JObject @params) { string prefabPath = @params["prefabPath"]?.ToString() ?? @params["path"]?.ToString(); if (string.IsNullOrEmpty(prefabPath)) { return new ErrorResponse("'prefabPath' parameter is required for get_hierarchy."); } string sanitizedPath = AssetPathUtility.SanitizeAssetPath(prefabPath); if (string.IsNullOrEmpty(sanitizedPath)) { return new ErrorResponse($"Invalid prefab path '{prefabPath}'. Path traversal sequences are not allowed."); } // Load prefab contents in background (without opening stage UI) GameObject prefabContents = PrefabUtility.LoadPrefabContents(sanitizedPath); if (prefabContents == null) { return new ErrorResponse($"Failed to load prefab contents from '{sanitizedPath}'."); } try { // Build complete hierarchy items (no pagination) var allItems = BuildHierarchyItems(prefabContents.transform, sanitizedPath); return new SuccessResponse( $"Successfully retrieved prefab hierarchy. Found {allItems.Count} objects.", new { prefabPath = sanitizedPath, total = allItems.Count, items = allItems } ); } finally { // Always unload prefab contents to free memory PrefabUtility.UnloadPrefabContents(prefabContents); } } #endregion #region Headless Prefab Editing /// /// Modifies a prefab's contents directly without opening the prefab stage. /// This is ideal for automated/agentic workflows as it avoids UI, dirty flags, and dialogs. /// private static object ModifyContents(JObject @params) { string prefabPath = @params["prefabPath"]?.ToString() ?? @params["path"]?.ToString(); if (string.IsNullOrEmpty(prefabPath)) { return new ErrorResponse("'prefabPath' parameter is required for modify_contents."); } string sanitizedPath = AssetPathUtility.SanitizeAssetPath(prefabPath); if (string.IsNullOrEmpty(sanitizedPath)) { return new ErrorResponse($"Invalid prefab path '{prefabPath}'. Path traversal sequences are not allowed."); } // Load prefab contents in isolated context (no UI) GameObject prefabContents = PrefabUtility.LoadPrefabContents(sanitizedPath); if (prefabContents == null) { return new ErrorResponse($"Failed to load prefab contents from '{sanitizedPath}'."); } try { // Find target object within the prefab (defaults to root) string targetName = @params["target"]?.ToString(); GameObject targetGo = FindInPrefabContents(prefabContents, targetName); if (targetGo == null) { string searchedFor = string.IsNullOrEmpty(targetName) ? "root" : $"'{targetName}'"; return new ErrorResponse($"Target {searchedFor} not found in prefab '{sanitizedPath}'."); } // Apply modifications var modifyResult = ApplyModificationsToPrefabObject(targetGo, @params, prefabContents, sanitizedPath); if (modifyResult.error != null) { return modifyResult.error; } // Skip saving when no modifications were made to avoid unnecessary asset writes if (!modifyResult.modified) { return new SuccessResponse( $"Prefab '{sanitizedPath}' is already up to date; no changes were applied.", new { prefabPath = sanitizedPath, targetName = targetGo.name, modified = false } ); } // Save the prefab bool success; PrefabUtility.SaveAsPrefabAsset(prefabContents, sanitizedPath, out success); if (!success) { return new ErrorResponse($"Failed to save prefab asset at '{sanitizedPath}'."); } AssetDatabase.Refresh(); McpLog.Info($"[ManagePrefabs] Successfully modified and saved prefab '{sanitizedPath}' (headless)."); return new SuccessResponse( $"Prefab '{sanitizedPath}' modified and saved successfully.", new { prefabPath = sanitizedPath, targetName = targetGo.name, modified = modifyResult.modified, transform = new { position = new { x = targetGo.transform.localPosition.x, y = targetGo.transform.localPosition.y, z = targetGo.transform.localPosition.z }, rotation = new { x = targetGo.transform.localEulerAngles.x, y = targetGo.transform.localEulerAngles.y, z = targetGo.transform.localEulerAngles.z }, scale = new { x = targetGo.transform.localScale.x, y = targetGo.transform.localScale.y, z = targetGo.transform.localScale.z } }, componentTypes = PrefabUtilityHelper.GetComponentTypeNames(targetGo) } ); } finally { // Always unload prefab contents to free memory PrefabUtility.UnloadPrefabContents(prefabContents); } } /// /// Finds a GameObject within loaded prefab contents by name or path. /// private static GameObject FindInPrefabContents(GameObject prefabContents, string target) { if (string.IsNullOrEmpty(target)) { // Return root if no target specified return prefabContents; } // Try to find by path first (e.g., "Parent/Child/Target") if (target.Contains("/")) { Transform found = prefabContents.transform.Find(target); if (found != null) { return found.gameObject; } // If path starts with root name, try without it if (target.StartsWith(prefabContents.name + "/")) { string relativePath = target.Substring(prefabContents.name.Length + 1); found = prefabContents.transform.Find(relativePath); if (found != null) { return found.gameObject; } } } // Check if target matches root name if (prefabContents.name == target) { return prefabContents; } // Search by name in hierarchy foreach (Transform t in prefabContents.GetComponentsInChildren(true)) { if (t.gameObject.name == target) { return t.gameObject; } } return null; } /// /// Applies modifications to a GameObject within loaded prefab contents. /// Returns (modified: bool, error: ErrorResponse or null). /// private static (bool modified, ErrorResponse error) ApplyModificationsToPrefabObject(GameObject targetGo, JObject @params, GameObject prefabRoot, string editingPrefabPath) { bool modified = false; // Name change string newName = @params["name"]?.ToString(); if (!string.IsNullOrEmpty(newName) && targetGo.name != newName) { // If renaming the root, this will affect the prefab asset name on save targetGo.name = newName; modified = true; } // Active state bool? setActive = @params["setActive"]?.ToObject(); if (setActive.HasValue && targetGo.activeSelf != setActive.Value) { targetGo.SetActive(setActive.Value); modified = true; } // Tag string tag = @params["tag"]?.ToString(); if (tag != null && targetGo.tag != tag) { string tagToSet = string.IsNullOrEmpty(tag) ? "Untagged" : tag; try { targetGo.tag = tagToSet; modified = true; } catch (Exception ex) { return (false, new ErrorResponse($"Failed to set tag to '{tagToSet}': {ex.Message}")); } } // Layer string layerName = @params["layer"]?.ToString(); if (!string.IsNullOrEmpty(layerName)) { int layerId = LayerMask.NameToLayer(layerName); if (layerId == -1) { return (false, new ErrorResponse($"Invalid layer specified: '{layerName}'. Use a valid layer name.")); } if (targetGo.layer != layerId) { targetGo.layer = layerId; modified = true; } } // Transform: position, rotation, scale Vector3? position = VectorParsing.ParseVector3(@params["position"]); Vector3? rotation = VectorParsing.ParseVector3(@params["rotation"]); Vector3? scale = VectorParsing.ParseVector3(@params["scale"]); if (position.HasValue && targetGo.transform.localPosition != position.Value) { targetGo.transform.localPosition = position.Value; modified = true; } if (rotation.HasValue && targetGo.transform.localEulerAngles != rotation.Value) { targetGo.transform.localEulerAngles = rotation.Value; modified = true; } if (scale.HasValue && targetGo.transform.localScale != scale.Value) { targetGo.transform.localScale = scale.Value; modified = true; } // Parent change (within prefab hierarchy) JToken parentToken = @params["parent"]; if (parentToken != null) { string parentTarget = parentToken.ToString(); Transform newParent = null; if (!string.IsNullOrEmpty(parentTarget)) { GameObject parentGo = FindInPrefabContents(prefabRoot, parentTarget); if (parentGo == null) { return (false, new ErrorResponse($"Parent '{parentTarget}' not found in prefab.")); } if (parentGo.transform.IsChildOf(targetGo.transform)) { return (false, new ErrorResponse($"Cannot parent '{targetGo.name}' to '{parentGo.name}' as it would create a hierarchy loop.")); } newParent = parentGo.transform; } if (targetGo.transform.parent != newParent) { targetGo.transform.SetParent(newParent, true); modified = true; } } // Components to add if (@params["componentsToAdd"] is JArray componentsToAdd) { 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)) { return (false, new ErrorResponse($"Component type '{typeName}' not found: {error}")); } targetGo.AddComponent(componentType); modified = true; } } } // Components to remove if (@params["componentsToRemove"] is JArray componentsToRemove) { foreach (var compToken in componentsToRemove) { string typeName = compToken.ToString(); if (!string.IsNullOrEmpty(typeName)) { if (!ComponentResolver.TryResolve(typeName, out Type componentType, out string error)) { return (false, new ErrorResponse($"Component type '{typeName}' not found: {error}")); } Component comp = targetGo.GetComponent(componentType); if (comp != null) { UnityEngine.Object.DestroyImmediate(comp); modified = true; } } } } // 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, editingPrefabPath); if (childResult.error != null) { return (false, childResult.error); } if (childResult.created) { modified = true; } } } else { // Handle single child object var childResult = CreateSingleChildInPrefab(createChildToken, targetGo, prefabRoot, editingPrefabPath); if (childResult.error != null) { return (false, childResult.error); } if (childResult.created) { modified = true; } } } // Delete child GameObjects (supports single string or array of paths/names) JToken deleteChildToken = @params["deleteChild"] ?? @params["delete_child"]; if (deleteChildToken != null) { var deleteResult = RemoveChildren(deleteChildToken, targetGo, prefabRoot); if (deleteResult.error != null) { return (false, deleteResult.error); } if (deleteResult.removedCount > 0) { modified = true; } } // Set properties on existing components JObject componentProperties = @params["componentProperties"] as JObject ?? @params["component_properties"] as JObject; if (componentProperties != null && componentProperties.Count > 0) { var errors = new List(); foreach (var entry in componentProperties.Properties()) { string typeName = entry.Name; if (!ComponentResolver.TryResolve(typeName, out Type componentType, out string resolveError)) { errors.Add($"{typeName}: type not found — {resolveError}"); continue; } Component component = targetGo.GetComponent(componentType); if (component == null) { errors.Add($"{typeName}: not found on '{targetGo.name}'"); continue; } if (entry.Value is not JObject props || !props.HasValues) { continue; } foreach (var prop in props.Properties()) { if (!ComponentOps.SetProperty(component, prop.Name, prop.Value, out string setError)) { errors.Add($"{typeName}.{prop.Name}: {setError}"); } else { modified = true; } } } if (errors.Count > 0) { return (false, new ErrorResponse($"Failed to set component properties (no changes saved): {string.Join("; ", errors)}")); } } 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, string editingPrefabPath) { 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 sourcePrefabPath = childParams["sourcePrefabPath"]?.ToString() ?? childParams["source_prefab_path"]?.ToString(); string primitiveType = childParams["primitiveType"]?.ToString() ?? childParams["primitive_type"]?.ToString(); if (!string.IsNullOrEmpty(sourcePrefabPath) && !string.IsNullOrEmpty(primitiveType)) { return (false, new ErrorResponse("'source_prefab_path' and 'primitive_type' are mutually exclusive in create_child.")); } if (!string.IsNullOrEmpty(sourcePrefabPath)) { string sanitizedSourcePath = AssetPathUtility.SanitizeAssetPath(sourcePrefabPath); if (string.IsNullOrEmpty(sanitizedSourcePath)) { return (false, new ErrorResponse($"Invalid source_prefab_path '{sourcePrefabPath}'. Path traversal sequences are not allowed.")); } if (!string.IsNullOrEmpty(editingPrefabPath) && sanitizedSourcePath.Equals(editingPrefabPath, StringComparison.OrdinalIgnoreCase)) { return (false, new ErrorResponse($"Cannot nest prefab '{sanitizedSourcePath}' inside itself. This would create a circular reference.")); } GameObject sourcePrefabAsset = AssetDatabase.LoadAssetAtPath(sanitizedSourcePath); if (sourcePrefabAsset == null) { return (false, new ErrorResponse($"Source prefab not found at path: '{sanitizedSourcePath}'.")); } newChild = PrefabUtility.InstantiatePrefab(sourcePrefabAsset, parentTransform) as GameObject; if (newChild == null) { return (false, new ErrorResponse($"Failed to instantiate prefab from '{sanitizedSourcePath}' as nested child.")); } newChild.name = childName; } else 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); } // Ensure local-space transform (worldPositionStays=false) for all creation modes 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) { 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)) { // 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); } } // Set tag if specified string tag = childParams["tag"]?.ToString(); if (!string.IsNullOrEmpty(tag)) { try { newChild.tag = tag; } catch (Exception ex) { UnityEngine.Object.DestroyImmediate(newChild); return (false, new ErrorResponse($"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) { 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 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); } /// /// Removes child GameObjects from a prefab. /// private static (int removedCount, ErrorResponse error) RemoveChildren(JToken deleteChildToken, GameObject targetGo, GameObject prefabRoot) { int removedCount = 0; // Normalize to array JArray childrenToDelete; if (deleteChildToken is JArray arr) { childrenToDelete = arr; } else { childrenToDelete = new JArray { deleteChildToken }; } foreach (var childToken in childrenToDelete) { string childPath = childToken.Type == JTokenType.String ? childToken.ToString() : childToken["name"]?.ToString(); if (string.IsNullOrEmpty(childPath)) { return (removedCount, new ErrorResponse("'deleteChild'/'delete_child' entries must be a string or object with 'name' field.")); } // Find the child to remove Transform childToRemove = targetGo.transform.Find(childPath); if (childToRemove == null) { return (removedCount, new ErrorResponse($"Child '{childPath}' not found under '{targetGo.name}'.")); } UnityEngine.Object.DestroyImmediate(childToRemove.gameObject); removedCount++; McpLog.Info($"[ManagePrefabs] Removed child '{childPath}' under '{targetGo.name}' in prefab."); } return (removedCount, null); } #endregion #region Hierarchy Builder /// /// Builds a flat list of hierarchy items from a transform root. /// /// The root transform of the prefab. /// Asset path of the main prefab. /// List of hierarchy items with prefab information. private static List BuildHierarchyItems(Transform root, string mainPrefabPath) { var items = new List(); BuildHierarchyItemsRecursive(root, root, mainPrefabPath, "", items); return items; } /// /// Recursively builds hierarchy items. /// /// Current transform being processed. /// Root transform of the main prefab asset. /// Asset path of the main prefab. /// Parent path for building full hierarchy path. /// List to accumulate hierarchy items. private static void BuildHierarchyItemsRecursive(Transform transform, Transform mainPrefabRoot, string mainPrefabPath, string parentPath, List items) { if (transform == null) return; string name = transform.gameObject.name; string path = string.IsNullOrEmpty(parentPath) ? name : $"{parentPath}/{name}"; int instanceId = transform.gameObject.GetInstanceIDCompat(); bool activeSelf = transform.gameObject.activeSelf; int childCount = transform.childCount; var componentTypes = PrefabUtilityHelper.GetComponentTypeNames(transform.gameObject); // Prefab information bool isNestedPrefab = PrefabUtility.IsAnyPrefabInstanceRoot(transform.gameObject); bool isPrefabRoot = transform == mainPrefabRoot; int nestingDepth = isPrefabRoot ? 0 : PrefabUtilityHelper.GetPrefabNestingDepth(transform.gameObject, mainPrefabRoot); string parentPrefabPath = isNestedPrefab && !isPrefabRoot ? PrefabUtilityHelper.GetParentPrefabPath(transform.gameObject, mainPrefabRoot) : null; string nestedPrefabPath = isNestedPrefab ? PrefabUtilityHelper.GetNestedPrefabPath(transform.gameObject) : null; var item = new { name = name, instanceId = instanceId, path = path, activeSelf = activeSelf, childCount = childCount, componentTypes = componentTypes, prefab = new { isRoot = isPrefabRoot, isNestedRoot = isNestedPrefab, nestingDepth = nestingDepth, assetPath = isNestedPrefab ? nestedPrefabPath : mainPrefabPath, parentPath = parentPrefabPath } }; items.Add(item); // Recursively process children foreach (Transform child in transform) { BuildHierarchyItemsRecursive(child, mainPrefabRoot, mainPrefabPath, path, items); } } #endregion #region Prefab Stage private static object OpenPrefabStage(string requestedPath) { if (string.IsNullOrWhiteSpace(requestedPath)) { return new ErrorResponse("Either 'prefabPath' or 'path' parameter is required for open_prefab_stage."); } string sanitizedPath = AssetPathUtility.SanitizeAssetPath(requestedPath); if (sanitizedPath == null) { return new ErrorResponse($"Invalid prefab path (path traversal detected): '{requestedPath}'."); } if (!sanitizedPath.StartsWith("Assets/", StringComparison.OrdinalIgnoreCase)) { return new ErrorResponse($"Prefab path must be within the Assets folder. Got: '{sanitizedPath}'."); } if (!sanitizedPath.EndsWith(".prefab", StringComparison.OrdinalIgnoreCase)) { return new ErrorResponse($"Prefab path must end with '.prefab'. Got: '{sanitizedPath}'."); } try { GameObject prefabAsset = AssetDatabase.LoadAssetAtPath(sanitizedPath); if (prefabAsset == null) { return new ErrorResponse($"Prefab asset not found at '{sanitizedPath}'."); } var prefabStage = PrefabStageUtility.OpenPrefab(sanitizedPath); bool enteredStage = prefabStage != null && string.Equals(prefabStage.assetPath, sanitizedPath, StringComparison.OrdinalIgnoreCase) && prefabStage.prefabContentsRoot != null; if (!enteredStage) { return new ErrorResponse($"Failed to open prefab stage for '{sanitizedPath}'. PrefabStageUtility.OpenPrefab did not enter the requested prefab stage."); } return new SuccessResponse( $"Opened prefab stage for '{sanitizedPath}'.", new { prefabPath = sanitizedPath, openedPrefabPath = prefabStage.assetPath, rootName = prefabStage.prefabContentsRoot.name, enteredPrefabStage = enteredStage } ); } catch (Exception e) { return new ErrorResponse($"Error opening prefab stage: {e.Message}"); } } private static object SavePrefabStage() { try { var prefabStage = PrefabStageUtility.GetCurrentPrefabStage(); if (prefabStage == null) { return new ErrorResponse("Not currently in prefab editing mode. Open a prefab stage first with open_prefab_stage."); } if (!TrySavePrefabStage(prefabStage, out string prefabPath, out string errorMessage)) { return new ErrorResponse(errorMessage); } return new SuccessResponse($"Saved prefab stage changes for '{prefabPath}'.", new { prefabPath, saved = true }); } catch (Exception e) { return new ErrorResponse($"Error saving prefab stage: {e.Message}"); } } private static object ClosePrefabStage(bool saveBeforeClose = false) { try { var prefabStage = PrefabStageUtility.GetCurrentPrefabStage(); if (prefabStage == null) { return new SuccessResponse("Not currently in prefab editing mode."); } if (saveBeforeClose) { if (!TrySavePrefabStage(prefabStage, out _, out string errorMessage)) { return new ErrorResponse(errorMessage); } } string prefabPath = prefabStage.assetPath; StageUtility.GoToMainStage(); return new SuccessResponse($"Exited prefab stage for '{prefabPath}'.", new { prefabPath }); } catch (Exception e) { return new ErrorResponse($"Error closing prefab stage: {e.Message}"); } } private static bool TrySavePrefabStage(PrefabStage prefabStage, out string prefabPath, out string errorMessage) { prefabPath = prefabStage.assetPath; errorMessage = null; if (prefabStage.prefabContentsRoot == null) { errorMessage = $"Failed to save prefab stage for '{prefabPath}'. The prefab contents root is missing."; return false; } bool saved; PrefabUtility.SaveAsPrefabAsset(prefabStage.prefabContentsRoot, prefabPath, out saved); if (!saved) { errorMessage = $"Failed to save prefab stage for '{prefabPath}'. The file may be read-only or the disk may be full."; return false; } prefabStage.ClearDirtiness(); AssetDatabase.SaveAssets(); AssetDatabase.Refresh(); return true; } #endregion } }