Open and close prefabs in the stage view + create them (#283)

* refactor: remove unused UnityEngine references from menu item classes

* Add new tools to manage a prefab, particularly, making them staged.

This might be enough, but it's possible we may have to extract some logic from ManageGameObject

* feat: add AssetPathUtility for asset path normalization and update references in ManageAsset and ManagePrefabs

* feat: add prefab management tools and register them with the MCP server

* feat: update prefab management commands to use 'prefabPath' and add 'create_from_gameobject' action

* fix: update parameter references to 'prefabPath' in ManagePrefabs and manage_prefabs tools

* fix: clarify error message for missing 'prefabPath' in create_from_gameobject command

* fix: ensure pull request triggers for unity tests workflow

* Revert "fix: ensure pull request triggers for unity tests workflow"

This reverts commit 10bfe54b5b7f3c449852b1bf1bb72f498289a1a0.

* Remove delayed execution of executing menu item, fixing #279
This brings the Unity window into focus but that seems to be a better UX for devs.
Also streamline manage_menu_item tool info, as FastMCP recommends

* docs: clarify menu item tool description with guidance to use list action first

* feat: add version update for server_version.txt in bump-version workflow

* fix: simplify error message for failed menu item execution
main
Marcus Sanatan 2025-09-26 19:28:56 -04:00 committed by GitHub
parent 549ac1eb0c
commit ac4eae926e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 750 additions and 77 deletions

View File

@ -70,6 +70,9 @@ jobs:
echo "Updating UnityMcpBridge/UnityMcpServer~/src/pyproject.toml to $NEW_VERSION"
sed -i '0,/^version = ".*"/s//version = "'"$NEW_VERSION"'"/' "UnityMcpBridge/UnityMcpServer~/src/pyproject.toml"
echo "Updating UnityMcpBridge/UnityMcpServer~/src/server_version.txt to $NEW_VERSION"
echo "$NEW_VERSION" > "UnityMcpBridge/UnityMcpServer~/src/server_version.txt"
- name: Commit and push changes
env:
NEW_VERSION: ${{ steps.compute.outputs.new_version }}
@ -78,7 +81,7 @@ jobs:
set -euo pipefail
git config user.name "GitHub Actions"
git config user.email "actions@github.com"
git add UnityMcpBridge/package.json "UnityMcpBridge/UnityMcpServer~/src/pyproject.toml"
git add UnityMcpBridge/package.json "UnityMcpBridge/UnityMcpServer~/src/pyproject.toml" "UnityMcpBridge/UnityMcpServer~/src/server_version.txt"
if git diff --cached --quiet; then
echo "No version changes to commit."
else

View File

@ -0,0 +1,255 @@
using System.IO;
using Newtonsoft.Json.Linq;
using NUnit.Framework;
using UnityEditor;
using UnityEditor.SceneManagement;
using UnityEngine;
using MCPForUnity.Editor.Tools.Prefabs;
using MCPForUnity.Editor.Tools;
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();
}
[OneTimeTearDown]
public void CleanupAll()
{
StageUtility.GoToMainStage();
if (AssetDatabase.IsValidFolder(TempDirectory))
{
AssetDatabase.DeleteAsset(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.");
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(ManageEditor.HandleCommand(new JObject { ["action"] = "get_prefab_stage" }));
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
});
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");
}
}
private static JObject ToJObject(object result)
{
return result as JObject ?? JObject.FromObject(result);
}
}
}

View File

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 8e7a7e542325421ba6de4992ddb3f5db
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,29 @@
using System;
namespace MCPForUnity.Editor.Helpers
{
/// <summary>
/// Provides common utility methods for working with Unity asset paths.
/// </summary>
public static class AssetPathUtility
{
/// <summary>
/// Normalizes a Unity asset path by ensuring forward slashes are used and that it is rooted under "Assets/".
/// </summary>
public static string SanitizeAssetPath(string path)
{
if (string.IsNullOrEmpty(path))
{
return path;
}
path = path.Replace('\\', '/');
if (!path.StartsWith("Assets/", StringComparison.OrdinalIgnoreCase))
{
return "Assets/" + path.TrimStart('/');
}
return path;
}
}
}

View File

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 1d42f5b5ea5d4d43ad1a771e14bda2a0
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -15,6 +15,7 @@ using MCPForUnity.Editor.Helpers;
using MCPForUnity.Editor.Models;
using MCPForUnity.Editor.Tools;
using MCPForUnity.Editor.Tools.MenuItems;
using MCPForUnity.Editor.Tools.Prefabs;
namespace MCPForUnity.Editor
{
@ -1040,7 +1041,26 @@ namespace MCPForUnity.Editor
// Use JObject for parameters as the new handlers likely expect this
JObject paramsObject = command.@params ?? new JObject();
object result = CommandRegistry.GetHandler(command.type)(paramsObject);
// Route command based on the new tool structure from the refactor plan
object result = command.type switch
{
// Maps the command type (tool name) to the corresponding handler's static HandleCommand method
// Assumes each handler class has a static method named 'HandleCommand' that takes JObject parameters
"manage_script" => ManageScript.HandleCommand(paramsObject),
// Run scene operations on the main thread to avoid deadlocks/hangs (with diagnostics under debug flag)
"manage_scene" => HandleManageScene(paramsObject)
?? throw new TimeoutException($"manage_scene timed out after {FrameIOTimeoutMs} ms on main thread"),
"manage_editor" => ManageEditor.HandleCommand(paramsObject),
"manage_gameobject" => ManageGameObject.HandleCommand(paramsObject),
"manage_asset" => ManageAsset.HandleCommand(paramsObject),
"manage_shader" => ManageShader.HandleCommand(paramsObject),
"read_console" => ReadConsole.HandleCommand(paramsObject),
"manage_menu_item" => ManageMenuItem.HandleCommand(paramsObject),
"manage_prefabs" => ManagePrefabs.HandleCommand(paramsObject),
_ => throw new ArgumentException(
$"Unknown or unsupported command type: {command.type}"
),
};
// Standard success response format
var response = new { status = "success", result };

View File

@ -115,7 +115,7 @@ namespace MCPForUnity.Editor.Tools
{
if (string.IsNullOrEmpty(path))
return Response.Error("'path' is required for reimport.");
string fullPath = SanitizeAssetPath(path);
string fullPath = AssetPathUtility.SanitizeAssetPath(path);
if (!AssetExists(fullPath))
return Response.Error($"Asset not found at path: {fullPath}");
@ -154,7 +154,7 @@ namespace MCPForUnity.Editor.Tools
if (string.IsNullOrEmpty(assetType))
return Response.Error("'assetType' is required for create.");
string fullPath = SanitizeAssetPath(path);
string fullPath = AssetPathUtility.SanitizeAssetPath(path);
string directory = Path.GetDirectoryName(fullPath);
// Ensure directory exists
@ -280,7 +280,7 @@ namespace MCPForUnity.Editor.Tools
{
if (string.IsNullOrEmpty(path))
return Response.Error("'path' is required for create_folder.");
string fullPath = SanitizeAssetPath(path);
string fullPath = AssetPathUtility.SanitizeAssetPath(path);
string parentDir = Path.GetDirectoryName(fullPath);
string folderName = Path.GetFileName(fullPath);
@ -338,7 +338,7 @@ namespace MCPForUnity.Editor.Tools
if (properties == null || !properties.HasValues)
return Response.Error("'properties' are required for modify.");
string fullPath = SanitizeAssetPath(path);
string fullPath = AssetPathUtility.SanitizeAssetPath(path);
if (!AssetExists(fullPath))
return Response.Error($"Asset not found at path: {fullPath}");
@ -495,7 +495,7 @@ namespace MCPForUnity.Editor.Tools
{
if (string.IsNullOrEmpty(path))
return Response.Error("'path' is required for delete.");
string fullPath = SanitizeAssetPath(path);
string fullPath = AssetPathUtility.SanitizeAssetPath(path);
if (!AssetExists(fullPath))
return Response.Error($"Asset not found at path: {fullPath}");
@ -526,7 +526,7 @@ namespace MCPForUnity.Editor.Tools
if (string.IsNullOrEmpty(path))
return Response.Error("'path' is required for duplicate.");
string sourcePath = SanitizeAssetPath(path);
string sourcePath = AssetPathUtility.SanitizeAssetPath(path);
if (!AssetExists(sourcePath))
return Response.Error($"Source asset not found at path: {sourcePath}");
@ -538,7 +538,7 @@ namespace MCPForUnity.Editor.Tools
}
else
{
destPath = SanitizeAssetPath(destinationPath);
destPath = AssetPathUtility.SanitizeAssetPath(destinationPath);
if (AssetExists(destPath))
return Response.Error($"Asset already exists at destination path: {destPath}");
// Ensure destination directory exists
@ -576,8 +576,8 @@ namespace MCPForUnity.Editor.Tools
if (string.IsNullOrEmpty(destinationPath))
return Response.Error("'destination' path is required for move/rename.");
string sourcePath = SanitizeAssetPath(path);
string destPath = SanitizeAssetPath(destinationPath);
string sourcePath = AssetPathUtility.SanitizeAssetPath(path);
string destPath = AssetPathUtility.SanitizeAssetPath(destinationPath);
if (!AssetExists(sourcePath))
return Response.Error($"Source asset not found at path: {sourcePath}");
@ -642,7 +642,7 @@ namespace MCPForUnity.Editor.Tools
string[] folderScope = null;
if (!string.IsNullOrEmpty(pathScope))
{
folderScope = new string[] { SanitizeAssetPath(pathScope) };
folderScope = new string[] { AssetPathUtility.SanitizeAssetPath(pathScope) };
if (!AssetDatabase.IsValidFolder(folderScope[0]))
{
// Maybe the user provided a file path instead of a folder?
@ -732,7 +732,7 @@ namespace MCPForUnity.Editor.Tools
{
if (string.IsNullOrEmpty(path))
return Response.Error("'path' is required for get_info.");
string fullPath = SanitizeAssetPath(path);
string fullPath = AssetPathUtility.SanitizeAssetPath(path);
if (!AssetExists(fullPath))
return Response.Error($"Asset not found at path: {fullPath}");
@ -761,7 +761,7 @@ namespace MCPForUnity.Editor.Tools
return Response.Error("'path' is required for get_components.");
// 2. Sanitize and check existence
string fullPath = SanitizeAssetPath(path);
string fullPath = AssetPathUtility.SanitizeAssetPath(path);
if (!AssetExists(fullPath))
return Response.Error($"Asset not found at path: {fullPath}");
@ -829,18 +829,6 @@ namespace MCPForUnity.Editor.Tools
/// <summary>
/// Ensures the asset path starts with "Assets/".
/// </summary>
private static string SanitizeAssetPath(string path)
{
if (string.IsNullOrEmpty(path))
return path;
path = path.Replace('\\', '/'); // Normalize separators
if (!path.StartsWith("Assets/", StringComparison.OrdinalIgnoreCase))
{
return "Assets/" + path.TrimStart('/');
}
return path;
}
/// <summary>
/// Checks if an asset exists at the given path (file or folder).
/// </summary>
@ -930,10 +918,12 @@ namespace MCPForUnity.Editor.Tools
);
}
}
} else if (properties["color"] is JArray colorArr) //Use color now with examples set in manage_asset.py
}
else if (properties["color"] is JArray colorArr) //Use color now with examples set in manage_asset.py
{
string propName = "_Color";
try {
try
{
if (colorArr.Count >= 3)
{
Color newColor = new Color(
@ -949,7 +939,8 @@ namespace MCPForUnity.Editor.Tools
}
}
}
catch (Exception ex) {
catch (Exception ex)
{
Debug.LogWarning(
$"Error parsing color property '{propName}': {ex.Message}"
);
@ -989,7 +980,7 @@ namespace MCPForUnity.Editor.Tools
if (!string.IsNullOrEmpty(texPath))
{
Texture newTex = AssetDatabase.LoadAssetAtPath<Texture>(
SanitizeAssetPath(texPath)
AssetPathUtility.SanitizeAssetPath(texPath)
);
if (
newTex != null
@ -1217,7 +1208,7 @@ namespace MCPForUnity.Editor.Tools
&& token.Type == JTokenType.String
)
{
string assetPath = SanitizeAssetPath(token.ToString());
string assetPath = AssetPathUtility.SanitizeAssetPath(token.ToString());
UnityEngine.Object loadedAsset = AssetDatabase.LoadAssetAtPath(
assetPath,
targetType
@ -1337,4 +1328,3 @@ namespace MCPForUnity.Editor.Tools
}
}
}

View File

@ -5,8 +5,9 @@ using System.IO;
using Newtonsoft.Json.Linq;
using UnityEditor;
using UnityEditorInternal; // Required for tag management
using UnityEditor.SceneManagement;
using UnityEngine;
using MCPForUnity.Editor.Helpers; // For Response class
using MCPForUnity.Editor.Helpers;
namespace MCPForUnity.Editor.Tools
{
@ -98,6 +99,8 @@ namespace MCPForUnity.Editor.Tools
return GetActiveTool();
case "get_selection":
return GetSelection();
case "get_prefab_stage":
return GetPrefabStageInfo();
case "set_active_tool":
string toolName = @params["toolName"]?.ToString();
if (string.IsNullOrEmpty(toolName))
@ -140,7 +143,7 @@ namespace MCPForUnity.Editor.Tools
default:
return Response.Error(
$"Unknown action: '{action}'. Supported actions include play, pause, stop, get_state, get_project_root, get_windows, get_active_tool, get_selection, set_active_tool, add_tag, remove_tag, get_tags, add_layer, remove_layer, get_layers."
$"Unknown action: '{action}'. Supported actions include play, pause, stop, get_state, get_project_root, get_windows, get_active_tool, get_selection, get_prefab_stage, set_active_tool, add_tag, remove_tag, get_tags, add_layer, remove_layer, get_layers."
);
}
}
@ -244,6 +247,35 @@ namespace MCPForUnity.Editor.Tools
}
}
private static object GetPrefabStageInfo()
{
try
{
PrefabStage stage = PrefabStageUtility.GetCurrentPrefabStage();
if (stage == null)
{
return Response.Success
("No prefab stage is currently open.", new { isOpen = false });
}
return Response.Success(
"Prefab stage info retrieved.",
new
{
isOpen = true,
assetPath = stage.assetPath,
prefabRootName = stage.prefabContentsRoot != null ? stage.prefabContentsRoot.name : null,
mode = stage.mode.ToString(),
isDirty = stage.scene.isDirty
}
);
}
catch (Exception e)
{
return Response.Error($"Error getting prefab stage info: {e.Message}");
}
}
private static object GetActiveTool()
{
try
@ -610,4 +642,3 @@ namespace MCPForUnity.Editor.Tools
}
}
}

View File

@ -1,15 +1,9 @@
using System;
using Newtonsoft.Json.Linq;
using UnityEditor;
using UnityEngine;
using MCPForUnity.Editor.Helpers;
namespace MCPForUnity.Editor.Tools.MenuItems
{
/// <summary>
/// Facade handler for managing Unity Editor menu items.
/// Routes actions to read or execute implementations.
/// </summary>
public static class ManageMenuItem
{
/// <summary>

View File

@ -2,7 +2,6 @@ using System;
using System.Collections.Generic;
using Newtonsoft.Json.Linq;
using UnityEditor;
using UnityEngine;
using MCPForUnity.Editor.Helpers;
namespace MCPForUnity.Editor.Tools.MenuItems
@ -35,25 +34,14 @@ namespace MCPForUnity.Editor.Tools.MenuItems
return Response.Error($"Execution of menu item '{menuPath}' is blocked for safety reasons.");
}
try
{
// Execute on main thread using delayCall
EditorApplication.delayCall += () =>
{
try
{
bool executed = EditorApplication.ExecuteMenuItem(menuPath);
if (!executed)
{
McpLog.Error($"[MenuItemExecutor] Failed to execute menu item via delayCall: '{menuPath}'. It might be invalid, disabled, or context-dependent.");
McpLog.Error($"[MenuItemExecutor] Failed to execute menu item '{menuPath}'. It might be invalid, disabled, or context-dependent.");
return Response.Error($"Failed to execute menu item '{menuPath}'. It might be invalid, disabled, or context-dependent.");
}
}
catch (Exception delayEx)
{
McpLog.Error($"[MenuItemExecutor] Exception during delayed execution of '{menuPath}': {delayEx}");
}
};
return Response.Success($"Attempted to execute menu item: '{menuPath}'. Check Unity logs for confirmation or errors.");
}
catch (Exception e)

View File

@ -3,7 +3,6 @@ using System.Collections.Generic;
using System.Linq;
using Newtonsoft.Json.Linq;
using UnityEditor;
using UnityEngine;
using MCPForUnity.Editor.Helpers;
namespace MCPForUnity.Editor.Tools.MenuItems

View File

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 1bd48a1b7555c46bba168078ce0291cc
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,274 @@
using System;
using System.IO;
using MCPForUnity.Editor.Helpers;
using Newtonsoft.Json.Linq;
using UnityEditor;
using UnityEditor.SceneManagement;
using UnityEngine;
using UnityEngine.SceneManagement;
namespace MCPForUnity.Editor.Tools.Prefabs
{
public static class ManagePrefabs
{
private const string SupportedActions = "open_stage, close_stage, save_open_stage, create_from_gameobject";
public static object HandleCommand(JObject @params)
{
if (@params == null)
{
return Response.Error("Parameters cannot be null.");
}
string action = @params["action"]?.ToString()?.ToLowerInvariant();
if (string.IsNullOrEmpty(action))
{
return Response.Error($"Action parameter is required. Valid actions are: {SupportedActions}.");
}
try
{
switch (action)
{
case "open_stage":
return OpenStage(@params);
case "close_stage":
return CloseStage(@params);
case "save_open_stage":
return SaveOpenStage();
case "create_from_gameobject":
return CreatePrefabFromGameObject(@params);
default:
return Response.Error($"Unknown action: '{action}'. Valid actions are: {SupportedActions}.");
}
}
catch (Exception e)
{
McpLog.Error($"[ManagePrefabs] Action '{action}' failed: {e}");
return Response.Error($"Internal error: {e.Message}");
}
}
private static object OpenStage(JObject @params)
{
string prefabPath = @params["prefabPath"]?.ToString();
if (string.IsNullOrEmpty(prefabPath))
{
return Response.Error("'prefabPath' parameter is required for open_stage.");
}
string sanitizedPath = AssetPathUtility.SanitizeAssetPath(prefabPath);
GameObject prefabAsset = AssetDatabase.LoadAssetAtPath<GameObject>(sanitizedPath);
if (prefabAsset == null)
{
return Response.Error($"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 Response.Error("Only PrefabStage mode 'InIsolation' is supported at this time.");
}
PrefabStage stage = PrefabStageUtility.OpenPrefab(sanitizedPath);
if (stage == null)
{
return Response.Error($"Failed to open prefab stage for '{sanitizedPath}'.");
}
return Response.Success($"Opened prefab stage for '{sanitizedPath}'.", SerializeStage(stage));
}
private static object CloseStage(JObject @params)
{
PrefabStage stage = PrefabStageUtility.GetCurrentPrefabStage();
if (stage == null)
{
return Response.Success("No prefab stage was open.");
}
bool saveBeforeClose = @params["saveBeforeClose"]?.ToObject<bool>() ?? false;
if (saveBeforeClose && stage.scene.isDirty)
{
SaveStagePrefab(stage);
AssetDatabase.SaveAssets();
}
StageUtility.GoToMainStage();
return Response.Success($"Closed prefab stage for '{stage.assetPath}'.");
}
private static object SaveOpenStage()
{
PrefabStage stage = PrefabStageUtility.GetCurrentPrefabStage();
if (stage == null)
{
return Response.Error("No prefab stage is currently open.");
}
SaveStagePrefab(stage);
AssetDatabase.SaveAssets();
return Response.Success($"Saved prefab stage for '{stage.assetPath}'.", SerializeStage(stage));
}
private static void SaveStagePrefab(PrefabStage stage)
{
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)
{
throw new InvalidOperationException($"Failed to save prefab asset at '{stage.assetPath}'.");
}
}
private static object CreatePrefabFromGameObject(JObject @params)
{
string targetName = @params["target"]?.ToString() ?? @params["name"]?.ToString();
if (string.IsNullOrEmpty(targetName))
{
return Response.Error("'target' parameter is required for create_from_gameobject.");
}
bool includeInactive = @params["searchInactive"]?.ToObject<bool>() ?? false;
GameObject sourceObject = FindSceneObjectByName(targetName, includeInactive);
if (sourceObject == null)
{
return Response.Error($"GameObject '{targetName}' not found in the active scene.");
}
if (PrefabUtility.IsPartOfPrefabAsset(sourceObject))
{
return Response.Error(
$"GameObject '{sourceObject.name}' is part of a prefab asset. Open the prefab stage to save changes instead."
);
}
PrefabInstanceStatus status = PrefabUtility.GetPrefabInstanceStatus(sourceObject);
if (status != PrefabInstanceStatus.NotAPrefab)
{
return Response.Error(
$"GameObject '{sourceObject.name}' is already linked to an existing prefab instance."
);
}
string requestedPath = @params["prefabPath"]?.ToString();
if (string.IsNullOrWhiteSpace(requestedPath))
{
return Response.Error("'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);
}
EnsureAssetDirectoryExists(finalPath);
try
{
GameObject connectedInstance = PrefabUtility.SaveAsPrefabAssetAndConnect(
sourceObject,
finalPath,
InteractionMode.AutomatedAction
);
if (connectedInstance == null)
{
return Response.Error($"Failed to save prefab asset at '{finalPath}'.");
}
Selection.activeGameObject = connectedInstance;
return Response.Success(
$"Prefab created at '{finalPath}' and instance linked.",
new
{
prefabPath = finalPath,
instanceId = connectedInstance.GetInstanceID()
}
);
}
catch (Exception e)
{
return Response.Error($"Error saving prefab asset at '{finalPath}': {e.Message}");
}
}
private static void EnsureAssetDirectoryExists(string assetPath)
{
string directory = Path.GetDirectoryName(assetPath);
if (string.IsNullOrEmpty(directory))
{
return;
}
string fullDirectory = Path.Combine(Directory.GetCurrentDirectory(), directory);
if (!Directory.Exists(fullDirectory))
{
Directory.CreateDirectory(fullDirectory);
AssetDatabase.Refresh();
}
}
private static GameObject FindSceneObjectByName(string name, bool includeInactive)
{
PrefabStage stage = PrefabStageUtility.GetCurrentPrefabStage();
if (stage?.prefabContentsRoot != null)
{
foreach (Transform transform in stage.prefabContentsRoot.GetComponentsInChildren<Transform>(includeInactive))
{
if (transform.name == name)
{
return transform.gameObject;
}
}
}
Scene activeScene = SceneManager.GetActiveScene();
foreach (GameObject root in activeScene.GetRootGameObjects())
{
foreach (Transform transform in root.GetComponentsInChildren<Transform>(includeInactive))
{
GameObject candidate = transform.gameObject;
if (candidate.name == name)
{
return candidate;
}
}
}
return null;
}
private static object SerializeStage(PrefabStage stage)
{
if (stage == null)
{
return new { isOpen = false };
}
return new
{
isOpen = true,
assetPath = stage.assetPath,
prefabRootName = stage.prefabContentsRoot != null ? stage.prefabContentsRoot.name : null,
mode = stage.mode.ToString(),
isDirty = stage.scene.isDirty
};
}
}
}

View File

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: c14e76b2aa7bb4570a88903b061e946e
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -5,6 +5,7 @@ from .manage_scene import register_manage_scene_tools
from .manage_editor import register_manage_editor_tools
from .manage_gameobject import register_manage_gameobject_tools
from .manage_asset import register_manage_asset_tools
from .manage_prefabs import register_manage_prefabs_tools
from .manage_shader import register_manage_shader_tools
from .read_console import register_read_console_tools
from .manage_menu_item import register_manage_menu_item_tools
@ -22,6 +23,7 @@ def register_all_tools(mcp):
register_manage_editor_tools(mcp)
register_manage_gameobject_tools(mcp)
register_manage_asset_tools(mcp)
register_manage_prefabs_tools(mcp)
register_manage_shader_tools(mcp)
register_read_console_tools(mcp)
register_manage_menu_item_tools(mcp)

View File

@ -13,7 +13,7 @@ from unity_connection import get_unity_connection, async_send_command_with_retry
def register_manage_menu_item_tools(mcp: FastMCP):
"""Registers the manage_menu_item tool with the MCP server."""
@mcp.tool()
@mcp.tool(description="Manage Unity menu items (execute/list/exists). If you're not sure what menu item to use, use the 'list' action to find it before using 'execute'.")
@telemetry_tool("manage_menu_item")
async def manage_menu_item(
ctx: Context,
@ -25,18 +25,6 @@ def register_manage_menu_item_tools(mcp: FastMCP):
refresh: Annotated[bool | None,
"Optional flag to force refresh of the menu cache when listing"] = None,
) -> dict[str, Any]:
"""Manage Unity menu items (execute/list/exists).
Args:
ctx: The MCP context.
action: One of 'execute', 'list', 'exists'.
menu_path: Menu path for 'execute' or 'exists' (e.g., "File/Save Project").
search: Optional filter string for 'list'.
refresh: Optional flag to force refresh of the menu cache when listing.
Returns:
A dictionary with operation results ('success', 'data', 'error').
"""
# Prepare parameters for the C# handler
params_dict: dict[str, Any] = {
"action": action,

View File

@ -0,0 +1,59 @@
from typing import Annotated, Any, Literal
from mcp.server.fastmcp import FastMCP, Context
from telemetry_decorator import telemetry_tool
from unity_connection import send_command_with_retry
def register_manage_prefabs_tools(mcp: FastMCP) -> None:
"""Register prefab management tools with the MCP server."""
@mcp.tool(description="Bridge for prefab management commands (stage control and creation).")
@telemetry_tool("manage_prefabs")
def manage_prefabs(
ctx: Context,
action: Annotated[Literal[
"open_stage",
"close_stage",
"save_open_stage",
"create_from_gameobject",
], "One of open_stage, close_stage, save_open_stage, create_from_gameobject"],
prefab_path: Annotated[str | None,
"Prefab asset path relative to Assets e.g. Assets/Prefabs/favorite.prefab"] = None,
mode: Annotated[str | None,
"Optional prefab stage mode (only 'InIsolation' is currently supported)"] = None,
save_before_close: Annotated[bool | None,
"When true, `close_stage` will save the prefab before exiting the stage."] = None,
target: Annotated[str | None,
"Scene GameObject name required for create_from_gameobject"] = None,
allow_overwrite: Annotated[bool | None,
"Allow replacing an existing prefab at the same path"] = None,
search_inactive: Annotated[bool | None,
"Include inactive objects when resolving the target name"] = None,
) -> dict[str, Any]:
try:
params: dict[str, Any] = {"action": action}
if prefab_path:
params["prefabPath"] = prefab_path
if mode:
params["mode"] = mode
if save_before_close is not None:
params["saveBeforeClose"] = bool(save_before_close)
if target:
params["target"] = target
if allow_overwrite is not None:
params["allowOverwrite"] = bool(allow_overwrite)
if search_inactive is not None:
params["searchInactive"] = bool(search_inactive)
response = send_command_with_retry("manage_prefabs", params)
if isinstance(response, dict) and response.get("success"):
return {
"success": True,
"message": response.get("message", "Prefab operation successful."),
"data": response.get("data"),
}
return response if isinstance(response, dict) else {"success": False, "message": str(response)}
except Exception as exc:
return {"success": False, "message": f"Python error managing prefabs: {exc}"}