Skip to content

Commit b7db868

Browse files
msanatandsarno
authored andcommitted
Open and close prefabs in the stage view + create them (CoplayDev#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 10bfe54. * Remove delayed execution of executing menu item, fixing CoplayDev#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
1 parent 8db59ed commit b7db868

File tree

17 files changed

+750
-77
lines changed

17 files changed

+750
-77
lines changed

.github/workflows/bump-version.yml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,9 @@ jobs:
7070
echo "Updating UnityMcpBridge/UnityMcpServer~/src/pyproject.toml to $NEW_VERSION"
7171
sed -i '0,/^version = ".*"/s//version = "'"$NEW_VERSION"'"/' "UnityMcpBridge/UnityMcpServer~/src/pyproject.toml"
7272
73+
echo "Updating UnityMcpBridge/UnityMcpServer~/src/server_version.txt to $NEW_VERSION"
74+
echo "$NEW_VERSION" > "UnityMcpBridge/UnityMcpServer~/src/server_version.txt"
75+
7376
- name: Commit and push changes
7477
env:
7578
NEW_VERSION: ${{ steps.compute.outputs.new_version }}
@@ -78,7 +81,7 @@ jobs:
7881
set -euo pipefail
7982
git config user.name "GitHub Actions"
8083
git config user.email "actions@github.com"
81-
git add UnityMcpBridge/package.json "UnityMcpBridge/UnityMcpServer~/src/pyproject.toml"
84+
git add UnityMcpBridge/package.json "UnityMcpBridge/UnityMcpServer~/src/pyproject.toml" "UnityMcpBridge/UnityMcpServer~/src/server_version.txt"
8285
if git diff --cached --quiet; then
8386
echo "No version changes to commit."
8487
else
Lines changed: 255 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,255 @@
1+
using System.IO;
2+
using Newtonsoft.Json.Linq;
3+
using NUnit.Framework;
4+
using UnityEditor;
5+
using UnityEditor.SceneManagement;
6+
using UnityEngine;
7+
using MCPForUnity.Editor.Tools.Prefabs;
8+
using MCPForUnity.Editor.Tools;
9+
10+
namespace MCPForUnityTests.Editor.Tools
11+
{
12+
public class ManagePrefabsTests
13+
{
14+
private const string TempDirectory = "Assets/Temp/ManagePrefabsTests";
15+
16+
[SetUp]
17+
public void SetUp()
18+
{
19+
StageUtility.GoToMainStage();
20+
EnsureTempDirectoryExists();
21+
}
22+
23+
[TearDown]
24+
public void TearDown()
25+
{
26+
StageUtility.GoToMainStage();
27+
}
28+
29+
[OneTimeTearDown]
30+
public void CleanupAll()
31+
{
32+
StageUtility.GoToMainStage();
33+
if (AssetDatabase.IsValidFolder(TempDirectory))
34+
{
35+
AssetDatabase.DeleteAsset(TempDirectory);
36+
}
37+
}
38+
39+
[Test]
40+
public void OpenStage_OpensPrefabInIsolation()
41+
{
42+
string prefabPath = CreateTestPrefab("OpenStageCube");
43+
44+
try
45+
{
46+
var openParams = new JObject
47+
{
48+
["action"] = "open_stage",
49+
["prefabPath"] = prefabPath
50+
};
51+
52+
var openResult = ToJObject(ManagePrefabs.HandleCommand(openParams));
53+
54+
Assert.IsTrue(openResult.Value<bool>("success"), "open_stage should succeed for a valid prefab.");
55+
56+
PrefabStage stage = PrefabStageUtility.GetCurrentPrefabStage();
57+
Assert.IsNotNull(stage, "Prefab stage should be open after open_stage.");
58+
Assert.AreEqual(prefabPath, stage.assetPath, "Opened stage should match prefab path.");
59+
60+
var stageInfo = ToJObject(ManageEditor.HandleCommand(new JObject { ["action"] = "get_prefab_stage" }));
61+
Assert.IsTrue(stageInfo.Value<bool>("success"), "get_prefab_stage should succeed when stage is open.");
62+
63+
var data = stageInfo["data"] as JObject;
64+
Assert.IsNotNull(data, "Stage info should include data payload.");
65+
Assert.IsTrue(data.Value<bool>("isOpen"));
66+
Assert.AreEqual(prefabPath, data.Value<string>("assetPath"));
67+
}
68+
finally
69+
{
70+
StageUtility.GoToMainStage();
71+
AssetDatabase.DeleteAsset(prefabPath);
72+
}
73+
}
74+
75+
[Test]
76+
public void CloseStage_ReturnsSuccess_WhenNoStageOpen()
77+
{
78+
StageUtility.GoToMainStage();
79+
var closeResult = ToJObject(ManagePrefabs.HandleCommand(new JObject
80+
{
81+
["action"] = "close_stage"
82+
}));
83+
84+
Assert.IsTrue(closeResult.Value<bool>("success"), "close_stage should succeed even if no stage is open.");
85+
}
86+
87+
[Test]
88+
public void CloseStage_ClosesOpenPrefabStage()
89+
{
90+
string prefabPath = CreateTestPrefab("CloseStageCube");
91+
92+
try
93+
{
94+
ManagePrefabs.HandleCommand(new JObject
95+
{
96+
["action"] = "open_stage",
97+
["prefabPath"] = prefabPath
98+
});
99+
100+
var closeResult = ToJObject(ManagePrefabs.HandleCommand(new JObject
101+
{
102+
["action"] = "close_stage"
103+
}));
104+
105+
Assert.IsTrue(closeResult.Value<bool>("success"), "close_stage should succeed when stage is open.");
106+
Assert.IsNull(PrefabStageUtility.GetCurrentPrefabStage(), "Prefab stage should be closed after close_stage.");
107+
}
108+
finally
109+
{
110+
StageUtility.GoToMainStage();
111+
AssetDatabase.DeleteAsset(prefabPath);
112+
}
113+
}
114+
115+
[Test]
116+
public void SaveOpenStage_SavesDirtyChanges()
117+
{
118+
string prefabPath = CreateTestPrefab("SaveStageCube");
119+
120+
try
121+
{
122+
ManagePrefabs.HandleCommand(new JObject
123+
{
124+
["action"] = "open_stage",
125+
["prefabPath"] = prefabPath
126+
});
127+
128+
PrefabStage stage = PrefabStageUtility.GetCurrentPrefabStage();
129+
Assert.IsNotNull(stage, "Stage should be open before modifying.");
130+
131+
stage.prefabContentsRoot.transform.localScale = new Vector3(2f, 2f, 2f);
132+
133+
var saveResult = ToJObject(ManagePrefabs.HandleCommand(new JObject
134+
{
135+
["action"] = "save_open_stage"
136+
}));
137+
138+
Assert.IsTrue(saveResult.Value<bool>("success"), "save_open_stage should succeed when stage is open.");
139+
Assert.IsFalse(stage.scene.isDirty, "Stage scene should not be dirty after saving.");
140+
141+
GameObject reloaded = AssetDatabase.LoadAssetAtPath<GameObject>(prefabPath);
142+
Assert.AreEqual(new Vector3(2f, 2f, 2f), reloaded.transform.localScale, "Saved prefab asset should include changes from open stage.");
143+
}
144+
finally
145+
{
146+
StageUtility.GoToMainStage();
147+
AssetDatabase.DeleteAsset(prefabPath);
148+
}
149+
}
150+
151+
[Test]
152+
public void SaveOpenStage_ReturnsError_WhenNoStageOpen()
153+
{
154+
StageUtility.GoToMainStage();
155+
156+
var saveResult = ToJObject(ManagePrefabs.HandleCommand(new JObject
157+
{
158+
["action"] = "save_open_stage"
159+
}));
160+
161+
Assert.IsFalse(saveResult.Value<bool>("success"), "save_open_stage should fail when no stage is open.");
162+
}
163+
164+
[Test]
165+
public void CreateFromGameObject_CreatesPrefabAndLinksInstance()
166+
{
167+
EnsureTempDirectoryExists();
168+
StageUtility.GoToMainStage();
169+
170+
string prefabPath = Path.Combine(TempDirectory, "SceneObjectSaved.prefab").Replace('\\', '/');
171+
GameObject sceneObject = new GameObject("ScenePrefabSource");
172+
173+
try
174+
{
175+
var result = ToJObject(ManagePrefabs.HandleCommand(new JObject
176+
{
177+
["action"] = "create_from_gameobject",
178+
["target"] = sceneObject.name,
179+
["prefabPath"] = prefabPath
180+
}));
181+
182+
Assert.IsTrue(result.Value<bool>("success"), "create_from_gameobject should succeed for a valid scene object.");
183+
184+
var data = result["data"] as JObject;
185+
Assert.IsNotNull(data, "Response data should include prefab information.");
186+
187+
string savedPath = data.Value<string>("prefabPath");
188+
Assert.AreEqual(prefabPath, savedPath, "Returned prefab path should match the requested path.");
189+
190+
GameObject prefabAsset = AssetDatabase.LoadAssetAtPath<GameObject>(savedPath);
191+
Assert.IsNotNull(prefabAsset, "Prefab asset should exist at the saved path.");
192+
193+
int instanceId = data.Value<int>("instanceId");
194+
var linkedInstance = EditorUtility.InstanceIDToObject(instanceId) as GameObject;
195+
Assert.IsNotNull(linkedInstance, "Linked instance should resolve from instanceId.");
196+
Assert.AreEqual(savedPath, PrefabUtility.GetPrefabAssetPathOfNearestInstanceRoot(linkedInstance), "Instance should be connected to the new prefab.");
197+
198+
sceneObject = linkedInstance;
199+
}
200+
finally
201+
{
202+
if (AssetDatabase.LoadAssetAtPath<UnityEngine.Object>(prefabPath) != null)
203+
{
204+
AssetDatabase.DeleteAsset(prefabPath);
205+
}
206+
207+
if (sceneObject != null)
208+
{
209+
if (PrefabUtility.IsPartOfPrefabInstance(sceneObject))
210+
{
211+
PrefabUtility.UnpackPrefabInstance(
212+
sceneObject,
213+
PrefabUnpackMode.Completely,
214+
InteractionMode.AutomatedAction
215+
);
216+
}
217+
UnityEngine.Object.DestroyImmediate(sceneObject, true);
218+
}
219+
}
220+
}
221+
222+
private static string CreateTestPrefab(string name)
223+
{
224+
EnsureTempDirectoryExists();
225+
226+
GameObject temp = GameObject.CreatePrimitive(PrimitiveType.Cube);
227+
temp.name = name;
228+
229+
string path = Path.Combine(TempDirectory, name + ".prefab").Replace('\\', '/');
230+
PrefabUtility.SaveAsPrefabAsset(temp, path, out bool success);
231+
UnityEngine.Object.DestroyImmediate(temp);
232+
233+
Assert.IsTrue(success, "PrefabUtility.SaveAsPrefabAsset should succeed for test prefab.");
234+
return path;
235+
}
236+
237+
private static void EnsureTempDirectoryExists()
238+
{
239+
if (!AssetDatabase.IsValidFolder("Assets/Temp"))
240+
{
241+
AssetDatabase.CreateFolder("Assets", "Temp");
242+
}
243+
244+
if (!AssetDatabase.IsValidFolder(TempDirectory))
245+
{
246+
AssetDatabase.CreateFolder("Assets/Temp", "ManagePrefabsTests");
247+
}
248+
}
249+
250+
private static JObject ToJObject(object result)
251+
{
252+
return result as JObject ?? JObject.FromObject(result);
253+
}
254+
}
255+
}

TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManagePrefabsTests.cs.meta

Lines changed: 11 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
using System;
2+
3+
namespace MCPForUnity.Editor.Helpers
4+
{
5+
/// <summary>
6+
/// Provides common utility methods for working with Unity asset paths.
7+
/// </summary>
8+
public static class AssetPathUtility
9+
{
10+
/// <summary>
11+
/// Normalizes a Unity asset path by ensuring forward slashes are used and that it is rooted under "Assets/".
12+
/// </summary>
13+
public static string SanitizeAssetPath(string path)
14+
{
15+
if (string.IsNullOrEmpty(path))
16+
{
17+
return path;
18+
}
19+
20+
path = path.Replace('\\', '/');
21+
if (!path.StartsWith("Assets/", StringComparison.OrdinalIgnoreCase))
22+
{
23+
return "Assets/" + path.TrimStart('/');
24+
}
25+
26+
return path;
27+
}
28+
}
29+
}

UnityMcpBridge/Editor/Helpers/AssetPathUtility.cs.meta

Lines changed: 11 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

UnityMcpBridge/Editor/MCPForUnityBridge.cs

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
using MCPForUnity.Editor.Models;
1616
using MCPForUnity.Editor.Tools;
1717
using MCPForUnity.Editor.Tools.MenuItems;
18+
using MCPForUnity.Editor.Tools.Prefabs;
1819

1920
namespace MCPForUnity.Editor
2021
{
@@ -1040,7 +1041,26 @@ private static string ExecuteCommand(Command command)
10401041
// Use JObject for parameters as the new handlers likely expect this
10411042
JObject paramsObject = command.@params ?? new JObject();
10421043

1043-
object result = CommandRegistry.GetHandler(command.type)(paramsObject);
1044+
// Route command based on the new tool structure from the refactor plan
1045+
object result = command.type switch
1046+
{
1047+
// Maps the command type (tool name) to the corresponding handler's static HandleCommand method
1048+
// Assumes each handler class has a static method named 'HandleCommand' that takes JObject parameters
1049+
"manage_script" => ManageScript.HandleCommand(paramsObject),
1050+
// Run scene operations on the main thread to avoid deadlocks/hangs (with diagnostics under debug flag)
1051+
"manage_scene" => HandleManageScene(paramsObject)
1052+
?? throw new TimeoutException($"manage_scene timed out after {FrameIOTimeoutMs} ms on main thread"),
1053+
"manage_editor" => ManageEditor.HandleCommand(paramsObject),
1054+
"manage_gameobject" => ManageGameObject.HandleCommand(paramsObject),
1055+
"manage_asset" => ManageAsset.HandleCommand(paramsObject),
1056+
"manage_shader" => ManageShader.HandleCommand(paramsObject),
1057+
"read_console" => ReadConsole.HandleCommand(paramsObject),
1058+
"manage_menu_item" => ManageMenuItem.HandleCommand(paramsObject),
1059+
"manage_prefabs" => ManagePrefabs.HandleCommand(paramsObject),
1060+
_ => throw new ArgumentException(
1061+
$"Unknown or unsupported command type: {command.type}"
1062+
),
1063+
};
10441064

10451065
// Standard success response format
10461066
var response = new { status = "success", result };

0 commit comments

Comments
 (0)