Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
46 changes: 46 additions & 0 deletions MCPForUnity/Editor/Tools/Prefabs/ManagePrefabs.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>();

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);
}

Expand Down
6 changes: 6 additions & 0 deletions Server/src/services/tools/manage_prefabs.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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]:
Expand Down
38 changes: 38 additions & 0 deletions Server/tests/test_manage_prefabs.py
Original file line number Diff line number Diff line change
@@ -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"]
2 changes: 1 addition & 1 deletion Server/uv.lock

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

Original file line number Diff line number Diff line change
Expand Up @@ -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<bool>("success"), $"Expected success but got: {result}");
Assert.IsTrue(result["data"].Value<bool>("modified"));

// Verify changes persisted
GameObject reloaded = AssetDatabase.LoadAssetAtPath<GameObject>(prefabPath);
var rb = reloaded.GetComponent<Rigidbody>();
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<bool>("success"), $"Expected success but got: {result}");

GameObject reloaded = AssetDatabase.LoadAssetAtPath<GameObject>(prefabPath);
Assert.AreEqual(10f, reloaded.GetComponent<Rigidbody>().mass, 0.01f);
Assert.AreEqual(3.5f, reloaded.GetComponent<Light>().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<Rigidbody>();

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<bool>("success"), $"Expected success but got: {result}");

GameObject reloaded = AssetDatabase.LoadAssetAtPath<GameObject>(prefabPath);
var childRb = reloaded.transform.Find("Child1").GetComponent<Rigidbody>();
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<bool>("success"));
Assert.IsTrue(result.Value<string>("error").Contains("not found"),
$"Expected 'not found' error but got: {result.Value<string>("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<bool>("success"));
Assert.IsTrue(result.Value<string>("error").Contains("not found"),
$"Expected 'not found' error but got: {result.Value<string>("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<bool>("success"), $"Expected success but got: {result}");

GameObject reloaded = AssetDatabase.LoadAssetAtPath<GameObject>(prefabPath);
Assert.AreEqual("RenamedWithProps", reloaded.name);
Assert.AreEqual(new Vector3(5f, 10f, 15f), reloaded.transform.localPosition);
Assert.AreEqual(25f, reloaded.GetComponent<Rigidbody>().mass, 0.01f);
}
finally
{
SafeDeleteAsset(prefabPath);
}
}

#endregion

#region Error Handling

[Test]
Expand Down Expand Up @@ -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)
Expand Down