Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
6b6172f
refactor: remove unused UnityEngine references from menu item classes
msanatan Sep 17, 2025
2d6baeb
Merge branch 'main' into improved-prefab-support
msanatan Sep 22, 2025
08bb831
Add new tools to manage a prefab, particularly, making them staged.
msanatan Sep 22, 2025
7f940db
feat: add AssetPathUtility for asset path normalization and update re…
msanatan Sep 22, 2025
863984c
feat: add prefab management tools and register them with the MCP server
msanatan Sep 23, 2025
dfa3272
feat: update prefab management commands to use 'prefabPath' and add '…
msanatan Sep 23, 2025
9002957
fix: update parameter references to 'prefabPath' in ManagePrefabs and…
msanatan Sep 23, 2025
fdd1262
fix: clarify error message for missing 'prefabPath' in create_from_ga…
msanatan Sep 23, 2025
10bfe54
fix: ensure pull request triggers for unity tests workflow
msanatan Sep 25, 2025
63b1a42
Revert "fix: ensure pull request triggers for unity tests workflow"
msanatan Sep 25, 2025
745aa32
Remove delayed execution of executing menu item, fixing #279
msanatan Sep 25, 2025
4d70f93
Merge pull request #1 from msanatan/menu-item-fix
msanatan Sep 25, 2025
7972faa
docs: clarify menu item tool description with guidance to use list ac…
msanatan Sep 25, 2025
72c8983
feat: add version update for server_version.txt in bump-version workflow
msanatan Sep 25, 2025
f685e26
fix: simplify error message for failed menu item execution
msanatan Sep 25, 2025
c72549c
Merge branch 'main' into improved-prefab-support
msanatan Sep 26, 2025
8a7d265
Merge branch 'main' into improved-prefab-support
msanatan Sep 26, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .github/workflows/bump-version.yml
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Auto-updates the server-version.txt file

Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
}
}
}

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

29 changes: 29 additions & 0 deletions UnityMcpBridge/Editor/Helpers/AssetPathUtility.cs
Original file line number Diff line number Diff line change
@@ -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;
}
}
}
11 changes: 11 additions & 0 deletions UnityMcpBridge/Editor/Helpers/AssetPathUtility.cs.meta

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

22 changes: 21 additions & 1 deletion UnityMcpBridge/Editor/MCPForUnityBridge.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
using MCPForUnity.Editor.Models;
using MCPForUnity.Editor.Tools;
using MCPForUnity.Editor.Tools.MenuItems;
using MCPForUnity.Editor.Tools.Prefabs;

namespace MCPForUnity.Editor
{
Expand Down Expand Up @@ -1040,7 +1041,26 @@ private static string ExecuteCommand(Command command)
// 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 };
Expand Down
Loading