diff --git a/MCPForUnity/Editor/Helpers/AssetPathUtility.cs b/MCPForUnity/Editor/Helpers/AssetPathUtility.cs
index b65cf21ea..c4169da5b 100644
--- a/MCPForUnity/Editor/Helpers/AssetPathUtility.cs
+++ b/MCPForUnity/Editor/Helpers/AssetPathUtility.cs
@@ -27,6 +27,7 @@ public static string NormalizeSeparators(string path)
///
/// 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.
///
public static string SanitizeAssetPath(string path)
{
@@ -36,6 +37,15 @@ public static string SanitizeAssetPath(string 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))
{
return "Assets/" + path.TrimStart('/');
@@ -44,6 +54,45 @@ public static string SanitizeAssetPath(string path)
return path;
}
+ ///
+ /// Checks if a given asset path is valid and safe (no traversal, within Assets folder).
+ ///
+ /// True if the path is valid, false otherwise.
+ 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;
+ }
+
///
/// Gets the MCP for Unity package root path.
/// Works for registry Package Manager, local Package Manager, and Asset Store installations.
diff --git a/MCPForUnity/Editor/Helpers/PrefabUtilityHelper.cs b/MCPForUnity/Editor/Helpers/PrefabUtilityHelper.cs
new file mode 100644
index 000000000..88397fb6e
--- /dev/null
+++ b/MCPForUnity/Editor/Helpers/PrefabUtilityHelper.cs
@@ -0,0 +1,228 @@
+using System;
+using System.Collections.Generic;
+using UnityEditor;
+using UnityEngine;
+
+namespace MCPForUnity.Editor.Helpers
+{
+ ///
+ /// Provides common utility methods for working with Unity Prefab assets.
+ ///
+ public static class PrefabUtilityHelper
+ {
+ ///
+ /// Gets the GUID for a prefab asset path.
+ ///
+ /// The Unity asset path (e.g., "Assets/Prefabs/MyPrefab.prefab")
+ /// The GUID string, or null if the path is invalid.
+ 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;
+ }
+ }
+
+ ///
+ /// Gets variant information if the prefab is a variant.
+ ///
+ /// The prefab GameObject to check.
+ /// A tuple containing (isVariant, parentPath, parentGuid).
+ 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);
+ }
+ }
+
+ ///
+ /// Gets the list of component type names on a GameObject.
+ ///
+ /// The GameObject to inspect.
+ /// A list of component type full names.
+ public static List GetComponentTypeNames(GameObject obj)
+ {
+ var typeNames = new List();
+
+ if (obj == null)
+ {
+ return typeNames;
+ }
+
+ try
+ {
+ var components = obj.GetComponents();
+ 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;
+ }
+
+ ///
+ /// Recursively counts all children in the hierarchy.
+ ///
+ /// The root transform to count from.
+ /// Total number of children in the hierarchy.
+ 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;
+ }
+
+ ///
+ /// Gets the source prefab path for a nested prefab instance.
+ ///
+ /// The GameObject to check.
+ /// The asset path of the source prefab, or null if not a nested prefab.
+ 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;
+ }
+
+ ///
+ /// 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.
+ ///
+ /// The GameObject to analyze.
+ /// The root transform of the main prefab asset.
+ /// Nesting depth (0=main root, 1+=nested), or -1 if not a prefab root.
+ 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;
+ }
+
+ ///
+ /// Gets the parent prefab path for a nested prefab instance.
+ /// Returns null for main prefab root or non-prefab objects.
+ ///
+ /// The GameObject to analyze.
+ /// The root transform of the main prefab asset.
+ /// The asset path of the parent prefab, or null if none.
+ 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;
+ }
+ }
+}
diff --git a/MCPForUnity/Editor/Tools/GameObjects/GameObjectModify.cs b/MCPForUnity/Editor/Tools/GameObjects/GameObjectModify.cs
index bfa83faac..b817b90b0 100644
--- a/MCPForUnity/Editor/Tools/GameObjects/GameObjectModify.cs
+++ b/MCPForUnity/Editor/Tools/GameObjects/GameObjectModify.cs
@@ -5,6 +5,7 @@
using MCPForUnity.Editor.Helpers;
using Newtonsoft.Json.Linq;
using UnityEditor;
+using UnityEditor.SceneManagement;
using UnityEditorInternal;
using UnityEngine;
@@ -239,6 +240,18 @@ internal static object Handle(JObject @params, JToken targetToken, string search
}
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(
$"GameObject '{targetGo.name}' modified successfully.",
Helpers.GameObjectSerializer.GetGameObjectData(targetGo)
diff --git a/MCPForUnity/Editor/Tools/Prefabs/ManagePrefabs.cs b/MCPForUnity/Editor/Tools/Prefabs/ManagePrefabs.cs
index 39ed057e0..4223a2561 100644
--- a/MCPForUnity/Editor/Tools/Prefabs/ManagePrefabs.cs
+++ b/MCPForUnity/Editor/Tools/Prefabs/ManagePrefabs.cs
@@ -1,4 +1,5 @@
using System;
+using System.Collections.Generic;
using System.IO;
using MCPForUnity.Editor.Helpers;
using Newtonsoft.Json.Linq;
@@ -15,7 +16,14 @@ namespace MCPForUnity.Editor.Tools.Prefabs
///
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)
{
@@ -34,14 +42,18 @@ public static object HandleCommand(JObject @params)
{
switch (action)
{
- case "open_stage":
+ case ACTION_OPEN_STAGE:
return OpenStage(@params);
- case "close_stage":
+ case ACTION_CLOSE_STAGE:
return CloseStage(@params);
- case "save_open_stage":
- return SaveOpenStage();
- case "create_from_gameobject":
+ case ACTION_SAVE_OPEN_STAGE:
+ return SaveOpenStage(@params);
+ case ACTION_CREATE_FROM_GAMEOBJECT:
return CreatePrefabFromGameObject(@params);
+ case ACTION_GET_INFO:
+ return GetInfo(@params);
+ case ACTION_GET_HIERARCHY:
+ return GetHierarchy(@params);
default:
return new ErrorResponse($"Unknown action: '{action}'. Valid actions are: {SupportedActions}.");
}
@@ -53,6 +65,9 @@ public static object HandleCommand(JObject @params)
}
}
+ ///
+ /// Opens a prefab in prefab mode for editing.
+ ///
private static object OpenStage(JObject @params)
{
string prefabPath = @params["prefabPath"]?.ToString();
@@ -62,18 +77,16 @@ private static object OpenStage(JObject @params)
}
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 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);
if (stage == null)
{
@@ -83,6 +96,9 @@ private static object OpenStage(JObject @params)
return new SuccessResponse($"Opened prefab stage for '{sanitizedPath}'.", SerializeStage(stage));
}
+ ///
+ /// Closes the currently open prefab stage, optionally saving first.
+ ///
private static object CloseStage(JObject @params)
{
PrefabStage stage = PrefabStageUtility.GetCurrentPrefabStage();
@@ -91,18 +107,30 @@ private static object CloseStage(JObject @params)
return new SuccessResponse("No prefab stage was open.");
}
+ string assetPath = stage.assetPath;
bool saveBeforeClose = @params["saveBeforeClose"]?.ToObject() ?? false;
+
if (saveBeforeClose && stage.scene.isDirty)
{
- SaveStagePrefab(stage);
- AssetDatabase.SaveAssets();
+ try
+ {
+ SaveAndRefreshStage(stage);
+ }
+ catch (Exception e)
+ {
+ return new ErrorResponse($"Failed to save prefab before closing: {e.Message}");
+ }
}
StageUtility.GoToMainStage();
- return new SuccessResponse($"Closed prefab stage for '{stage.assetPath}'.");
+ return new SuccessResponse($"Closed prefab stage for '{assetPath}'.");
}
- private static object SaveOpenStage()
+ ///
+ /// Saves changes to the currently open prefab stage.
+ /// Supports a 'force' parameter for automated workflows where isDirty may not be set.
+ ///
+ private static object SaveOpenStage(JObject @params)
{
PrefabStage stage = PrefabStageUtility.GetCurrentPrefabStage();
if (stage == null)
@@ -110,107 +138,327 @@ private static object SaveOpenStage()
return new ErrorResponse("No prefab stage is currently open.");
}
- SaveStagePrefab(stage);
- AssetDatabase.SaveAssets();
- return new SuccessResponse($"Saved prefab stage for '{stage.assetPath}'.", SerializeStage(stage));
+ if (!ValidatePrefabStageForSave(stage))
+ {
+ return new ErrorResponse("Prefab stage validation failed. Cannot save.");
+ }
+
+ // Check for force parameter (useful for automated workflows)
+ bool force = @params?["force"]?.ToObject() ?? 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
+
+ ///
+ /// Saves the prefab stage and refreshes the asset database.
+ /// Uses PrefabUtility.SaveAsPrefabAsset for reliable prefab saving without dialogs.
+ ///
+ /// The prefab stage to save.
+ /// If true, marks the prefab dirty before saving to ensure changes are captured.
+ 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.");
}
- bool saved = PrefabUtility.SaveAsPrefabAsset(stage.prefabContentsRoot, stage.assetPath);
- if (!saved)
+ if (string.IsNullOrEmpty(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)
{
- throw new InvalidOperationException($"Failed to save prefab asset at '{stage.assetPath}'.");
+ 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(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)
+ ///
+ /// Validates prefab stage before saving.
+ ///
+ private static bool ValidatePrefabStageForSave(PrefabStage stage)
{
- string targetName = @params["target"]?.ToString() ?? @params["name"]?.ToString();
- if (string.IsNullOrEmpty(targetName))
+ if (stage == null)
{
- 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() ?? false;
- GameObject sourceObject = FindSceneObjectByName(targetName, includeInactive);
- if (sourceObject == null)
+ if (stage.prefabContentsRoot == null)
{
- return new ErrorResponse($"GameObject '{targetName}' not found in the active scene.");
+ McpLog.Error($"[ManagePrefabs] Prefab stage '{stage.assetPath}' has no root object.");
+ return false;
}
- if (PrefabUtility.IsPartOfPrefabAsset(sourceObject))
+ if (string.IsNullOrEmpty(stage.assetPath))
{
- return new ErrorResponse(
- $"GameObject '{sourceObject.name}' is part of a prefab asset. Open the prefab stage to save changes instead."
- );
+ McpLog.Error("[ManagePrefabs] Prefab stage has invalid asset path.");
+ return false;
}
- PrefabInstanceStatus status = PrefabUtility.GetPrefabInstanceStatus(sourceObject);
- if (status != PrefabInstanceStatus.NotAPrefab)
+ return true;
+ }
+
+ #endregion
+
+ #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(
- $"GameObject '{sourceObject.name}' is already linked to an existing prefab instance."
- );
+ return new ErrorResponse(validation.errorMessage);
}
- string requestedPath = @params["prefabPath"]?.ToString();
- if (string.IsNullOrWhiteSpace(requestedPath))
+ 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("'prefabPath' parameter is required for create_from_gameobject.");
+ return new ErrorResponse($"GameObject '{targetName}' not found in the active scene or prefab stage{(includeInactive ? " (including inactive objects)" : "")}.");
}
- string sanitizedPath = AssetPathUtility.SanitizeAssetPath(requestedPath);
- if (!sanitizedPath.EndsWith(".prefab", StringComparison.OrdinalIgnoreCase))
+ // 3. Validate source object state
+ var objectValidation = ValidateSourceObjectForPrefab(sourceObject, unlinkIfInstance);
+ if (!objectValidation.isValid)
{
- sanitizedPath += ".prefab";
+ return new ErrorResponse(objectValidation.errorMessage);
}
- bool allowOverwrite = @params["allowOverwrite"]?.ToObject() ?? false;
- string finalPath = sanitizedPath;
+ // 4. Check for path conflicts and track if file will be replaced
+ bool fileExistedAtPath = AssetDatabase.LoadAssetAtPath(finalPath) != null;
- if (!allowOverwrite && 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. Create the prefab
try
{
- GameObject connectedInstance = PrefabUtility.SaveAsPrefabAssetAndConnect(
- sourceObject,
- finalPath,
- InteractionMode.AutomatedAction
- );
+ GameObject result = CreatePrefabAsset(sourceObject, finalPath, replaceExisting);
- 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(
$"Prefab created at '{finalPath}' and instance linked.",
new
{
prefabPath = finalPath,
- instanceId = connectedInstance.GetInstanceID()
+ instanceId = result.GetInstanceID(),
+ instanceName = result.name,
+ wasUnlinked = unlinkIfInstance && objectValidation.shouldUnlink,
+ wasReplaced = replaceExisting && fileExistedAtPath,
+ componentCount = result.GetComponents().Length,
+ childCount = result.transform.childCount
}
);
}
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}");
}
}
+ ///
+ /// 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;
+ }
+
+ #endregion
+
+ ///
+ /// Ensures the directory for an asset path exists, creating it if necessary.
+ ///
private static void EnsureAssetDirectoryExists(string assetPath)
{
string directory = Path.GetDirectoryName(assetPath);
@@ -219,37 +467,54 @@ private static void EnsureAssetDirectoryExists(string assetPath)
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))
{
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)
+ 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))
{
- GameObject candidate = transform.gameObject;
- if (candidate.name == name)
+ if (transform.name == name && (includeInactive || transform.gameObject.activeSelf))
{
- return candidate;
+ return transform.gameObject;
}
}
}
@@ -257,6 +522,177 @@ private static GameObject FindSceneObjectByName(string name, bool includeInactiv
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 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