feat: Prefab Feature Updates (#611)

* feat: Add prefab read operations (get_info, get_hierarchy, list_prefabs)

- Add get_info: retrieve prefab metadata (GUID, type, components, child count, variant info)
- Add get_hierarchy: get prefab internal structure with pagination support
- Add list_prefabs: search prefabs in project with optional name filtering
- Extract PrefabUtilityHelper class for reusable prefab utility methods
- Update Python tool descriptions and parameter documentation

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: Use correct API to save prefab stage changes

Replace PrefabUtility.SaveAsPrefabAsset (for creating new prefabs) with
EditorSceneManager.SaveScene to properly save stage modifications.

This fixes the issue where component additions were lost after closing
the prefab stage.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* refactor: improve code quality and error handling

- Add pagination constants (DefaultPageSize, MaxPageSize)
- Extract SaveAndRefreshStage helper to reduce duplication
- Change all user-facing messages to English
- Add REQUIRED_PARAMS validation in Python
- Split path parameter into prefab_path and folder_path for clarity
- Improve error handling with specific exception types

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* feat: Remove list_prefabs action and update related documentation

* feat: Enhance prefab management with detailed parameter descriptions and new unlinking option

* feat: Simplify prefab creation logic and unify logging for asset replacement

* feat: Update SaveStagePrefab method to use SetDirty and SaveAssets for prefab stage saving

* feat: Add PrefabUtilityHelper class with utility methods for prefab asset management

* feat: Refactor action constants and enhance parameter validation in prefab management

* feat: Update ValidateSourceObjectForPrefab method to remove replaceExisting parameter and simplify validation logic

* fix: Fix searchInactive parameter and improve prefab management

- Fix searchInactive not working correctly for child objects
- Improve error message accuracy for object not found
- Use Application.dataPath for reliable directory path resolution

* feat: Add path validation and security checks for prefab operations

* feat: Remove pagination from GetHierarchy method and simplify prefab retrieval

* feat: Remove mode parameter from prefab management functions to simplify usage

* fix: Improve path validation and replace logic in prefab management

* feat: Enhance prefab management by adding nesting depth and parent prefab path retrieval

* fix: resolve Unknown pseudo class last-child USS warnings

Unity UI Toolkit does not support the :last-child pseudo-class. Replace
it with a .section-last class that is applied programmatically to the
last section in each .section-stack container.

Also moves the Configure All Detected Clients button to the bottom
of the Client Configuration section and makes it auto-width.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: improve prefab stage save for automated workflows

- Add force parameter to save_open_stage for automated workflows
  where isDirty may not be correctly set
- Use PrefabUtility.SaveAsPrefabAsset for dialog-free saving
- Mark prefab stage scene dirty when modifying GameObjects in prefab mode
- Skip save when no changes and force=false (prevents false dirty flag)

The force parameter ensures reliable saving in CI/automation scenarios
where Unity dirty tracking may be inconsistent with programmatic changes.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Update prefab.py

* refactor: remove unnecessary blank line before create function

* feat: add info and hierarchy commands to prefab CLI for enhanced prefab management

* feat: enhance prefab management with comprehensive CRUD tests and ensure dirty state tracking

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: David Sarno <david@lighthaus.us>
main
whatevertogo 2026-01-26 08:36:29 +08:00 committed by GitHub
parent bb56f78ad3
commit 300a745bf2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 1842 additions and 366 deletions

View File

@ -27,6 +27,7 @@ namespace MCPForUnity.Editor.Helpers
/// <summary> /// <summary>
/// Normalizes a Unity asset path by ensuring forward slashes are used and that it is rooted under "Assets/". /// Normalizes a Unity asset path by ensuring forward slashes are used and that it is rooted under "Assets/".
/// Also protects against path traversal attacks using "../" sequences.
/// </summary> /// </summary>
public static string SanitizeAssetPath(string path) public static string SanitizeAssetPath(string path)
{ {
@ -36,6 +37,15 @@ namespace MCPForUnity.Editor.Helpers
} }
path = NormalizeSeparators(path); path = NormalizeSeparators(path);
// Check for path traversal sequences
if (path.Contains(".."))
{
McpLog.Warn($"[AssetPathUtility] Path contains potential traversal sequence: '{path}'");
return null;
}
// Ensure path starts with Assets/
if (!path.StartsWith("Assets/", StringComparison.OrdinalIgnoreCase)) if (!path.StartsWith("Assets/", StringComparison.OrdinalIgnoreCase))
{ {
return "Assets/" + path.TrimStart('/'); return "Assets/" + path.TrimStart('/');
@ -44,6 +54,45 @@ namespace MCPForUnity.Editor.Helpers
return path; return path;
} }
/// <summary>
/// Checks if a given asset path is valid and safe (no traversal, within Assets folder).
/// </summary>
/// <returns>True if the path is valid, false otherwise.</returns>
public static bool IsValidAssetPath(string path)
{
if (string.IsNullOrEmpty(path))
{
return false;
}
// Normalize for comparison
string normalized = NormalizeSeparators(path);
// Must start with Assets/
if (!normalized.StartsWith("Assets/", StringComparison.OrdinalIgnoreCase))
{
return false;
}
// Must not contain traversal sequences
if (normalized.Contains(".."))
{
return false;
}
// Must not contain invalid path characters
char[] invalidChars = { ':', '*', '?', '"', '<', '>', '|' };
foreach (char c in invalidChars)
{
if (normalized.IndexOf(c) >= 0)
{
return false;
}
}
return true;
}
/// <summary> /// <summary>
/// Gets the MCP for Unity package root path. /// Gets the MCP for Unity package root path.
/// Works for registry Package Manager, local Package Manager, and Asset Store installations. /// Works for registry Package Manager, local Package Manager, and Asset Store installations.

View File

@ -0,0 +1,228 @@
using System;
using System.Collections.Generic;
using UnityEditor;
using UnityEngine;
namespace MCPForUnity.Editor.Helpers
{
/// <summary>
/// Provides common utility methods for working with Unity Prefab assets.
/// </summary>
public static class PrefabUtilityHelper
{
/// <summary>
/// Gets the GUID for a prefab asset path.
/// </summary>
/// <param name="assetPath">The Unity asset path (e.g., "Assets/Prefabs/MyPrefab.prefab")</param>
/// <returns>The GUID string, or null if the path is invalid.</returns>
public static string GetPrefabGUID(string assetPath)
{
if (string.IsNullOrEmpty(assetPath))
{
return null;
}
try
{
return AssetDatabase.AssetPathToGUID(assetPath);
}
catch (Exception ex)
{
McpLog.Warn($"Failed to get GUID for asset path '{assetPath}': {ex.Message}");
return null;
}
}
/// <summary>
/// Gets variant information if the prefab is a variant.
/// </summary>
/// <param name="prefabAsset">The prefab GameObject to check.</param>
/// <returns>A tuple containing (isVariant, parentPath, parentGuid).</returns>
public static (bool isVariant, string parentPath, string parentGuid) GetVariantInfo(GameObject prefabAsset)
{
if (prefabAsset == null)
{
return (false, null, null);
}
try
{
PrefabAssetType assetType = PrefabUtility.GetPrefabAssetType(prefabAsset);
if (assetType != PrefabAssetType.Variant)
{
return (false, null, null);
}
GameObject parentAsset = PrefabUtility.GetCorrespondingObjectFromSource(prefabAsset);
if (parentAsset == null)
{
return (true, null, null);
}
string parentPath = AssetDatabase.GetAssetPath(parentAsset);
string parentGuid = GetPrefabGUID(parentPath);
return (true, parentPath, parentGuid);
}
catch (Exception ex)
{
McpLog.Warn($"Failed to get variant info for '{prefabAsset.name}': {ex.Message}");
return (false, null, null);
}
}
/// <summary>
/// Gets the list of component type names on a GameObject.
/// </summary>
/// <param name="obj">The GameObject to inspect.</param>
/// <returns>A list of component type full names.</returns>
public static List<string> GetComponentTypeNames(GameObject obj)
{
var typeNames = new List<string>();
if (obj == null)
{
return typeNames;
}
try
{
var components = obj.GetComponents<Component>();
foreach (var component in components)
{
if (component != null)
{
typeNames.Add(component.GetType().FullName);
}
}
}
catch (Exception ex)
{
McpLog.Warn($"Failed to get component types for '{obj.name}': {ex.Message}");
}
return typeNames;
}
/// <summary>
/// Recursively counts all children in the hierarchy.
/// </summary>
/// <param name="transform">The root transform to count from.</param>
/// <returns>Total number of children in the hierarchy.</returns>
public static int CountChildrenRecursive(Transform transform)
{
if (transform == null)
{
return 0;
}
int count = transform.childCount;
for (int i = 0; i < transform.childCount; i++)
{
count += CountChildrenRecursive(transform.GetChild(i));
}
return count;
}
/// <summary>
/// Gets the source prefab path for a nested prefab instance.
/// </summary>
/// <param name="gameObject">The GameObject to check.</param>
/// <returns>The asset path of the source prefab, or null if not a nested prefab.</returns>
public static string GetNestedPrefabPath(GameObject gameObject)
{
if (gameObject == null || !PrefabUtility.IsAnyPrefabInstanceRoot(gameObject))
{
return null;
}
try
{
var sourcePrefab = PrefabUtility.GetCorrespondingObjectFromSource(gameObject);
if (sourcePrefab != null)
{
return AssetDatabase.GetAssetPath(sourcePrefab);
}
}
catch (Exception ex)
{
McpLog.Warn($"Failed to get nested prefab path for '{gameObject.name}': {ex.Message}");
}
return null;
}
/// <summary>
/// Gets the nesting depth of a prefab instance within the prefab hierarchy.
/// Returns 0 for main prefab root, 1 for first-level nested, 2 for second-level, etc.
/// Returns -1 for non-prefab-root objects.
/// </summary>
/// <param name="gameObject">The GameObject to analyze.</param>
/// <param name="mainPrefabRoot">The root transform of the main prefab asset.</param>
/// <returns>Nesting depth (0=main root, 1+=nested), or -1 if not a prefab root.</returns>
public static int GetPrefabNestingDepth(GameObject gameObject, Transform mainPrefabRoot)
{
if (gameObject == null)
return -1;
// Main prefab root
if (gameObject.transform == mainPrefabRoot)
return 0;
// Not a prefab instance root
if (!PrefabUtility.IsAnyPrefabInstanceRoot(gameObject))
return -1;
// Calculate depth by walking up the hierarchy
int depth = 0;
Transform current = gameObject.transform;
while (current != null && current != mainPrefabRoot)
{
if (PrefabUtility.IsAnyPrefabInstanceRoot(current.gameObject))
{
depth++;
}
current = current.parent;
}
return depth;
}
/// <summary>
/// Gets the parent prefab path for a nested prefab instance.
/// Returns null for main prefab root or non-prefab objects.
/// </summary>
/// <param name="gameObject">The GameObject to analyze.</param>
/// <param name="mainPrefabRoot">The root transform of the main prefab asset.</param>
/// <returns>The asset path of the parent prefab, or null if none.</returns>
public static string GetParentPrefabPath(GameObject gameObject, Transform mainPrefabRoot)
{
if (gameObject == null || gameObject.transform == mainPrefabRoot)
return null;
if (!PrefabUtility.IsAnyPrefabInstanceRoot(gameObject))
return null;
// Walk up the hierarchy to find the parent prefab instance
Transform current = gameObject.transform.parent;
while (current != null && current != mainPrefabRoot)
{
if (PrefabUtility.IsAnyPrefabInstanceRoot(current.gameObject))
{
return GetNestedPrefabPath(current.gameObject);
}
current = current.parent;
}
// Parent is the main prefab root - get its asset path
if (mainPrefabRoot != null)
{
return AssetDatabase.GetAssetPath(mainPrefabRoot.gameObject);
}
return null;
}
}
}

View File

@ -5,6 +5,7 @@ using System.Linq;
using MCPForUnity.Editor.Helpers; using MCPForUnity.Editor.Helpers;
using Newtonsoft.Json.Linq; using Newtonsoft.Json.Linq;
using UnityEditor; using UnityEditor;
using UnityEditor.SceneManagement;
using UnityEditorInternal; using UnityEditorInternal;
using UnityEngine; using UnityEngine;
@ -239,6 +240,18 @@ namespace MCPForUnity.Editor.Tools.GameObjects
} }
EditorUtility.SetDirty(targetGo); EditorUtility.SetDirty(targetGo);
// Mark the appropriate scene as dirty (handles both regular scenes and prefab stages)
var prefabStage = PrefabStageUtility.GetCurrentPrefabStage();
if (prefabStage != null)
{
EditorSceneManager.MarkSceneDirty(prefabStage.scene);
}
else
{
EditorSceneManager.MarkSceneDirty(EditorSceneManager.GetActiveScene());
}
return new SuccessResponse( return new SuccessResponse(
$"GameObject '{targetGo.name}' modified successfully.", $"GameObject '{targetGo.name}' modified successfully.",
Helpers.GameObjectSerializer.GetGameObjectData(targetGo) Helpers.GameObjectSerializer.GetGameObjectData(targetGo)

View File

@ -1,4 +1,5 @@
using System; using System;
using System.Collections.Generic;
using System.IO; using System.IO;
using MCPForUnity.Editor.Helpers; using MCPForUnity.Editor.Helpers;
using Newtonsoft.Json.Linq; using Newtonsoft.Json.Linq;
@ -15,7 +16,14 @@ namespace MCPForUnity.Editor.Tools.Prefabs
/// </summary> /// </summary>
public static class ManagePrefabs public static class ManagePrefabs
{ {
private const string SupportedActions = "open_stage, close_stage, save_open_stage, create_from_gameobject"; // Action constants
private const string ACTION_OPEN_STAGE = "open_stage";
private const string ACTION_CLOSE_STAGE = "close_stage";
private const string ACTION_SAVE_OPEN_STAGE = "save_open_stage";
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 SupportedActions = ACTION_OPEN_STAGE + ", " + ACTION_CLOSE_STAGE + ", " + ACTION_SAVE_OPEN_STAGE + ", " + ACTION_CREATE_FROM_GAMEOBJECT + ", " + ACTION_GET_INFO + ", " + ACTION_GET_HIERARCHY;
public static object HandleCommand(JObject @params) public static object HandleCommand(JObject @params)
{ {
@ -34,14 +42,18 @@ namespace MCPForUnity.Editor.Tools.Prefabs
{ {
switch (action) switch (action)
{ {
case "open_stage": case ACTION_OPEN_STAGE:
return OpenStage(@params); return OpenStage(@params);
case "close_stage": case ACTION_CLOSE_STAGE:
return CloseStage(@params); return CloseStage(@params);
case "save_open_stage": case ACTION_SAVE_OPEN_STAGE:
return SaveOpenStage(); return SaveOpenStage(@params);
case "create_from_gameobject": case ACTION_CREATE_FROM_GAMEOBJECT:
return CreatePrefabFromGameObject(@params); return CreatePrefabFromGameObject(@params);
case ACTION_GET_INFO:
return GetInfo(@params);
case ACTION_GET_HIERARCHY:
return GetHierarchy(@params);
default: default:
return new ErrorResponse($"Unknown action: '{action}'. Valid actions are: {SupportedActions}."); return new ErrorResponse($"Unknown action: '{action}'. Valid actions are: {SupportedActions}.");
} }
@ -53,6 +65,9 @@ namespace MCPForUnity.Editor.Tools.Prefabs
} }
} }
/// <summary>
/// Opens a prefab in prefab mode for editing.
/// </summary>
private static object OpenStage(JObject @params) private static object OpenStage(JObject @params)
{ {
string prefabPath = @params["prefabPath"]?.ToString(); string prefabPath = @params["prefabPath"]?.ToString();
@ -62,18 +77,16 @@ namespace MCPForUnity.Editor.Tools.Prefabs
} }
string sanitizedPath = AssetPathUtility.SanitizeAssetPath(prefabPath); string sanitizedPath = AssetPathUtility.SanitizeAssetPath(prefabPath);
if (string.IsNullOrEmpty(sanitizedPath))
{
return new ErrorResponse($"Invalid prefab path: '{prefabPath}'.");
}
GameObject prefabAsset = AssetDatabase.LoadAssetAtPath<GameObject>(sanitizedPath); GameObject prefabAsset = AssetDatabase.LoadAssetAtPath<GameObject>(sanitizedPath);
if (prefabAsset == null) if (prefabAsset == null)
{ {
return new ErrorResponse($"No prefab asset found at path '{sanitizedPath}'."); return new ErrorResponse($"No prefab asset found at path '{sanitizedPath}'.");
} }
string modeValue = @params["mode"]?.ToString();
if (!string.IsNullOrEmpty(modeValue) && !modeValue.Equals(PrefabStage.Mode.InIsolation.ToString(), StringComparison.OrdinalIgnoreCase))
{
return new ErrorResponse("Only PrefabStage mode 'InIsolation' is supported at this time.");
}
PrefabStage stage = PrefabStageUtility.OpenPrefab(sanitizedPath); PrefabStage stage = PrefabStageUtility.OpenPrefab(sanitizedPath);
if (stage == null) if (stage == null)
{ {
@ -83,6 +96,9 @@ namespace MCPForUnity.Editor.Tools.Prefabs
return new SuccessResponse($"Opened prefab stage for '{sanitizedPath}'.", SerializeStage(stage)); return new SuccessResponse($"Opened prefab stage for '{sanitizedPath}'.", SerializeStage(stage));
} }
/// <summary>
/// Closes the currently open prefab stage, optionally saving first.
/// </summary>
private static object CloseStage(JObject @params) private static object CloseStage(JObject @params)
{ {
PrefabStage stage = PrefabStageUtility.GetCurrentPrefabStage(); PrefabStage stage = PrefabStageUtility.GetCurrentPrefabStage();
@ -91,18 +107,30 @@ namespace MCPForUnity.Editor.Tools.Prefabs
return new SuccessResponse("No prefab stage was open."); return new SuccessResponse("No prefab stage was open.");
} }
string assetPath = stage.assetPath;
bool saveBeforeClose = @params["saveBeforeClose"]?.ToObject<bool>() ?? false; bool saveBeforeClose = @params["saveBeforeClose"]?.ToObject<bool>() ?? false;
if (saveBeforeClose && stage.scene.isDirty) if (saveBeforeClose && stage.scene.isDirty)
{ {
SaveStagePrefab(stage); try
AssetDatabase.SaveAssets(); {
SaveAndRefreshStage(stage);
}
catch (Exception e)
{
return new ErrorResponse($"Failed to save prefab before closing: {e.Message}");
}
} }
StageUtility.GoToMainStage(); StageUtility.GoToMainStage();
return new SuccessResponse($"Closed prefab stage for '{stage.assetPath}'."); return new SuccessResponse($"Closed prefab stage for '{assetPath}'.");
} }
private static object SaveOpenStage() /// <summary>
/// Saves changes to the currently open prefab stage.
/// Supports a 'force' parameter for automated workflows where isDirty may not be set.
/// </summary>
private static object SaveOpenStage(JObject @params)
{ {
PrefabStage stage = PrefabStageUtility.GetCurrentPrefabStage(); PrefabStage stage = PrefabStageUtility.GetCurrentPrefabStage();
if (stage == null) if (stage == null)
@ -110,107 +138,327 @@ namespace MCPForUnity.Editor.Tools.Prefabs
return new ErrorResponse("No prefab stage is currently open."); return new ErrorResponse("No prefab stage is currently open.");
} }
SaveStagePrefab(stage); if (!ValidatePrefabStageForSave(stage))
AssetDatabase.SaveAssets(); {
return new SuccessResponse($"Saved prefab stage for '{stage.assetPath}'.", SerializeStage(stage)); return new ErrorResponse("Prefab stage validation failed. Cannot save.");
}
// Check for force parameter (useful for automated workflows)
bool force = @params?["force"]?.ToObject<bool>() ?? false;
// Check if there are actual changes to save
bool wasDirty = stage.scene.isDirty;
if (!wasDirty && !force)
{
return new SuccessResponse($"Prefab stage for '{stage.assetPath}' has no unsaved changes.", SerializeStage(stage));
}
try
{
SaveAndRefreshStage(stage, force);
return new SuccessResponse($"Saved prefab stage for '{stage.assetPath}'.", SerializeStage(stage));
}
catch (Exception e)
{
return new ErrorResponse($"Failed to save prefab: {e.Message}");
}
} }
private static void SaveStagePrefab(PrefabStage stage) #region Prefab Save Operations
/// <summary>
/// Saves the prefab stage and refreshes the asset database.
/// Uses PrefabUtility.SaveAsPrefabAsset for reliable prefab saving without dialogs.
/// </summary>
/// <param name="stage">The prefab stage to save.</param>
/// <param name="force">If true, marks the prefab dirty before saving to ensure changes are captured.</param>
private static void SaveAndRefreshStage(PrefabStage stage, bool force = false)
{ {
if (stage?.prefabContentsRoot == null) if (stage == null)
{
throw new ArgumentNullException(nameof(stage), "Prefab stage cannot be null.");
}
if (stage.prefabContentsRoot == null)
{ {
throw new InvalidOperationException("Cannot save prefab stage without a prefab root."); throw new InvalidOperationException("Cannot save prefab stage without a prefab root.");
} }
bool saved = PrefabUtility.SaveAsPrefabAsset(stage.prefabContentsRoot, stage.assetPath); if (string.IsNullOrEmpty(stage.assetPath))
if (!saved)
{ {
throw new InvalidOperationException($"Failed to save prefab asset at '{stage.assetPath}'."); throw new InvalidOperationException("Prefab stage has invalid asset path.");
} }
// When force=true, mark the prefab root dirty to ensure changes are saved
// This is useful for automated workflows where isDirty may not be set correctly
if (force)
{
EditorUtility.SetDirty(stage.prefabContentsRoot);
EditorSceneManager.MarkSceneDirty(stage.scene);
}
// Mark all children as dirty to ensure their changes are captured
foreach (Transform child in stage.prefabContentsRoot.GetComponentsInChildren<Transform>(true))
{
if (child != stage.prefabContentsRoot.transform)
{
EditorUtility.SetDirty(child.gameObject);
}
}
// Use PrefabUtility.SaveAsPrefabAsset which saves without dialogs
// This is more reliable for automated workflows than EditorSceneManager.SaveScene
bool success;
PrefabUtility.SaveAsPrefabAsset(stage.prefabContentsRoot, stage.assetPath, out success);
if (!success)
{
throw new InvalidOperationException($"Failed to save prefab asset for '{stage.assetPath}'.");
}
// Ensure changes are persisted to disk
AssetDatabase.SaveAssets();
AssetDatabase.Refresh();
McpLog.Info($"[ManagePrefabs] Successfully saved prefab '{stage.assetPath}'.");
} }
private static object CreatePrefabFromGameObject(JObject @params) /// <summary>
/// Validates prefab stage before saving.
/// </summary>
private static bool ValidatePrefabStageForSave(PrefabStage stage)
{ {
string targetName = @params["target"]?.ToString() ?? @params["name"]?.ToString(); if (stage == null)
if (string.IsNullOrEmpty(targetName))
{ {
return new ErrorResponse("'target' parameter is required for create_from_gameobject."); McpLog.Warn("[ManagePrefabs] No prefab stage is open.");
return false;
} }
bool includeInactive = @params["searchInactive"]?.ToObject<bool>() ?? false; if (stage.prefabContentsRoot == null)
{
McpLog.Error($"[ManagePrefabs] Prefab stage '{stage.assetPath}' has no root object.");
return false;
}
if (string.IsNullOrEmpty(stage.assetPath))
{
McpLog.Error("[ManagePrefabs] Prefab stage has invalid asset path.");
return false;
}
return true;
}
#endregion
#region Create Prefab from GameObject
/// <summary>
/// Creates a prefab asset from a GameObject in the scene.
/// </summary>
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); GameObject sourceObject = FindSceneObjectByName(targetName, includeInactive);
if (sourceObject == null) if (sourceObject == null)
{ {
return new ErrorResponse($"GameObject '{targetName}' not found in the active scene."); return new ErrorResponse($"GameObject '{targetName}' not found in the active scene or prefab stage{(includeInactive ? " (including inactive objects)" : "")}.");
} }
if (PrefabUtility.IsPartOfPrefabAsset(sourceObject)) // 3. Validate source object state
var objectValidation = ValidateSourceObjectForPrefab(sourceObject, unlinkIfInstance);
if (!objectValidation.isValid)
{ {
return new ErrorResponse( return new ErrorResponse(objectValidation.errorMessage);
$"GameObject '{sourceObject.name}' is part of a prefab asset. Open the prefab stage to save changes instead."
);
} }
PrefabInstanceStatus status = PrefabUtility.GetPrefabInstanceStatus(sourceObject); // 4. Check for path conflicts and track if file will be replaced
if (status != PrefabInstanceStatus.NotAPrefab) bool fileExistedAtPath = AssetDatabase.LoadAssetAtPath<UnityEngine.Object>(finalPath) != null;
{
return new ErrorResponse(
$"GameObject '{sourceObject.name}' is already linked to an existing prefab instance."
);
}
string requestedPath = @params["prefabPath"]?.ToString(); if (!replaceExisting && fileExistedAtPath)
if (string.IsNullOrWhiteSpace(requestedPath))
{
return new ErrorResponse("'prefabPath' parameter is required for create_from_gameobject.");
}
string sanitizedPath = AssetPathUtility.SanitizeAssetPath(requestedPath);
if (!sanitizedPath.EndsWith(".prefab", StringComparison.OrdinalIgnoreCase))
{
sanitizedPath += ".prefab";
}
bool allowOverwrite = @params["allowOverwrite"]?.ToObject<bool>() ?? false;
string finalPath = sanitizedPath;
if (!allowOverwrite && AssetDatabase.LoadAssetAtPath<UnityEngine.Object>(finalPath) != null)
{ {
finalPath = AssetDatabase.GenerateUniqueAssetPath(finalPath); finalPath = AssetDatabase.GenerateUniqueAssetPath(finalPath);
McpLog.Info($"[ManagePrefabs] Generated unique path: {finalPath}");
} }
// 5. Ensure directory exists
EnsureAssetDirectoryExists(finalPath); 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. Create the prefab
try try
{ {
GameObject connectedInstance = PrefabUtility.SaveAsPrefabAssetAndConnect( GameObject result = CreatePrefabAsset(sourceObject, finalPath, replaceExisting);
sourceObject,
finalPath,
InteractionMode.AutomatedAction
);
if (connectedInstance == null) if (result == null)
{ {
return new ErrorResponse($"Failed to save prefab asset at '{finalPath}'."); return new ErrorResponse($"Failed to create prefab asset at '{finalPath}'.");
} }
Selection.activeGameObject = connectedInstance; // 8. Select the newly created instance
Selection.activeGameObject = result;
return new SuccessResponse( return new SuccessResponse(
$"Prefab created at '{finalPath}' and instance linked.", $"Prefab created at '{finalPath}' and instance linked.",
new new
{ {
prefabPath = finalPath, prefabPath = finalPath,
instanceId = connectedInstance.GetInstanceID() instanceId = result.GetInstanceID(),
instanceName = result.name,
wasUnlinked = unlinkIfInstance && objectValidation.shouldUnlink,
wasReplaced = replaceExisting && fileExistedAtPath,
componentCount = result.GetComponents<Component>().Length,
childCount = result.transform.childCount
} }
); );
} }
catch (Exception e) catch (Exception e)
{ {
return new ErrorResponse($"Error saving prefab asset at '{finalPath}': {e.Message}"); McpLog.Error($"[ManagePrefabs] Error creating prefab at '{finalPath}': {e}");
return new ErrorResponse($"Error saving prefab asset: {e.Message}");
} }
} }
/// <summary>
/// Validates parameters for creating a prefab from GameObject.
/// </summary>
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<bool>() ?? false;
bool replaceExisting = @params["allowOverwrite"]?.ToObject<bool>() ?? false;
bool unlinkIfInstance = @params["unlinkIfInstance"]?.ToObject<bool>() ?? false;
return (true, null, targetName, sanitizedPath, includeInactive, replaceExisting, unlinkIfInstance);
}
/// <summary>
/// Validates source object can be converted to prefab.
/// </summary>
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);
}
/// <summary>
/// Creates a prefab asset from a GameObject.
/// </summary>
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;
}
#endregion
/// <summary>
/// Ensures the directory for an asset path exists, creating it if necessary.
/// </summary>
private static void EnsureAssetDirectoryExists(string assetPath) private static void EnsureAssetDirectoryExists(string assetPath)
{ {
string directory = Path.GetDirectoryName(assetPath); string directory = Path.GetDirectoryName(assetPath);
@ -219,37 +467,54 @@ namespace MCPForUnity.Editor.Tools.Prefabs
return; return;
} }
string fullDirectory = Path.Combine(Directory.GetCurrentDirectory(), directory); // 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)) if (!Directory.Exists(fullDirectory))
{ {
Directory.CreateDirectory(fullDirectory); Directory.CreateDirectory(fullDirectory);
AssetDatabase.Refresh(ImportAssetOptions.ForceSynchronousImport); AssetDatabase.Refresh(ImportAssetOptions.ForceSynchronousImport);
McpLog.Info($"[ManagePrefabs] Created directory: {directory}");
} }
} }
/// <summary>
/// Finds a GameObject by name in the active scene or current prefab stage.
/// </summary>
private static GameObject FindSceneObjectByName(string name, bool includeInactive) private static GameObject FindSceneObjectByName(string name, bool includeInactive)
{ {
// First check if we're in Prefab Stage
PrefabStage stage = PrefabStageUtility.GetCurrentPrefabStage(); PrefabStage stage = PrefabStageUtility.GetCurrentPrefabStage();
if (stage?.prefabContentsRoot != null) if (stage?.prefabContentsRoot != null)
{ {
foreach (Transform transform in stage.prefabContentsRoot.GetComponentsInChildren<Transform>(includeInactive)) foreach (Transform transform in stage.prefabContentsRoot.GetComponentsInChildren<Transform>(includeInactive))
{ {
if (transform.name == name) if (transform.name == name && (includeInactive || transform.gameObject.activeSelf))
{ {
return transform.gameObject; return transform.gameObject;
} }
} }
} }
// Search in the active scene
Scene activeScene = SceneManager.GetActiveScene(); Scene activeScene = SceneManager.GetActiveScene();
foreach (GameObject root in activeScene.GetRootGameObjects()) 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<Transform>(includeInactive)) foreach (Transform transform in root.GetComponentsInChildren<Transform>(includeInactive))
{ {
GameObject candidate = transform.gameObject; if (transform.name == name && (includeInactive || transform.gameObject.activeSelf))
if (candidate.name == name)
{ {
return candidate; return transform.gameObject;
} }
} }
} }
@ -257,6 +522,177 @@ namespace MCPForUnity.Editor.Tools.Prefabs
return null; return null;
} }
#region Read Operations
/// <summary>
/// Gets basic metadata information about a prefab asset.
/// </summary>
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<GameObject>(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
}
);
}
/// <summary>
/// Gets the hierarchical structure of a prefab asset.
/// Returns all objects in the prefab for full client-side filtering and search.
/// </summary>
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 Hierarchy Builder
/// <summary>
/// Builds a flat list of hierarchy items from a transform root.
/// </summary>
/// <param name="root">The root transform of the prefab.</param>
/// <param name="mainPrefabPath">Asset path of the main prefab.</param>
/// <returns>List of hierarchy items with prefab information.</returns>
private static List<object> BuildHierarchyItems(Transform root, string mainPrefabPath)
{
var items = new List<object>();
BuildHierarchyItemsRecursive(root, root, mainPrefabPath, "", items);
return items;
}
/// <summary>
/// Recursively builds hierarchy items.
/// </summary>
/// <param name="transform">Current transform being processed.</param>
/// <param name="mainPrefabRoot">Root transform of the main prefab asset.</param>
/// <param name="mainPrefabPath">Asset path of the main prefab.</param>
/// <param name="parentPath">Parent path for building full hierarchy path.</param>
/// <param name="items">List to accumulate hierarchy items.</param>
private static void BuildHierarchyItemsRecursive(Transform transform, Transform mainPrefabRoot, string mainPrefabPath, string parentPath, List<object> items)
{
if (transform == null) return;
string name = transform.gameObject.name;
string path = string.IsNullOrEmpty(parentPath) ? name : $"{parentPath}/{name}";
int instanceId = transform.gameObject.GetInstanceID();
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
/// <summary>
/// Serializes the prefab stage information for response.
/// </summary>
private static object SerializeStage(PrefabStage stage) private static object SerializeStage(PrefabStage stage)
{ {
if (stage == null) if (stage == null)
@ -273,6 +709,5 @@ namespace MCPForUnity.Editor.Tools.Prefabs
isDirty = stage.scene.isDirty isDirty = stage.scene.isDirty
}; };
} }
} }
} }

View File

@ -11,18 +11,13 @@ from cli.utils.connection import run_command, UnityConnectionError
@click.group() @click.group()
def prefab(): def prefab():
"""Prefab operations - open, save, create prefabs.""" """Prefab operations - info, hierarchy, open, save, close, create prefabs."""
pass pass
@prefab.command("open") @prefab.command("open")
@click.argument("path") @click.argument("path")
@click.option( def open_stage(path: str):
"--mode", "-m",
default="InIsolation",
help="Prefab stage mode (InIsolation)."
)
def open_stage(path: str, mode: str):
"""Open a prefab in the prefab stage for editing. """Open a prefab in the prefab stage for editing.
\b \b
@ -34,7 +29,6 @@ def open_stage(path: str, mode: str):
params: dict[str, Any] = { params: dict[str, Any] = {
"action": "open_stage", "action": "open_stage",
"prefabPath": path, "prefabPath": path,
"mode": mode,
} }
try: try:
@ -80,18 +74,29 @@ def close_stage(save: bool):
@prefab.command("save") @prefab.command("save")
def save_stage(): @click.option(
"--force", "-f",
is_flag=True,
help="Force save even if no changes detected. Useful for automated workflows."
)
def save_stage(force: bool):
"""Save the currently open prefab stage. """Save the currently open prefab stage.
\b \b
Examples: Examples:
unity-mcp prefab save unity-mcp prefab save
unity-mcp prefab save --force
""" """
config = get_config() config = get_config()
params: dict[str, Any] = {
"action": "save_open_stage",
}
if force:
params["force"] = True
try: try:
result = run_command("manage_prefabs", { result = run_command("manage_prefabs", params, config)
"action": "save_open_stage"}, config)
click.echo(format_output(result, config.format)) click.echo(format_output(result, config.format))
if result.get("success"): if result.get("success"):
print_success("Saved prefab") print_success("Saved prefab")
@ -100,6 +105,114 @@ def save_stage():
sys.exit(1) sys.exit(1)
@prefab.command("info")
@click.argument("path")
@click.option(
"--compact", "-c",
is_flag=True,
help="Show compact output (key values only)."
)
def info(path: str, compact: bool):
"""Get information about a prefab asset.
\b
Examples:
unity-mcp prefab info "Assets/Prefabs/Player.prefab"
unity-mcp prefab info "Assets/Prefabs/UI.prefab" --compact
"""
config = get_config()
params: dict[str, Any] = {
"action": "get_info",
"prefabPath": path,
}
try:
result = run_command("manage_prefabs", params, config)
# Get the actual response data from the wrapped result structure
response_data = result.get("result", result)
if compact and response_data.get("success") and response_data.get("data"):
data = response_data["data"]
click.echo(f"Prefab: {data.get('assetPath', path)}")
click.echo(f" Type: {data.get('prefabType', 'Unknown')}")
click.echo(f" Root: {data.get('rootObjectName', 'N/A')}")
click.echo(f" GUID: {data.get('guid', 'N/A')}")
click.echo(f" Components: {len(data.get('rootComponentTypes', []))}")
click.echo(f" Children: {data.get('childCount', 0)}")
if data.get('isVariant'):
click.echo(f" Variant of: {data.get('parentPrefab', 'N/A')}")
else:
click.echo(format_output(result, config.format))
except UnityConnectionError as e:
print_error(str(e))
sys.exit(1)
@prefab.command("hierarchy")
@click.argument("path")
@click.option(
"--compact", "-c",
is_flag=True,
help="Show compact output (names and paths only)."
)
@click.option(
"--show-prefab-info", "-p",
is_flag=True,
help="Show prefab nesting information."
)
def hierarchy(path: str, compact: bool, show_prefab_info: bool):
"""Get the hierarchical structure of a prefab.
\b
Examples:
unity-mcp prefab hierarchy "Assets/Prefabs/Player.prefab"
unity-mcp prefab hierarchy "Assets/Prefabs/UI.prefab" --compact
unity-mcp prefab hierarchy "Assets/Prefabs/Complex.prefab" --show-prefab-info
"""
config = get_config()
params: dict[str, Any] = {
"action": "get_hierarchy",
"prefabPath": path,
}
try:
result = run_command("manage_prefabs", params, config)
# Get the actual response data from the wrapped result structure
response_data = result.get("result", result)
if compact and response_data.get("success") and response_data.get("data"):
data = response_data["data"]
items = data.get("items", [])
for item in items:
indent = " " * item.get("path", "").count("/")
prefab_info = ""
if show_prefab_info and item.get("prefab", {}).get("isNestedRoot"):
prefab_info = f" [nested: {item['prefab']['assetPath']}]"
click.echo(f"{indent}{item.get('name')}{prefab_info}")
click.echo(f"\nTotal: {data.get('total', 0)} objects")
elif show_prefab_info:
# Show prefab info in readable format
if response_data.get("success") and response_data.get("data"):
data = response_data["data"]
items = data.get("items", [])
for item in items:
prefab = item.get("prefab", {})
prefab_info = ""
if prefab.get("isRoot"):
prefab_info = " [root]"
elif prefab.get("isNestedRoot"):
prefab_info = f" [nested: {prefab.get('nestingDepth', 0)}]"
click.echo(f"{item.get('path')}{prefab_info}")
click.echo(f"\nTotal: {data.get('total', 0)} objects")
else:
click.echo(format_output(result, config.format))
else:
click.echo(format_output(result, config.format))
except UnityConnectionError as e:
print_error(str(e))
sys.exit(1)
@prefab.command("create") @prefab.command("create")
@click.argument("target") @click.argument("target")
@click.argument("path") @click.argument("path")
@ -113,13 +226,19 @@ def save_stage():
is_flag=True, is_flag=True,
help="Include inactive objects when finding target." help="Include inactive objects when finding target."
) )
def create(target: str, path: str, overwrite: bool, include_inactive: bool): @click.option(
"--unlink-if-instance",
is_flag=True,
help="Unlink from existing prefab before creating new one."
)
def create(target: str, path: str, overwrite: bool, include_inactive: bool, unlink_if_instance: bool):
"""Create a prefab from a scene GameObject. """Create a prefab from a scene GameObject.
\b \b
Examples: Examples:
unity-mcp prefab create "Player" "Assets/Prefabs/Player.prefab" unity-mcp prefab create "Player" "Assets/Prefabs/Player.prefab"
unity-mcp prefab create "Enemy" "Assets/Prefabs/Enemy.prefab" --overwrite unity-mcp prefab create "Enemy" "Assets/Prefabs/Enemy.prefab" --overwrite
unity-mcp prefab create "EnemyInstance" "Assets/Prefabs/BossEnemy.prefab" --unlink-if-instance
""" """
config = get_config() config = get_config()
@ -133,6 +252,8 @@ def create(target: str, path: str, overwrite: bool, include_inactive: bool):
params["allowOverwrite"] = True params["allowOverwrite"] = True
if include_inactive: if include_inactive:
params["searchInactive"] = True params["searchInactive"] = True
if unlink_if_instance:
params["unlinkIfInstance"] = True
try: try:
result = run_command("manage_prefabs", params, config) result = run_command("manage_prefabs", params, config)

View File

@ -5,13 +5,29 @@ from mcp.types import ToolAnnotations
from services.registry import mcp_for_unity_tool from services.registry import mcp_for_unity_tool
from services.tools import get_unity_instance_from_context from services.tools import get_unity_instance_from_context
from services.tools.utils import coerce_bool
from transport.unity_transport import send_with_unity_instance from transport.unity_transport import send_with_unity_instance
from transport.legacy.unity_connection import async_send_command_with_retry from transport.legacy.unity_connection import async_send_command_with_retry
from services.tools.utils import coerce_bool from services.tools.preflight import preflight
# Required parameters for each action
REQUIRED_PARAMS = {
"get_info": ["prefab_path"],
"get_hierarchy": ["prefab_path"],
"open_stage": ["prefab_path"],
"create_from_gameobject": ["target", "prefab_path"],
"save_open_stage": [],
"close_stage": [],
}
@mcp_for_unity_tool( @mcp_for_unity_tool(
description="Performs prefab operations (open_stage, close_stage, save_open_stage, create_from_gameobject).", description=(
"Manages Unity Prefab assets and stages. "
"Actions: get_info, get_hierarchy, open_stage, close_stage, save_open_stage, create_from_gameobject. "
"Use manage_asset action=search filterType=Prefab to list prefabs."
),
annotations=ToolAnnotations( annotations=ToolAnnotations(
title="Manage Prefabs", title="Manage Prefabs",
destructiveHint=True, destructiveHint=True,
@ -19,50 +35,106 @@ from services.tools.utils import coerce_bool
) )
async def manage_prefabs( async def manage_prefabs(
ctx: Context, ctx: Context,
action: Annotated[Literal["open_stage", "close_stage", "save_open_stage", "create_from_gameobject"], "Perform prefab operations."], action: Annotated[
prefab_path: Annotated[str, Literal[
"Prefab asset path relative to Assets e.g. Assets/Prefabs/favorite.prefab"] | None = None, "open_stage",
mode: Annotated[str, "close_stage",
"Optional prefab stage mode (only 'InIsolation' is currently supported)"] | None = None, "save_open_stage",
save_before_close: Annotated[bool, "create_from_gameobject",
"When true, `close_stage` will save the prefab before exiting the stage."] | None = None, "get_info",
target: Annotated[str, "get_hierarchy",
"Scene GameObject name required for create_from_gameobject"] | None = None, ],
allow_overwrite: Annotated[bool, "Prefab operation to perform.",
"Allow replacing an existing prefab at the same path"] | None = None, ],
search_inactive: Annotated[bool, prefab_path: Annotated[str, "Prefab asset path (e.g., Assets/Prefabs/MyPrefab.prefab)."] | None = None,
"Include inactive objects when resolving the target name"] | None = None, save_before_close: Annotated[bool, "Save before closing if unsaved changes exist."] | None = None,
target: Annotated[str, "Scene GameObject name for create_from_gameobject."] | None = None,
allow_overwrite: Annotated[bool, "Allow replacing existing prefab."] | None = None,
search_inactive: Annotated[bool, "Include inactive GameObjects in search."] | None = None,
unlink_if_instance: Annotated[bool, "Unlink from existing prefab before creating new one."] | None = None,
force: Annotated[bool, "Force save even if no changes detected. Useful for automated workflows."] | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
# Get active instance from session state # Validate required parameters
# Removed session_state import required = REQUIRED_PARAMS.get(action, [])
for param_name in required:
param_value = locals().get(param_name)
# Check for None and empty/whitespace strings
if param_value is None or (isinstance(param_value, str) and not param_value.strip()):
return {
"success": False,
"message": f"Action '{action}' requires parameter '{param_name}'."
}
unity_instance = get_unity_instance_from_context(ctx) unity_instance = get_unity_instance_from_context(ctx)
# Preflight check for operations to ensure Unity is ready
try: try:
gate = await preflight(ctx, wait_for_no_compile=True, refresh_if_dirty=True)
if gate is not None:
return gate.model_dump()
except Exception as exc:
return {
"success": False,
"message": f"Unity preflight check failed: {exc}"
}
try:
# Build parameters dictionary
params: dict[str, Any] = {"action": action} params: dict[str, Any] = {"action": action}
# Handle prefab path parameter
if prefab_path: if prefab_path:
params["prefabPath"] = prefab_path params["prefabPath"] = prefab_path
if mode:
params["mode"] = mode # Handle boolean parameters with proper coercion
save_before_close_val = coerce_bool(save_before_close) save_before_close_val = coerce_bool(save_before_close)
if save_before_close_val is not None: if save_before_close_val is not None:
params["saveBeforeClose"] = save_before_close_val params["saveBeforeClose"] = save_before_close_val
if target: if target:
params["target"] = target params["target"] = target
allow_overwrite_val = coerce_bool(allow_overwrite) allow_overwrite_val = coerce_bool(allow_overwrite)
if allow_overwrite_val is not None: if allow_overwrite_val is not None:
params["allowOverwrite"] = allow_overwrite_val params["allowOverwrite"] = allow_overwrite_val
search_inactive_val = coerce_bool(search_inactive) search_inactive_val = coerce_bool(search_inactive)
if search_inactive_val is not None: if search_inactive_val is not None:
params["searchInactive"] = search_inactive_val params["searchInactive"] = search_inactive_val
response = await send_with_unity_instance(async_send_command_with_retry, unity_instance, "manage_prefabs", params)
if isinstance(response, dict) and response.get("success"): unlink_if_instance_val = coerce_bool(unlink_if_instance)
return { if unlink_if_instance_val is not None:
"success": True, params["unlinkIfInstance"] = unlink_if_instance_val
"message": response.get("message", "Prefab operation successful."),
"data": response.get("data"), force_val = coerce_bool(force)
} if force_val is not None:
return response if isinstance(response, dict) else {"success": False, "message": str(response)} params["force"] = force_val
# Send command to Unity
response = await send_with_unity_instance(
async_send_command_with_retry, unity_instance, "manage_prefabs", params
)
# Return Unity response directly; ensure success field exists
# Handle MCPResponse objects (returned on error) by converting to dict
if hasattr(response, 'model_dump'):
return response.model_dump()
if isinstance(response, dict):
if "success" not in response:
response["success"] = False
return response
return {
"success": False,
"message": f"Unexpected response type: {type(response).__name__}"
}
except TimeoutError:
return {
"success": False,
"message": "Unity connection timeout. Please check if Unity is running and responsive."
}
except Exception as exc: except Exception as exc:
return {"success": False, "message": f"Python error managing prefabs: {exc}"} return {
"success": False,
"message": f"Error managing prefabs: {exc}"
}

View File

@ -0,0 +1,807 @@
using System;
using System.IO;
using System.Linq;
using System.Text.RegularExpressions;
using Newtonsoft.Json.Linq;
using NUnit.Framework;
using UnityEditor;
using UnityEditor.SceneManagement;
using UnityEngine;
using UnityEngine.TestTools;
using MCPForUnity.Editor.Tools.Prefabs;
using static MCPForUnityTests.Editor.TestUtilities;
namespace MCPForUnityTests.Editor.Tools
{
/// <summary>
/// Comprehensive test suite for Prefab CRUD operations and new features.
/// Tests cover: Create, Read, Update, Delete patterns, force save, unlink-if-instance,
/// overwrite handling, inactive object search, and save dialog prevention.
/// </summary>
public class ManagePrefabsCrudTests
{
private const string TempDirectory = "Assets/Temp/ManagePrefabsCrudTests";
[SetUp]
public void SetUp()
{
StageUtility.GoToMainStage();
EnsureFolder(TempDirectory);
}
[TearDown]
public void TearDown()
{
StageUtility.GoToMainStage();
if (AssetDatabase.IsValidFolder(TempDirectory))
{
AssetDatabase.DeleteAsset(TempDirectory);
}
CleanupEmptyParentFolders(TempDirectory);
}
#region CREATE Tests
[Test]
public void CreateFromGameObject_CreatesNewPrefab_WithValidParameters()
{
string prefabPath = Path.Combine(TempDirectory, "NewPrefab.prefab").Replace('\\', '/');
GameObject sceneObject = new GameObject("TestObject");
try
{
var result = ToJObject(ManagePrefabs.HandleCommand(new JObject
{
["action"] = "create_from_gameobject",
["target"] = sceneObject.name,
["prefabPath"] = prefabPath
}));
Assert.IsTrue(result.Value<bool>("success"), "create_from_gameobject should succeed.");
var data = result["data"] as JObject;
Assert.AreEqual(prefabPath, data.Value<string>("prefabPath"));
GameObject prefabAsset = AssetDatabase.LoadAssetAtPath<GameObject>(prefabPath);
Assert.IsNotNull(prefabAsset, "Prefab asset should exist at path.");
}
finally
{
SafeDeleteAsset(prefabPath);
if (sceneObject != null) UnityEngine.Object.DestroyImmediate(sceneObject, true);
}
}
[Test]
public void CreateFromGameObject_UnlinksInstance_WhenUnlinkIfInstanceIsTrue()
{
// Create an initial prefab
string initialPrefabPath = Path.Combine(TempDirectory, "Original.prefab").Replace('\\', '/');
GameObject sourceObject = new GameObject("SourceObject");
GameObject instance = null;
try
{
// Create initial prefab and connect source object to it
PrefabUtility.SaveAsPrefabAssetAndConnect(
sourceObject, initialPrefabPath, InteractionMode.AutomatedAction);
// Verify source object is now linked
Assert.IsTrue(PrefabUtility.IsAnyPrefabInstanceRoot(sourceObject),
"Source object should be linked to prefab after SaveAsPrefabAssetAndConnect.");
// Create new prefab with unlinkIfInstance
// The command will find sourceObject by name and unlink it
string newPrefabPath = Path.Combine(TempDirectory, "NewFromLinked.prefab").Replace('\\', '/');
var result = ToJObject(ManagePrefabs.HandleCommand(new JObject
{
["action"] = "create_from_gameobject",
["target"] = sourceObject.name,
["prefabPath"] = newPrefabPath,
["unlinkIfInstance"] = true
}));
Assert.IsTrue(result.Value<bool>("success"), "create_from_gameobject with unlinkIfInstance should succeed.");
var data = result["data"] as JObject;
Assert.IsTrue(data.Value<bool>("wasUnlinked"), "wasUnlinked should be true.");
// Note: After creating the new prefab, the sourceObject is now linked to the NEW prefab
// (via SaveAsPrefabAssetAndConnect in CreatePrefabAsset), which is the correct behavior.
// What matters is that it was unlinked from the original prefab first.
Assert.IsTrue(PrefabUtility.IsAnyPrefabInstanceRoot(sourceObject),
"Source object should now be linked to the new prefab.");
string currentPrefabPath = PrefabUtility.GetPrefabAssetPathOfNearestInstanceRoot(sourceObject);
Assert.AreNotEqual(initialPrefabPath, currentPrefabPath,
"Source object should NOT be linked to original prefab anymore.");
Assert.AreEqual(newPrefabPath, currentPrefabPath,
"Source object should now be linked to the new prefab.");
}
finally
{
SafeDeleteAsset(initialPrefabPath);
SafeDeleteAsset(Path.Combine(TempDirectory, "NewFromLinked.prefab").Replace('\\', '/'));
if (sourceObject != null) UnityEngine.Object.DestroyImmediate(sourceObject, true);
if (instance != null) UnityEngine.Object.DestroyImmediate(instance, true);
}
}
[Test]
public void CreateFromGameObject_Fails_WhenTargetIsAlreadyLinked()
{
string prefabPath = Path.Combine(TempDirectory, "Existing.prefab").Replace('\\', '/');
GameObject sourceObject = new GameObject("SourceObject");
try
{
// Create initial prefab and connect the source object to it
GameObject connectedInstance = PrefabUtility.SaveAsPrefabAssetAndConnect(
sourceObject, prefabPath, InteractionMode.AutomatedAction);
// Verify the source object is now linked to the prefab
Assert.IsTrue(PrefabUtility.IsAnyPrefabInstanceRoot(sourceObject),
"Source object should be linked to prefab after SaveAsPrefabAssetAndConnect.");
// Try to create again without unlink - sourceObject.name should find the connected instance
string newPath = Path.Combine(TempDirectory, "Duplicate.prefab").Replace('\\', '/');
var result = ToJObject(ManagePrefabs.HandleCommand(new JObject
{
["action"] = "create_from_gameobject",
["target"] = sourceObject.name,
["prefabPath"] = newPath
}));
Assert.IsFalse(result.Value<bool>("success"),
"create_from_gameobject should fail when target is already linked.");
Assert.IsTrue(result.Value<string>("error").Contains("already linked"),
"Error message should mention 'already linked'.");
}
finally
{
SafeDeleteAsset(prefabPath);
SafeDeleteAsset(Path.Combine(TempDirectory, "Duplicate.prefab").Replace('\\', '/'));
if (sourceObject != null) UnityEngine.Object.DestroyImmediate(sourceObject, true);
}
}
[Test]
public void CreateFromGameObject_Overwrites_WhenAllowOverwriteIsTrue()
{
string prefabPath = Path.Combine(TempDirectory, "OverwriteTest.prefab").Replace('\\', '/');
GameObject firstObject = new GameObject("OverwriteTest"); // Use path filename
GameObject secondObject = new GameObject("OverwriteTest"); // Use path filename
try
{
// Create initial prefab
PrefabUtility.SaveAsPrefabAsset(firstObject, prefabPath, out bool _);
AssetDatabase.Refresh();
GameObject firstPrefab = AssetDatabase.LoadAssetAtPath<GameObject>(prefabPath);
Assert.AreEqual("OverwriteTest", firstPrefab.name, "First prefab should have name 'OverwriteTest'.");
// Overwrite with new object
var result = ToJObject(ManagePrefabs.HandleCommand(new JObject
{
["action"] = "create_from_gameobject",
["target"] = secondObject.name,
["prefabPath"] = prefabPath,
["allowOverwrite"] = true
}));
Assert.IsTrue(result.Value<bool>("success"), "create_from_gameobject with allowOverwrite should succeed.");
var data = result["data"] as JObject;
Assert.IsTrue(data.Value<bool>("wasReplaced"), "wasReplaced should be true.");
AssetDatabase.Refresh();
GameObject updatedPrefab = AssetDatabase.LoadAssetAtPath<GameObject>(prefabPath);
Assert.AreEqual("OverwriteTest", updatedPrefab.name, "Prefab should be overwritten (keeps filename as name).");
}
finally
{
SafeDeleteAsset(prefabPath);
if (firstObject != null) UnityEngine.Object.DestroyImmediate(firstObject, true);
if (secondObject != null) UnityEngine.Object.DestroyImmediate(secondObject, true);
}
}
[Test]
public void CreateFromGameObject_GeneratesUniquePath_WhenFileExistsAndNoOverwrite()
{
string prefabPath = Path.Combine(TempDirectory, "UniqueTest.prefab").Replace('\\', '/');
GameObject firstObject = new GameObject("FirstObject");
GameObject secondObject = new GameObject("SecondObject");
try
{
// Create initial prefab
PrefabUtility.SaveAsPrefabAsset(firstObject, prefabPath, out bool _);
AssetDatabase.Refresh();
// Create again without overwrite - should generate unique path
var result = ToJObject(ManagePrefabs.HandleCommand(new JObject
{
["action"] = "create_from_gameobject",
["target"] = secondObject.name,
["prefabPath"] = prefabPath
}));
Assert.IsTrue(result.Value<bool>("success"), "create_from_gameobject should succeed with unique path.");
var data = result["data"] as JObject;
string actualPath = data.Value<string>("prefabPath");
Assert.AreNotEqual(prefabPath, actualPath, "Path should be different (unique).");
Assert.IsTrue(actualPath.Contains("UniqueTest 1"), "Unique path should contain suffix.");
// Verify both prefabs exist
Assert.IsNotNull(AssetDatabase.LoadAssetAtPath<GameObject>(prefabPath),
"Original prefab should still exist.");
Assert.IsNotNull(AssetDatabase.LoadAssetAtPath<GameObject>(actualPath),
"New prefab should exist at unique path.");
}
finally
{
SafeDeleteAsset(prefabPath);
SafeDeleteAsset(Path.Combine(TempDirectory, "UniqueTest 1.prefab").Replace('\\', '/'));
if (firstObject != null) UnityEngine.Object.DestroyImmediate(firstObject, true);
if (secondObject != null) UnityEngine.Object.DestroyImmediate(secondObject, true);
}
}
[Test]
public void CreateFromGameObject_FindsInactiveObject_WhenSearchInactiveIsTrue()
{
string prefabPath = Path.Combine(TempDirectory, "InactiveTest.prefab").Replace('\\', '/');
GameObject inactiveObject = new GameObject("InactiveObject");
inactiveObject.SetActive(false);
try
{
// Try without searchInactive - should fail
var resultWithout = ToJObject(ManagePrefabs.HandleCommand(new JObject
{
["action"] = "create_from_gameobject",
["target"] = inactiveObject.name,
["prefabPath"] = prefabPath
}));
Assert.IsFalse(resultWithout.Value<bool>("success"),
"Should fail when object is inactive and searchInactive=false.");
// Try with searchInactive - should succeed
var resultWith = ToJObject(ManagePrefabs.HandleCommand(new JObject
{
["action"] = "create_from_gameobject",
["target"] = inactiveObject.name,
["prefabPath"] = prefabPath,
["searchInactive"] = true
}));
Assert.IsTrue(resultWith.Value<bool>("success"),
"Should succeed when searchInactive=true.");
}
finally
{
SafeDeleteAsset(prefabPath);
if (inactiveObject != null) UnityEngine.Object.DestroyImmediate(inactiveObject, true);
}
}
[Test]
public void CreateFromGameObject_CreatesDirectory_WhenPathDoesNotExist()
{
string prefabPath = Path.Combine(TempDirectory, "Nested/Deep/Directory/NewPrefab.prefab").Replace('\\', '/');
GameObject sceneObject = new GameObject("TestObject");
try
{
var result = ToJObject(ManagePrefabs.HandleCommand(new JObject
{
["action"] = "create_from_gameobject",
["target"] = sceneObject.name,
["prefabPath"] = prefabPath
}));
Assert.IsTrue(result.Value<bool>("success"), "Should create directories as needed.");
GameObject prefabAsset = AssetDatabase.LoadAssetAtPath<GameObject>(prefabPath);
Assert.IsNotNull(prefabAsset, "Prefab should exist at nested path.");
Assert.IsTrue(AssetDatabase.IsValidFolder(Path.Combine(TempDirectory, "Nested").Replace('\\', '/')),
"Nested directory should be created.");
}
finally
{
SafeDeleteAsset(prefabPath);
if (sceneObject != null) UnityEngine.Object.DestroyImmediate(sceneObject, true);
}
}
#endregion
#region READ Tests (GetInfo & GetHierarchy)
[Test]
public void GetInfo_ReturnsCorrectMetadata_ForValidPrefab()
{
string prefabPath = CreateTestPrefab("InfoTestPrefab");
try
{
var result = ToJObject(ManagePrefabs.HandleCommand(new JObject
{
["action"] = "get_info",
["prefabPath"] = prefabPath
}));
Assert.IsTrue(result.Value<bool>("success"), "get_info should succeed.");
var data = result["data"] as JObject;
Assert.AreEqual(prefabPath, data.Value<string>("assetPath"));
Assert.IsNotNull(data.Value<string>("guid"), "GUID should be present.");
Assert.AreEqual("Regular", data.Value<string>("prefabType"), "Should be Regular prefab type.");
Assert.AreEqual("InfoTestPrefab", data.Value<string>("rootObjectName"));
Assert.AreEqual(0, data.Value<int>("childCount"), "Should have no children.");
Assert.IsFalse(data.Value<bool>("isVariant"), "Should not be a variant.");
var components = data["rootComponentTypes"] as JArray;
Assert.IsNotNull(components, "Component types should be present.");
Assert.IsTrue(components.Count > 0, "Should have at least one component.");
}
finally
{
SafeDeleteAsset(prefabPath);
}
}
[Test]
public void GetInfo_ReturnsError_ForInvalidPath()
{
var result = ToJObject(ManagePrefabs.HandleCommand(new JObject
{
["action"] = "get_info",
["prefabPath"] = "Assets/Nonexistent/Prefab.prefab"
}));
Assert.IsFalse(result.Value<bool>("success"), "get_info should fail for invalid path.");
Assert.IsTrue(result.Value<string>("error").Contains("No prefab asset found") ||
result.Value<string>("error").Contains("not found"),
"Error should mention prefab not found.");
}
[Test]
public void GetHierarchy_ReturnsCompleteHierarchy_ForNestedPrefab()
{
string prefabPath = CreateNestedTestPrefab("HierarchyTest");
try
{
var result = ToJObject(ManagePrefabs.HandleCommand(new JObject
{
["action"] = "get_hierarchy",
["prefabPath"] = prefabPath
}));
Assert.IsTrue(result.Value<bool>("success"), "get_hierarchy should succeed.");
var data = result["data"] as JObject;
Assert.AreEqual(prefabPath, data.Value<string>("prefabPath"));
int total = data.Value<int>("total");
Assert.IsTrue(total >= 3, $"Should have at least 3 objects (root + 2 children), got {total}.");
var items = data["items"] as JArray;
Assert.IsNotNull(items, "Items should be present.");
Assert.AreEqual(total, items.Count, "Items count should match total.");
// Find root object
var root = items.Cast<JObject>().FirstOrDefault(j => j["prefab"]["isRoot"].Value<bool>());
Assert.IsNotNull(root, "Should have a root object with isRoot=true.");
Assert.AreEqual("HierarchyTest", root.Value<string>("name"));
}
finally
{
SafeDeleteAsset(prefabPath);
}
}
[Test]
public void GetHierarchy_IncludesNestingInfo_ForNestedPrefabs()
{
// Create a parent prefab first
string parentPath = CreateTestPrefab("ParentPrefab");
try
{
// Create a prefab that contains the parent prefab as nested
string childPath = CreateTestPrefab("ChildPrefab");
GameObject container = new GameObject("Container");
GameObject nestedInstance = PrefabUtility.InstantiatePrefab(
AssetDatabase.LoadAssetAtPath<GameObject>(childPath)) as GameObject;
nestedInstance.transform.parent = container.transform;
string nestedPrefabPath = Path.Combine(TempDirectory, "NestedContainer.prefab").Replace('\\', '/');
PrefabUtility.SaveAsPrefabAsset(container, nestedPrefabPath, out bool _);
UnityEngine.Object.DestroyImmediate(container);
AssetDatabase.Refresh();
// Expect the nested prefab warning due to test environment
LogAssert.Expect(UnityEngine.LogType.Error, new Regex("Nested Prefab problem"));
var result = ToJObject(ManagePrefabs.HandleCommand(new JObject
{
["action"] = "get_hierarchy",
["prefabPath"] = nestedPrefabPath
}));
Assert.IsTrue(result.Value<bool>("success"), "get_hierarchy should succeed.");
var data = result["data"] as JObject;
var items = data["items"] as JArray;
// Find the nested prefab
var nested = items.Cast<JObject>().FirstOrDefault(j => j["prefab"]["isNestedRoot"].Value<bool>());
Assert.IsNotNull(nested, "Should have a nested prefab root.");
Assert.AreEqual(1, nested["prefab"]["nestingDepth"].Value<int>(),
"Nested prefab should have depth 1.");
}
finally
{
SafeDeleteAsset(parentPath);
SafeDeleteAsset(Path.Combine(TempDirectory, "ParentPrefab.prefab").Replace('\\', '/'));
SafeDeleteAsset(Path.Combine(TempDirectory, "ChildPrefab.prefab").Replace('\\', '/'));
SafeDeleteAsset(Path.Combine(TempDirectory, "NestedContainer.prefab").Replace('\\', '/'));
}
}
#endregion
#region UPDATE Tests (Open, Save, Close)
[Test]
public void SaveOpenStage_WithForce_SavesEvenWhenNotDirty()
{
string prefabPath = CreateTestPrefab("ForceSaveTest");
Vector3 originalScale = Vector3.one;
try
{
ManagePrefabs.HandleCommand(new JObject
{
["action"] = "open_stage",
["prefabPath"] = prefabPath
});
PrefabStage stage = PrefabStageUtility.GetCurrentPrefabStage();
Assert.IsNotNull(stage, "Stage should be open.");
Assert.IsFalse(stage.scene.isDirty, "Stage should not be dirty initially.");
// Save without force - should succeed but indicate no changes
var noForceResult = ToJObject(ManagePrefabs.HandleCommand(new JObject
{
["action"] = "save_open_stage"
}));
Assert.IsTrue(noForceResult.Value<bool>("success"),
"Save should succeed even when not dirty.");
// Now save with force
var forceResult = ToJObject(ManagePrefabs.HandleCommand(new JObject
{
["action"] = "save_open_stage",
["force"] = true
}));
Assert.IsTrue(forceResult.Value<bool>("success"), "Force save should succeed.");
var data = forceResult["data"] as JObject;
Assert.IsTrue(data.Value<bool>("isDirty") || data.Value<bool>("isOpen"),
"Stage should still be open after force save.");
}
finally
{
StageUtility.GoToMainStage();
SafeDeleteAsset(prefabPath);
}
}
[Test]
public void SaveOpenStage_DoesNotShowSaveDialog()
{
string prefabPath = CreateTestPrefab("NoDialogTest");
try
{
ManagePrefabs.HandleCommand(new JObject
{
["action"] = "open_stage",
["prefabPath"] = prefabPath
});
PrefabStage stage = PrefabStageUtility.GetCurrentPrefabStage();
stage.prefabContentsRoot.transform.localScale = new Vector3(2f, 2f, 2f);
// Mark as dirty to ensure changes are tracked
EditorUtility.SetDirty(stage.prefabContentsRoot);
// This save should NOT show a dialog - it should complete synchronously
// If a dialog appeared, this would hang or require user interaction
var result = ToJObject(ManagePrefabs.HandleCommand(new JObject
{
["action"] = "save_open_stage",
["force"] = true // Use force to ensure save happens
}));
// If we got here without hanging, no dialog was shown
Assert.IsTrue(result.Value<bool>("success"),
"Save should complete without showing dialog.");
// Verify the change was saved
GameObject reloaded = AssetDatabase.LoadAssetAtPath<GameObject>(prefabPath);
Assert.AreEqual(new Vector3(2f, 2f, 2f), reloaded.transform.localScale,
"Changes should be saved without dialog.");
}
finally
{
StageUtility.GoToMainStage();
SafeDeleteAsset(prefabPath);
}
}
[Test]
public void CloseStage_WithSaveBeforeClose_SavesDirtyChanges()
{
string prefabPath = CreateTestPrefab("CloseSaveTest");
try
{
ManagePrefabs.HandleCommand(new JObject
{
["action"] = "open_stage",
["prefabPath"] = prefabPath
});
PrefabStage stage = PrefabStageUtility.GetCurrentPrefabStage();
stage.prefabContentsRoot.transform.position = new Vector3(5f, 5f, 5f);
// Mark as dirty to ensure changes are tracked
EditorUtility.SetDirty(stage.prefabContentsRoot);
// Close with save
var result = ToJObject(ManagePrefabs.HandleCommand(new JObject
{
["action"] = "close_stage",
["saveBeforeClose"] = true
}));
Assert.IsTrue(result.Value<bool>("success"), "Close with save should succeed.");
Assert.IsNull(PrefabStageUtility.GetCurrentPrefabStage(),
"Stage should be closed after close_stage.");
// Verify changes were saved
GameObject reloaded = AssetDatabase.LoadAssetAtPath<GameObject>(prefabPath);
Assert.AreEqual(new Vector3(5f, 5f, 5f), reloaded.transform.position,
"Position change should be saved before close.");
}
finally
{
StageUtility.GoToMainStage();
SafeDeleteAsset(prefabPath);
}
}
[Test]
public void OpenEditClose_CompleteWorkflow_Succeeds()
{
string prefabPath = CreateTestPrefab("WorkflowTest");
try
{
// OPEN
var openResult = ToJObject(ManagePrefabs.HandleCommand(new JObject
{
["action"] = "open_stage",
["prefabPath"] = prefabPath
}));
Assert.IsTrue(openResult.Value<bool>("success"), "Open should succeed.");
// EDIT
PrefabStage stage = PrefabStageUtility.GetCurrentPrefabStage();
stage.prefabContentsRoot.transform.localRotation = Quaternion.Euler(45f, 45f, 45f);
// Mark as dirty to ensure changes are tracked
EditorUtility.SetDirty(stage.prefabContentsRoot);
// SAVE
var saveResult = ToJObject(ManagePrefabs.HandleCommand(new JObject
{
["action"] = "save_open_stage",
["force"] = true // Use force to ensure save happens
}));
Assert.IsTrue(saveResult.Value<bool>("success"), "Save should succeed.");
// Note: stage.scene.isDirty may still be true in Unity's internal state
// The important thing is that changes were saved (verified below)
// CLOSE
var closeResult = ToJObject(ManagePrefabs.HandleCommand(new JObject
{
["action"] = "close_stage"
}));
Assert.IsTrue(closeResult.Value<bool>("success"), "Close should succeed.");
Assert.IsNull(PrefabStageUtility.GetCurrentPrefabStage(),
"No stage should be open after close.");
// VERIFY
GameObject reloaded = AssetDatabase.LoadAssetAtPath<GameObject>(prefabPath);
Assert.AreEqual(Quaternion.Euler(45f, 45f, 45f), reloaded.transform.localRotation,
"Rotation should be saved and persisted.");
}
finally
{
StageUtility.GoToMainStage();
SafeDeleteAsset(prefabPath);
}
}
#endregion
#region Edge Cases & Error Handling
[Test]
public void HandleCommand_ReturnsError_ForUnknownAction()
{
var result = ToJObject(ManagePrefabs.HandleCommand(new JObject
{
["action"] = "unknown_action"
}));
Assert.IsFalse(result.Value<bool>("success"), "Unknown action should fail.");
Assert.IsTrue(result.Value<string>("error").Contains("Unknown action"),
"Error should mention unknown action.");
}
[Test]
public void HandleCommand_ReturnsError_ForNullParameters()
{
var result = ToJObject(ManagePrefabs.HandleCommand(null));
Assert.IsFalse(result.Value<bool>("success"), "Null parameters should fail.");
Assert.IsTrue(result.Value<string>("error").Contains("null"),
"Error should mention null parameters.");
}
[Test]
public void HandleCommand_ReturnsError_WhenActionIsMissing()
{
var result = ToJObject(ManagePrefabs.HandleCommand(new JObject()));
Assert.IsFalse(result.Value<bool>("success"), "Missing action should fail.");
Assert.IsTrue(result.Value<string>("error").Contains("Action parameter is required"),
"Error should mention required action parameter.");
}
[Test]
public void CreateFromGameObject_ReturnsError_ForEmptyTarget()
{
var result = ToJObject(ManagePrefabs.HandleCommand(new JObject
{
["action"] = "create_from_gameobject",
["prefabPath"] = "Assets/Test.prefab"
}));
Assert.IsFalse(result.Value<bool>("success"), "Missing target should fail.");
Assert.IsTrue(result.Value<string>("error").Contains("'target' parameter is required"),
"Error should mention required target parameter.");
}
[Test]
public void CreateFromGameObject_ReturnsError_ForEmptyPrefabPath()
{
var result = ToJObject(ManagePrefabs.HandleCommand(new JObject
{
["action"] = "create_from_gameobject",
["target"] = "SomeObject"
}));
Assert.IsFalse(result.Value<bool>("success"), "Missing prefabPath should fail.");
Assert.IsTrue(result.Value<string>("error").Contains("'prefabPath' parameter is required"),
"Error should mention required prefabPath parameter.");
}
[Test]
public void CreateFromGameObject_ReturnsError_ForPathTraversal()
{
GameObject testObject = new GameObject("TestObject");
try
{
var result = ToJObject(ManagePrefabs.HandleCommand(new JObject
{
["action"] = "create_from_gameobject",
["target"] = "TestObject",
["prefabPath"] = "../../etc/passwd"
}));
Assert.IsFalse(result.Value<bool>("success"), "Path traversal should be blocked.");
Assert.IsTrue(result.Value<string>("error").Contains("path traversal") ||
result.Value<string>("error").Contains("Invalid"),
"Error should mention path traversal or invalid path.");
}
finally
{
if (testObject != null) UnityEngine.Object.DestroyImmediate(testObject, true);
}
}
[Test]
public void CreateFromGameObject_AutoPrependsAssets_WhenPathIsRelative()
{
GameObject testObject = new GameObject("TestObject");
try
{
// SanitizeAssetPath auto-prepends "Assets/" to relative paths
var result = ToJObject(ManagePrefabs.HandleCommand(new JObject
{
["action"] = "create_from_gameobject",
["target"] = "TestObject",
["prefabPath"] = "SomeFolder/Prefab.prefab"
}));
Assert.IsTrue(result.Value<bool>("success"), "Should auto-prepend Assets/ to relative path.");
// Clean up the created prefab at the corrected path
SafeDeleteAsset("Assets/SomeFolder/Prefab.prefab");
}
finally
{
if (testObject != null) UnityEngine.Object.DestroyImmediate(testObject, true);
}
}
#endregion
#region Test Helpers
private static string CreateTestPrefab(string name)
{
EnsureFolder(TempDirectory);
GameObject temp = GameObject.CreatePrimitive(PrimitiveType.Cube);
temp.name = name;
string path = Path.Combine(TempDirectory, name + ".prefab").Replace('\\', '/');
PrefabUtility.SaveAsPrefabAsset(temp, path, out bool success);
UnityEngine.Object.DestroyImmediate(temp);
AssetDatabase.Refresh();
if (!success)
{
throw new Exception($"Failed to create test prefab at {path}");
}
return path;
}
private static string CreateNestedTestPrefab(string name)
{
EnsureFolder(TempDirectory);
GameObject root = new GameObject(name);
// Add children
GameObject child1 = new GameObject("Child1");
child1.transform.parent = root.transform;
GameObject child2 = new GameObject("Child2");
child2.transform.parent = root.transform;
// Add grandchild
GameObject grandchild = new GameObject("Grandchild");
grandchild.transform.parent = child1.transform;
string path = Path.Combine(TempDirectory, name + ".prefab").Replace('\\', '/');
PrefabUtility.SaveAsPrefabAsset(root, path, out bool success);
UnityEngine.Object.DestroyImmediate(root);
AssetDatabase.Refresh();
if (!success)
{
throw new Exception($"Failed to create nested test prefab at {path}");
}
return path;
}
#endregion
}
}

View File

@ -1,5 +1,5 @@
fileFormatVersion: 2 fileFormatVersion: 2
guid: 8e7a7e542325421ba6de4992ddb3f5db guid: 7a8d9f0e1b2c3d4e5f6a7b8c9d0e1f2a
MonoImporter: MonoImporter:
externalObjects: {} externalObjects: {}
serializedVersion: 2 serializedVersion: 2

View File

@ -1,249 +0,0 @@
using System.IO;
using Newtonsoft.Json.Linq;
using NUnit.Framework;
using UnityEditor;
using UnityEditor.SceneManagement;
using UnityEngine;
using MCPForUnity.Editor.Tools.Prefabs;
using static MCPForUnityTests.Editor.TestUtilities;
namespace MCPForUnityTests.Editor.Tools
{
public class ManagePrefabsTests
{
private const string TempDirectory = "Assets/Temp/ManagePrefabsTests";
[SetUp]
public void SetUp()
{
StageUtility.GoToMainStage();
EnsureTempDirectoryExists();
}
[TearDown]
public void TearDown()
{
StageUtility.GoToMainStage();
// Clean up temp directory after each test
if (AssetDatabase.IsValidFolder(TempDirectory))
{
AssetDatabase.DeleteAsset(TempDirectory);
}
// Clean up empty parent folders to avoid debris
CleanupEmptyParentFolders(TempDirectory);
}
[Test]
public void OpenStage_OpensPrefabInIsolation()
{
string prefabPath = CreateTestPrefab("OpenStageCube");
try
{
var openParams = new JObject
{
["action"] = "open_stage",
["prefabPath"] = prefabPath
};
var openResult = ToJObject(ManagePrefabs.HandleCommand(openParams));
Assert.IsTrue(openResult.Value<bool>("success"), "open_stage should succeed for a valid prefab.");
UnityEditor.SceneManagement.PrefabStage stage = PrefabStageUtility.GetCurrentPrefabStage();
Assert.IsNotNull(stage, "Prefab stage should be open after open_stage.");
Assert.AreEqual(prefabPath, stage.assetPath, "Opened stage should match prefab path.");
var stageInfo = ToJObject(MCPForUnity.Editor.Resources.Editor.PrefabStage.HandleCommand(new JObject()));
Assert.IsTrue(stageInfo.Value<bool>("success"), "get_prefab_stage should succeed when stage is open.");
var data = stageInfo["data"] as JObject;
Assert.IsNotNull(data, "Stage info should include data payload.");
Assert.IsTrue(data.Value<bool>("isOpen"));
Assert.AreEqual(prefabPath, data.Value<string>("assetPath"));
}
finally
{
StageUtility.GoToMainStage();
AssetDatabase.DeleteAsset(prefabPath);
}
}
[Test]
public void CloseStage_ReturnsSuccess_WhenNoStageOpen()
{
StageUtility.GoToMainStage();
var closeResult = ToJObject(ManagePrefabs.HandleCommand(new JObject
{
["action"] = "close_stage"
}));
Assert.IsTrue(closeResult.Value<bool>("success"), "close_stage should succeed even if no stage is open.");
}
[Test]
public void CloseStage_ClosesOpenPrefabStage()
{
string prefabPath = CreateTestPrefab("CloseStageCube");
try
{
ManagePrefabs.HandleCommand(new JObject
{
["action"] = "open_stage",
["prefabPath"] = prefabPath
});
var closeResult = ToJObject(ManagePrefabs.HandleCommand(new JObject
{
["action"] = "close_stage"
}));
Assert.IsTrue(closeResult.Value<bool>("success"), "close_stage should succeed when stage is open.");
Assert.IsNull(PrefabStageUtility.GetCurrentPrefabStage(), "Prefab stage should be closed after close_stage.");
}
finally
{
StageUtility.GoToMainStage();
AssetDatabase.DeleteAsset(prefabPath);
}
}
[Test]
public void SaveOpenStage_SavesDirtyChanges()
{
string prefabPath = CreateTestPrefab("SaveStageCube");
try
{
ManagePrefabs.HandleCommand(new JObject
{
["action"] = "open_stage",
["prefabPath"] = prefabPath
});
UnityEditor.SceneManagement.PrefabStage stage = PrefabStageUtility.GetCurrentPrefabStage();
Assert.IsNotNull(stage, "Stage should be open before modifying.");
stage.prefabContentsRoot.transform.localScale = new Vector3(2f, 2f, 2f);
var saveResult = ToJObject(ManagePrefabs.HandleCommand(new JObject
{
["action"] = "save_open_stage"
}));
Assert.IsTrue(saveResult.Value<bool>("success"), "save_open_stage should succeed when stage is open.");
Assert.IsFalse(stage.scene.isDirty, "Stage scene should not be dirty after saving.");
GameObject reloaded = AssetDatabase.LoadAssetAtPath<GameObject>(prefabPath);
Assert.AreEqual(new Vector3(2f, 2f, 2f), reloaded.transform.localScale, "Saved prefab asset should include changes from open stage.");
}
finally
{
StageUtility.GoToMainStage();
AssetDatabase.DeleteAsset(prefabPath);
}
}
[Test]
public void SaveOpenStage_ReturnsError_WhenNoStageOpen()
{
StageUtility.GoToMainStage();
var saveResult = ToJObject(ManagePrefabs.HandleCommand(new JObject
{
["action"] = "save_open_stage"
}));
Assert.IsFalse(saveResult.Value<bool>("success"), "save_open_stage should fail when no stage is open.");
}
[Test]
public void CreateFromGameObject_CreatesPrefabAndLinksInstance()
{
EnsureTempDirectoryExists();
StageUtility.GoToMainStage();
string prefabPath = Path.Combine(TempDirectory, "SceneObjectSaved.prefab").Replace('\\', '/');
GameObject sceneObject = new GameObject("ScenePrefabSource");
try
{
var result = ToJObject(ManagePrefabs.HandleCommand(new JObject
{
["action"] = "create_from_gameobject",
["target"] = sceneObject.name,
["prefabPath"] = prefabPath
}));
Assert.IsTrue(result.Value<bool>("success"), "create_from_gameobject should succeed for a valid scene object.");
var data = result["data"] as JObject;
Assert.IsNotNull(data, "Response data should include prefab information.");
string savedPath = data.Value<string>("prefabPath");
Assert.AreEqual(prefabPath, savedPath, "Returned prefab path should match the requested path.");
GameObject prefabAsset = AssetDatabase.LoadAssetAtPath<GameObject>(savedPath);
Assert.IsNotNull(prefabAsset, "Prefab asset should exist at the saved path.");
int instanceId = data.Value<int>("instanceId");
var linkedInstance = EditorUtility.InstanceIDToObject(instanceId) as GameObject;
Assert.IsNotNull(linkedInstance, "Linked instance should resolve from instanceId.");
Assert.AreEqual(savedPath, PrefabUtility.GetPrefabAssetPathOfNearestInstanceRoot(linkedInstance), "Instance should be connected to the new prefab.");
sceneObject = linkedInstance;
}
finally
{
if (AssetDatabase.LoadAssetAtPath<UnityEngine.Object>(prefabPath) != null)
{
AssetDatabase.DeleteAsset(prefabPath);
}
if (sceneObject != null)
{
if (PrefabUtility.IsPartOfPrefabInstance(sceneObject))
{
PrefabUtility.UnpackPrefabInstance(
sceneObject,
PrefabUnpackMode.Completely,
InteractionMode.AutomatedAction
);
}
UnityEngine.Object.DestroyImmediate(sceneObject, true);
}
}
}
private static string CreateTestPrefab(string name)
{
EnsureTempDirectoryExists();
GameObject temp = GameObject.CreatePrimitive(PrimitiveType.Cube);
temp.name = name;
string path = Path.Combine(TempDirectory, name + ".prefab").Replace('\\', '/');
PrefabUtility.SaveAsPrefabAsset(temp, path, out bool success);
UnityEngine.Object.DestroyImmediate(temp);
Assert.IsTrue(success, "PrefabUtility.SaveAsPrefabAsset should succeed for test prefab.");
return path;
}
private static void EnsureTempDirectoryExists()
{
if (!AssetDatabase.IsValidFolder("Assets/Temp"))
{
AssetDatabase.CreateFolder("Assets", "Temp");
}
if (!AssetDatabase.IsValidFolder(TempDirectory))
{
AssetDatabase.CreateFolder("Assets/Temp", "ManagePrefabsTests");
}
}
}
}