diff --git a/MCPForUnity/Editor/Tools/Prefabs/ManagePrefabs.cs b/MCPForUnity/Editor/Tools/Prefabs/ManagePrefabs.cs index 6df9d47bf..10ebf1677 100644 --- a/MCPForUnity/Editor/Tools/Prefabs/ManagePrefabs.cs +++ b/MCPForUnity/Editor/Tools/Prefabs/ManagePrefabs.cs @@ -758,6 +758,52 @@ private static (bool modified, ErrorResponse error) ApplyModificationsToPrefabOb } } + // Set properties on existing components + JObject componentProperties = @params["componentProperties"] as JObject ?? @params["component_properties"] as JObject; + if (componentProperties != null && componentProperties.Count > 0) + { + var errors = new List(); + + foreach (var entry in componentProperties.Properties()) + { + string typeName = entry.Name; + if (!ComponentResolver.TryResolve(typeName, out Type componentType, out string resolveError)) + { + errors.Add($"{typeName}: type not found — {resolveError}"); + continue; + } + + Component component = targetGo.GetComponent(componentType); + if (component == null) + { + errors.Add($"{typeName}: not found on '{targetGo.name}'"); + continue; + } + + if (entry.Value is not JObject props || !props.HasValues) + { + continue; + } + + foreach (var prop in props.Properties()) + { + if (!ComponentOps.SetProperty(component, prop.Name, prop.Value, out string setError)) + { + errors.Add($"{typeName}.{prop.Name}: {setError}"); + } + else + { + modified = true; + } + } + } + + if (errors.Count > 0) + { + return (false, new ErrorResponse($"Failed to set component properties (no changes saved): {string.Join("; ", errors)}")); + } + } + return (modified, null); } diff --git a/Server/src/services/tools/manage_prefabs.py b/Server/src/services/tools/manage_prefabs.py index 15df5f803..ac9866202 100644 --- a/Server/src/services/tools/manage_prefabs.py +++ b/Server/src/services/tools/manage_prefabs.py @@ -29,6 +29,9 @@ "(single object or array for batch creation in one save). " "Example: create_child=[{\"name\": \"Child1\", \"primitive_type\": \"Sphere\", \"position\": [1,0,0]}, " "{\"name\": \"Child2\", \"primitive_type\": \"Cube\", \"parent\": \"Child1\"}]. " + "Use component_properties with modify_contents to set serialized fields on existing components " + "(e.g. component_properties={\"Rigidbody\": {\"mass\": 5.0}, \"MyScript\": {\"health\": 100}}). " + "Supports object references via {\"guid\": \"...\"}, {\"path\": \"Assets/...\"}, or {\"instanceID\": 123}. " "Use manage_asset action=search filterType=Prefab to list prefabs." ), annotations=ToolAnnotations( @@ -64,6 +67,7 @@ async def manage_prefabs( components_to_add: Annotated[list[str], "Component types to add in modify_contents."] | None = None, components_to_remove: Annotated[list[str], "Component types to remove in modify_contents."] | None = None, 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, + 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, ) -> dict[str, Any]: # Back-compat: map 'name' → 'target' for create_from_gameobject (Unity accepts both) if action == "create_from_gameobject" and target is None and name is not None: @@ -148,6 +152,8 @@ async def manage_prefabs( params["componentsToAdd"] = components_to_add if components_to_remove is not None: params["componentsToRemove"] = components_to_remove + if component_properties is not None: + params["componentProperties"] = component_properties if create_child is not None: # Normalize vector fields within create_child (handles single object or array) def normalize_child_params(child: Any, index: int | None = None) -> tuple[dict | None, str | None]: diff --git a/Server/tests/test_manage_prefabs.py b/Server/tests/test_manage_prefabs.py new file mode 100644 index 000000000..be441581c --- /dev/null +++ b/Server/tests/test_manage_prefabs.py @@ -0,0 +1,38 @@ +"""Tests for manage_prefabs tool - component_properties parameter.""" + +import inspect + +from services.tools.manage_prefabs import manage_prefabs + + +class TestManagePrefabsComponentProperties: + """Tests for the component_properties parameter on manage_prefabs.""" + + def test_component_properties_parameter_exists(self): + """The manage_prefabs tool should have a component_properties parameter.""" + sig = inspect.signature(manage_prefabs) + assert "component_properties" in sig.parameters + + def test_component_properties_parameter_is_optional(self): + """component_properties should default to None.""" + sig = inspect.signature(manage_prefabs) + param = sig.parameters["component_properties"] + assert param.default is None + + def test_tool_description_mentions_component_properties(self): + """The tool description should mention component_properties.""" + from services.registry import get_registered_tools + tools = get_registered_tools() + prefab_tool = next( + (t for t in tools if t["name"] == "manage_prefabs"), None + ) + assert prefab_tool is not None + # Description is stored at top level or in kwargs depending on how the decorator stores it + desc = prefab_tool.get("description") or prefab_tool.get("kwargs", {}).get("description", "") + assert "component_properties" in desc + + def test_required_params_include_modify_contents(self): + """modify_contents should be a valid action requiring prefab_path.""" + from services.tools.manage_prefabs import REQUIRED_PARAMS + assert "modify_contents" in REQUIRED_PARAMS + assert "prefab_path" in REQUIRED_PARAMS["modify_contents"] diff --git a/Server/uv.lock b/Server/uv.lock index bb38dc952..46e8a45a7 100644 --- a/Server/uv.lock +++ b/Server/uv.lock @@ -912,7 +912,7 @@ wheels = [ [[package]] name = "mcpforunityserver" -version = "9.4.0" +version = "9.4.6" source = { editable = "." } dependencies = [ { name = "click" }, diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManagePrefabsCrudTests.cs b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManagePrefabsCrudTests.cs index b4949224d..365ad306a 100644 --- a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManagePrefabsCrudTests.cs +++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManagePrefabsCrudTests.cs @@ -722,6 +722,204 @@ public void ModifyContents_CreateChild_ReturnsErrorForInvalidInput() #endregion + #region Component Properties Tests + + [Test] + public void ModifyContents_ComponentProperties_SetsSimpleProperties() + { + string prefabPath = CreatePrefabWithComponents("CompPropSimple", typeof(Rigidbody)); + + try + { + var result = ToJObject(ManagePrefabs.HandleCommand(new JObject + { + ["action"] = "modify_contents", + ["prefabPath"] = prefabPath, + ["componentProperties"] = new JObject + { + ["Rigidbody"] = new JObject + { + ["mass"] = 42f, + ["useGravity"] = false + } + } + })); + + Assert.IsTrue(result.Value("success"), $"Expected success but got: {result}"); + Assert.IsTrue(result["data"].Value("modified")); + + // Verify changes persisted + GameObject reloaded = AssetDatabase.LoadAssetAtPath(prefabPath); + var rb = reloaded.GetComponent(); + Assert.IsNotNull(rb); + Assert.AreEqual(42f, rb.mass, 0.01f); + Assert.IsFalse(rb.useGravity); + } + finally + { + SafeDeleteAsset(prefabPath); + } + } + + [Test] + public void ModifyContents_ComponentProperties_SetsMultipleComponents() + { + string prefabPath = CreatePrefabWithComponents("CompPropMulti", typeof(Rigidbody), typeof(Light)); + + try + { + var result = ToJObject(ManagePrefabs.HandleCommand(new JObject + { + ["action"] = "modify_contents", + ["prefabPath"] = prefabPath, + ["componentProperties"] = new JObject + { + ["Rigidbody"] = new JObject { ["mass"] = 10f }, + ["Light"] = new JObject { ["intensity"] = 3.5f } + } + })); + + Assert.IsTrue(result.Value("success"), $"Expected success but got: {result}"); + + GameObject reloaded = AssetDatabase.LoadAssetAtPath(prefabPath); + Assert.AreEqual(10f, reloaded.GetComponent().mass, 0.01f); + Assert.AreEqual(3.5f, reloaded.GetComponent().intensity, 0.01f); + } + finally + { + SafeDeleteAsset(prefabPath); + } + } + + [Test] + public void ModifyContents_ComponentProperties_SetsOnChildTarget() + { + // Create a prefab with a child that has a Rigidbody + EnsureFolder(TempDirectory); + GameObject root = new GameObject("ChildTargetTest"); + GameObject child = new GameObject("Child1") { transform = { parent = root.transform } }; + child.AddComponent(); + + string prefabPath = Path.Combine(TempDirectory, "ChildTargetTest.prefab").Replace('\\', '/'); + PrefabUtility.SaveAsPrefabAsset(root, prefabPath, out bool success); + UnityEngine.Object.DestroyImmediate(root); + AssetDatabase.Refresh(); + Assert.IsTrue(success); + + try + { + var result = ToJObject(ManagePrefabs.HandleCommand(new JObject + { + ["action"] = "modify_contents", + ["prefabPath"] = prefabPath, + ["target"] = "Child1", + ["componentProperties"] = new JObject + { + ["Rigidbody"] = new JObject { ["mass"] = 99f, ["drag"] = 2.5f } + } + })); + + Assert.IsTrue(result.Value("success"), $"Expected success but got: {result}"); + + GameObject reloaded = AssetDatabase.LoadAssetAtPath(prefabPath); + var childRb = reloaded.transform.Find("Child1").GetComponent(); + Assert.AreEqual(99f, childRb.mass, 0.01f); + Assert.AreEqual(2.5f, childRb.drag, 0.01f); + } + finally + { + SafeDeleteAsset(prefabPath); + } + } + + [Test] + public void ModifyContents_ComponentProperties_ReturnsErrorForMissingComponent() + { + string prefabPath = CreateTestPrefab("CompPropMissing"); + + try + { + var result = ToJObject(ManagePrefabs.HandleCommand(new JObject + { + ["action"] = "modify_contents", + ["prefabPath"] = prefabPath, + ["componentProperties"] = new JObject + { + ["Rigidbody"] = new JObject { ["mass"] = 5f } + } + })); + + Assert.IsFalse(result.Value("success")); + Assert.IsTrue(result.Value("error").Contains("not found"), + $"Expected 'not found' error but got: {result.Value("error")}"); + } + finally + { + SafeDeleteAsset(prefabPath); + } + } + + [Test] + public void ModifyContents_ComponentProperties_ReturnsErrorForInvalidType() + { + string prefabPath = CreateTestPrefab("CompPropInvalidType"); + + try + { + var result = ToJObject(ManagePrefabs.HandleCommand(new JObject + { + ["action"] = "modify_contents", + ["prefabPath"] = prefabPath, + ["componentProperties"] = new JObject + { + ["NonexistentComponent"] = new JObject { ["foo"] = "bar" } + } + })); + + Assert.IsFalse(result.Value("success")); + Assert.IsTrue(result.Value("error").Contains("not found"), + $"Expected 'not found' error but got: {result.Value("error")}"); + } + finally + { + SafeDeleteAsset(prefabPath); + } + } + + [Test] + public void ModifyContents_ComponentProperties_CombinesWithOtherModifications() + { + string prefabPath = CreatePrefabWithComponents("CompPropCombined", typeof(Rigidbody)); + + try + { + var result = ToJObject(ManagePrefabs.HandleCommand(new JObject + { + ["action"] = "modify_contents", + ["prefabPath"] = prefabPath, + ["position"] = new JArray(5f, 10f, 15f), + ["name"] = "RenamedWithProps", + ["componentProperties"] = new JObject + { + ["Rigidbody"] = new JObject { ["mass"] = 25f } + } + })); + + Assert.IsTrue(result.Value("success"), $"Expected success but got: {result}"); + + GameObject reloaded = AssetDatabase.LoadAssetAtPath(prefabPath); + Assert.AreEqual("RenamedWithProps", reloaded.name); + Assert.AreEqual(new Vector3(5f, 10f, 15f), reloaded.transform.localPosition); + Assert.AreEqual(25f, reloaded.GetComponent().mass, 0.01f); + } + finally + { + SafeDeleteAsset(prefabPath); + } + } + + #endregion + #region Error Handling [Test] @@ -824,6 +1022,24 @@ private static string CreateNestedTestPrefab(string name) return path; } + private static string CreatePrefabWithComponents(string name, params Type[] componentTypes) + { + EnsureFolder(TempDirectory); + GameObject temp = new GameObject(name); + foreach (var t in componentTypes) + { + temp.AddComponent(t); + } + + string path = Path.Combine(TempDirectory, name + ".prefab").Replace('\\', '/'); + PrefabUtility.SaveAsPrefabAsset(temp, path, out bool success); + UnityEngine.Object.DestroyImmediate(temp); + AssetDatabase.Refresh(); + + if (!success) throw new Exception($"Failed to create test prefab at {path}"); + return path; + } + private static string CreateComplexTestPrefab(string name) { // Creates: Vehicle (root with BoxCollider)