Skip to content

Commit 6ee7ffb

Browse files
authored
feat: add component_properties to manage_prefabs modify_contents (#793) (#802)
1 parent 21371ae commit 6ee7ffb

5 files changed

Lines changed: 307 additions & 1 deletion

File tree

MCPForUnity/Editor/Tools/Prefabs/ManagePrefabs.cs

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -758,6 +758,52 @@ private static (bool modified, ErrorResponse error) ApplyModificationsToPrefabOb
758758
}
759759
}
760760

761+
// Set properties on existing components
762+
JObject componentProperties = @params["componentProperties"] as JObject ?? @params["component_properties"] as JObject;
763+
if (componentProperties != null && componentProperties.Count > 0)
764+
{
765+
var errors = new List<string>();
766+
767+
foreach (var entry in componentProperties.Properties())
768+
{
769+
string typeName = entry.Name;
770+
if (!ComponentResolver.TryResolve(typeName, out Type componentType, out string resolveError))
771+
{
772+
errors.Add($"{typeName}: type not found — {resolveError}");
773+
continue;
774+
}
775+
776+
Component component = targetGo.GetComponent(componentType);
777+
if (component == null)
778+
{
779+
errors.Add($"{typeName}: not found on '{targetGo.name}'");
780+
continue;
781+
}
782+
783+
if (entry.Value is not JObject props || !props.HasValues)
784+
{
785+
continue;
786+
}
787+
788+
foreach (var prop in props.Properties())
789+
{
790+
if (!ComponentOps.SetProperty(component, prop.Name, prop.Value, out string setError))
791+
{
792+
errors.Add($"{typeName}.{prop.Name}: {setError}");
793+
}
794+
else
795+
{
796+
modified = true;
797+
}
798+
}
799+
}
800+
801+
if (errors.Count > 0)
802+
{
803+
return (false, new ErrorResponse($"Failed to set component properties (no changes saved): {string.Join("; ", errors)}"));
804+
}
805+
}
806+
761807
return (modified, null);
762808
}
763809

Server/src/services/tools/manage_prefabs.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,9 @@
2929
"(single object or array for batch creation in one save). "
3030
"Example: create_child=[{\"name\": \"Child1\", \"primitive_type\": \"Sphere\", \"position\": [1,0,0]}, "
3131
"{\"name\": \"Child2\", \"primitive_type\": \"Cube\", \"parent\": \"Child1\"}]. "
32+
"Use component_properties with modify_contents to set serialized fields on existing components "
33+
"(e.g. component_properties={\"Rigidbody\": {\"mass\": 5.0}, \"MyScript\": {\"health\": 100}}). "
34+
"Supports object references via {\"guid\": \"...\"}, {\"path\": \"Assets/...\"}, or {\"instanceID\": 123}. "
3235
"Use manage_asset action=search filterType=Prefab to list prefabs."
3336
),
3437
annotations=ToolAnnotations(
@@ -64,6 +67,7 @@ async def manage_prefabs(
6467
components_to_add: Annotated[list[str], "Component types to add in modify_contents."] | None = None,
6568
components_to_remove: Annotated[list[str], "Component types to remove in modify_contents."] | None = None,
6669
create_child: Annotated[dict[str, Any] | list[dict[str, Any]], "Create child GameObject(s) in the prefab. Single object or array of objects, each with: name (required), parent (optional, defaults to target), primitive_type (optional: Cube, Sphere, Capsule, Cylinder, Plane, Quad), position, rotation, scale, components_to_add, tag, layer, set_active."] | None = None,
70+
component_properties: Annotated[dict[str, dict[str, Any]], "Set properties on existing components in modify_contents. Keys are component type names, values are dicts of property name to value. Example: {\"Rigidbody\": {\"mass\": 5.0}, \"MyScript\": {\"health\": 100}}. Supports object references via {\"guid\": \"...\"}, {\"path\": \"Assets/...\"}, or {\"instanceID\": 123}."] | None = None,
6771
) -> dict[str, Any]:
6872
# Back-compat: map 'name' → 'target' for create_from_gameobject (Unity accepts both)
6973
if action == "create_from_gameobject" and target is None and name is not None:
@@ -148,6 +152,8 @@ async def manage_prefabs(
148152
params["componentsToAdd"] = components_to_add
149153
if components_to_remove is not None:
150154
params["componentsToRemove"] = components_to_remove
155+
if component_properties is not None:
156+
params["componentProperties"] = component_properties
151157
if create_child is not None:
152158
# Normalize vector fields within create_child (handles single object or array)
153159
def normalize_child_params(child: Any, index: int | None = None) -> tuple[dict | None, str | None]:
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
"""Tests for manage_prefabs tool - component_properties parameter."""
2+
3+
import inspect
4+
5+
from services.tools.manage_prefabs import manage_prefabs
6+
7+
8+
class TestManagePrefabsComponentProperties:
9+
"""Tests for the component_properties parameter on manage_prefabs."""
10+
11+
def test_component_properties_parameter_exists(self):
12+
"""The manage_prefabs tool should have a component_properties parameter."""
13+
sig = inspect.signature(manage_prefabs)
14+
assert "component_properties" in sig.parameters
15+
16+
def test_component_properties_parameter_is_optional(self):
17+
"""component_properties should default to None."""
18+
sig = inspect.signature(manage_prefabs)
19+
param = sig.parameters["component_properties"]
20+
assert param.default is None
21+
22+
def test_tool_description_mentions_component_properties(self):
23+
"""The tool description should mention component_properties."""
24+
from services.registry import get_registered_tools
25+
tools = get_registered_tools()
26+
prefab_tool = next(
27+
(t for t in tools if t["name"] == "manage_prefabs"), None
28+
)
29+
assert prefab_tool is not None
30+
# Description is stored at top level or in kwargs depending on how the decorator stores it
31+
desc = prefab_tool.get("description") or prefab_tool.get("kwargs", {}).get("description", "")
32+
assert "component_properties" in desc
33+
34+
def test_required_params_include_modify_contents(self):
35+
"""modify_contents should be a valid action requiring prefab_path."""
36+
from services.tools.manage_prefabs import REQUIRED_PARAMS
37+
assert "modify_contents" in REQUIRED_PARAMS
38+
assert "prefab_path" in REQUIRED_PARAMS["modify_contents"]

Server/uv.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManagePrefabsCrudTests.cs

Lines changed: 216 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -722,6 +722,204 @@ public void ModifyContents_CreateChild_ReturnsErrorForInvalidInput()
722722

723723
#endregion
724724

725+
#region Component Properties Tests
726+
727+
[Test]
728+
public void ModifyContents_ComponentProperties_SetsSimpleProperties()
729+
{
730+
string prefabPath = CreatePrefabWithComponents("CompPropSimple", typeof(Rigidbody));
731+
732+
try
733+
{
734+
var result = ToJObject(ManagePrefabs.HandleCommand(new JObject
735+
{
736+
["action"] = "modify_contents",
737+
["prefabPath"] = prefabPath,
738+
["componentProperties"] = new JObject
739+
{
740+
["Rigidbody"] = new JObject
741+
{
742+
["mass"] = 42f,
743+
["useGravity"] = false
744+
}
745+
}
746+
}));
747+
748+
Assert.IsTrue(result.Value<bool>("success"), $"Expected success but got: {result}");
749+
Assert.IsTrue(result["data"].Value<bool>("modified"));
750+
751+
// Verify changes persisted
752+
GameObject reloaded = AssetDatabase.LoadAssetAtPath<GameObject>(prefabPath);
753+
var rb = reloaded.GetComponent<Rigidbody>();
754+
Assert.IsNotNull(rb);
755+
Assert.AreEqual(42f, rb.mass, 0.01f);
756+
Assert.IsFalse(rb.useGravity);
757+
}
758+
finally
759+
{
760+
SafeDeleteAsset(prefabPath);
761+
}
762+
}
763+
764+
[Test]
765+
public void ModifyContents_ComponentProperties_SetsMultipleComponents()
766+
{
767+
string prefabPath = CreatePrefabWithComponents("CompPropMulti", typeof(Rigidbody), typeof(Light));
768+
769+
try
770+
{
771+
var result = ToJObject(ManagePrefabs.HandleCommand(new JObject
772+
{
773+
["action"] = "modify_contents",
774+
["prefabPath"] = prefabPath,
775+
["componentProperties"] = new JObject
776+
{
777+
["Rigidbody"] = new JObject { ["mass"] = 10f },
778+
["Light"] = new JObject { ["intensity"] = 3.5f }
779+
}
780+
}));
781+
782+
Assert.IsTrue(result.Value<bool>("success"), $"Expected success but got: {result}");
783+
784+
GameObject reloaded = AssetDatabase.LoadAssetAtPath<GameObject>(prefabPath);
785+
Assert.AreEqual(10f, reloaded.GetComponent<Rigidbody>().mass, 0.01f);
786+
Assert.AreEqual(3.5f, reloaded.GetComponent<Light>().intensity, 0.01f);
787+
}
788+
finally
789+
{
790+
SafeDeleteAsset(prefabPath);
791+
}
792+
}
793+
794+
[Test]
795+
public void ModifyContents_ComponentProperties_SetsOnChildTarget()
796+
{
797+
// Create a prefab with a child that has a Rigidbody
798+
EnsureFolder(TempDirectory);
799+
GameObject root = new GameObject("ChildTargetTest");
800+
GameObject child = new GameObject("Child1") { transform = { parent = root.transform } };
801+
child.AddComponent<Rigidbody>();
802+
803+
string prefabPath = Path.Combine(TempDirectory, "ChildTargetTest.prefab").Replace('\\', '/');
804+
PrefabUtility.SaveAsPrefabAsset(root, prefabPath, out bool success);
805+
UnityEngine.Object.DestroyImmediate(root);
806+
AssetDatabase.Refresh();
807+
Assert.IsTrue(success);
808+
809+
try
810+
{
811+
var result = ToJObject(ManagePrefabs.HandleCommand(new JObject
812+
{
813+
["action"] = "modify_contents",
814+
["prefabPath"] = prefabPath,
815+
["target"] = "Child1",
816+
["componentProperties"] = new JObject
817+
{
818+
["Rigidbody"] = new JObject { ["mass"] = 99f, ["drag"] = 2.5f }
819+
}
820+
}));
821+
822+
Assert.IsTrue(result.Value<bool>("success"), $"Expected success but got: {result}");
823+
824+
GameObject reloaded = AssetDatabase.LoadAssetAtPath<GameObject>(prefabPath);
825+
var childRb = reloaded.transform.Find("Child1").GetComponent<Rigidbody>();
826+
Assert.AreEqual(99f, childRb.mass, 0.01f);
827+
Assert.AreEqual(2.5f, childRb.drag, 0.01f);
828+
}
829+
finally
830+
{
831+
SafeDeleteAsset(prefabPath);
832+
}
833+
}
834+
835+
[Test]
836+
public void ModifyContents_ComponentProperties_ReturnsErrorForMissingComponent()
837+
{
838+
string prefabPath = CreateTestPrefab("CompPropMissing");
839+
840+
try
841+
{
842+
var result = ToJObject(ManagePrefabs.HandleCommand(new JObject
843+
{
844+
["action"] = "modify_contents",
845+
["prefabPath"] = prefabPath,
846+
["componentProperties"] = new JObject
847+
{
848+
["Rigidbody"] = new JObject { ["mass"] = 5f }
849+
}
850+
}));
851+
852+
Assert.IsFalse(result.Value<bool>("success"));
853+
Assert.IsTrue(result.Value<string>("error").Contains("not found"),
854+
$"Expected 'not found' error but got: {result.Value<string>("error")}");
855+
}
856+
finally
857+
{
858+
SafeDeleteAsset(prefabPath);
859+
}
860+
}
861+
862+
[Test]
863+
public void ModifyContents_ComponentProperties_ReturnsErrorForInvalidType()
864+
{
865+
string prefabPath = CreateTestPrefab("CompPropInvalidType");
866+
867+
try
868+
{
869+
var result = ToJObject(ManagePrefabs.HandleCommand(new JObject
870+
{
871+
["action"] = "modify_contents",
872+
["prefabPath"] = prefabPath,
873+
["componentProperties"] = new JObject
874+
{
875+
["NonexistentComponent"] = new JObject { ["foo"] = "bar" }
876+
}
877+
}));
878+
879+
Assert.IsFalse(result.Value<bool>("success"));
880+
Assert.IsTrue(result.Value<string>("error").Contains("not found"),
881+
$"Expected 'not found' error but got: {result.Value<string>("error")}");
882+
}
883+
finally
884+
{
885+
SafeDeleteAsset(prefabPath);
886+
}
887+
}
888+
889+
[Test]
890+
public void ModifyContents_ComponentProperties_CombinesWithOtherModifications()
891+
{
892+
string prefabPath = CreatePrefabWithComponents("CompPropCombined", typeof(Rigidbody));
893+
894+
try
895+
{
896+
var result = ToJObject(ManagePrefabs.HandleCommand(new JObject
897+
{
898+
["action"] = "modify_contents",
899+
["prefabPath"] = prefabPath,
900+
["position"] = new JArray(5f, 10f, 15f),
901+
["name"] = "RenamedWithProps",
902+
["componentProperties"] = new JObject
903+
{
904+
["Rigidbody"] = new JObject { ["mass"] = 25f }
905+
}
906+
}));
907+
908+
Assert.IsTrue(result.Value<bool>("success"), $"Expected success but got: {result}");
909+
910+
GameObject reloaded = AssetDatabase.LoadAssetAtPath<GameObject>(prefabPath);
911+
Assert.AreEqual("RenamedWithProps", reloaded.name);
912+
Assert.AreEqual(new Vector3(5f, 10f, 15f), reloaded.transform.localPosition);
913+
Assert.AreEqual(25f, reloaded.GetComponent<Rigidbody>().mass, 0.01f);
914+
}
915+
finally
916+
{
917+
SafeDeleteAsset(prefabPath);
918+
}
919+
}
920+
921+
#endregion
922+
725923
#region Error Handling
726924

727925
[Test]
@@ -824,6 +1022,24 @@ private static string CreateNestedTestPrefab(string name)
8241022
return path;
8251023
}
8261024

1025+
private static string CreatePrefabWithComponents(string name, params Type[] componentTypes)
1026+
{
1027+
EnsureFolder(TempDirectory);
1028+
GameObject temp = new GameObject(name);
1029+
foreach (var t in componentTypes)
1030+
{
1031+
temp.AddComponent(t);
1032+
}
1033+
1034+
string path = Path.Combine(TempDirectory, name + ".prefab").Replace('\\', '/');
1035+
PrefabUtility.SaveAsPrefabAsset(temp, path, out bool success);
1036+
UnityEngine.Object.DestroyImmediate(temp);
1037+
AssetDatabase.Refresh();
1038+
1039+
if (!success) throw new Exception($"Failed to create test prefab at {path}");
1040+
return path;
1041+
}
1042+
8271043
private static string CreateComplexTestPrefab(string name)
8281044
{
8291045
// Creates: Vehicle (root with BoxCollider)

0 commit comments

Comments
 (0)