diff --git a/MCPForUnity/Editor/Tools/Prefabs/ManagePrefabs.cs b/MCPForUnity/Editor/Tools/Prefabs/ManagePrefabs.cs index b24eee044..664c976c2 100644 --- a/MCPForUnity/Editor/Tools/Prefabs/ManagePrefabs.cs +++ b/MCPForUnity/Editor/Tools/Prefabs/ManagePrefabs.cs @@ -905,6 +905,21 @@ private static (bool modified, ErrorResponse error) ApplyModificationsToPrefabOb } } + // Delete child GameObjects (supports single string or array of paths/names) + JToken deleteChildToken = @params["deleteChild"] ?? @params["delete_child"]; + if (deleteChildToken != null) + { + var deleteResult = RemoveChildren(deleteChildToken, targetGo, prefabRoot); + if (deleteResult.error != null) + { + return (false, deleteResult.error); + } + if (deleteResult.removedCount > 0) + { + modified = true; + } + } + // Set properties on existing components JObject componentProperties = @params["componentProperties"] as JObject ?? @params["component_properties"] as JObject; if (componentProperties != null && componentProperties.Count > 0) @@ -1132,6 +1147,47 @@ private static (bool created, ErrorResponse error) CreateSingleChildInPrefab(JTo return (true, null); } + /// + /// Removes child GameObjects from a prefab. + /// + private static (int removedCount, ErrorResponse error) RemoveChildren(JToken deleteChildToken, GameObject targetGo, GameObject prefabRoot) + { + int removedCount = 0; + + // Normalize to array + JArray childrenToDelete; + if (deleteChildToken is JArray arr) + { + childrenToDelete = arr; + } + else + { + childrenToDelete = new JArray { deleteChildToken }; + } + + foreach (var childToken in childrenToDelete) + { + string childPath = childToken.Type == JTokenType.String ? childToken.ToString() : childToken["name"]?.ToString(); + if (string.IsNullOrEmpty(childPath)) + { + return (removedCount, new ErrorResponse("'delete_child' must be a string or object with 'name' field.")); + } + + // Find the child to remove + Transform childToRemove = targetGo.transform.Find(childPath); + if (childToRemove == null) + { + return (removedCount, new ErrorResponse($"Child '{childPath}' not found under '{targetGo.name}'.")); + } + + UnityEngine.Object.DestroyImmediate(childToRemove.gameObject); + removedCount++; + McpLog.Info($"[ManagePrefabs] Removed child '{childPath}' under '{targetGo.name}' in prefab."); + } + + return (removedCount, null); + } + #endregion #region Hierarchy Builder diff --git a/Server/src/cli/commands/prefab.py b/Server/src/cli/commands/prefab.py index 1081476d4..c0226db2e 100644 --- a/Server/src/cli/commands/prefab.py +++ b/Server/src/cli/commands/prefab.py @@ -1,5 +1,6 @@ """Prefab CLI commands.""" +import json import sys import click from typing import Optional, Any @@ -246,3 +247,124 @@ def create(target: str, path: str, overwrite: bool, include_inactive: bool, unli click.echo(format_output(result, config.format)) if result.get("success"): print_success(f"Created prefab: {path}") + + +def _parse_vector3(value: str) -> list[float]: + """Parse 'x,y,z' string to list of floats.""" + parts = value.split(",") + if len(parts) != 3: + raise click.BadParameter("Must be 'x,y,z' format") + return [float(p.strip()) for p in parts] + + +def _parse_property(prop_str: str) -> tuple[str, str, Any]: + """Parse 'Component.prop=value' into (component, prop, value).""" + if "=" not in prop_str: + raise click.BadParameter("Must be 'Component.prop=value' format") + comp_prop, val_str = prop_str.split("=", 1) + if "." not in comp_prop: + raise click.BadParameter("Must be 'Component.prop=value' format") + component, prop = comp_prop.rsplit(".", 1) + + val_str = val_str.strip() + + # Parse booleans + if val_str.lower() == "true": + parsed_value: Any = True + elif val_str.lower() == "false": + parsed_value = False + # Parse numbers + elif "." in val_str: + try: + parsed_value = float(val_str) + except ValueError: + parsed_value = val_str + else: + try: + parsed_value = int(val_str) + except ValueError: + parsed_value = val_str + + return component.strip(), prop.strip(), parsed_value + + +@prefab.command("modify") +@click.argument("path") +@click.option("--target", "-t", help="Target object name/path within prefab (default: root)") +@click.option("--position", "-p", help="New local position as 'x,y,z'") +@click.option("--rotation", "-r", help="New local rotation as 'x,y,z'") +@click.option("--scale", "-s", help="New local scale as 'x,y,z'") +@click.option("--name", "-n", help="New name for target") +@click.option("--tag", help="New tag") +@click.option("--layer", help="New layer") +@click.option("--active/--inactive", default=None, help="Set active state") +@click.option("--parent", help="New parent object name/path") +@click.option("--add-component", multiple=True, help="Component type to add (repeatable)") +@click.option("--remove-component", multiple=True, help="Component type to remove (repeatable)") +@click.option("--set-property", multiple=True, help="Property as 'Component.prop=value' (repeatable)") +@click.option("--delete-child", multiple=True, help="Child name/path to remove (repeatable)") +@click.option("--create-child", help="JSON object for child creation") +@handle_unity_errors +def modify(path: str, target: Optional[str], position: Optional[str], rotation: Optional[str], + scale: Optional[str], name: Optional[str], tag: Optional[str], layer: Optional[str], + active: Optional[bool], parent: Optional[str], add_component: tuple, remove_component: tuple, + set_property: tuple, delete_child: tuple, create_child: Optional[str]): + """Modify a prefab's contents (headless, no UI). + + \b + Examples: + unity-mcp prefab modify "Assets/Prefabs/Player.prefab" --delete-child Child1 + unity-mcp prefab modify "Assets/Prefabs/Player.prefab" --delete-child "Turret/Barrel" --delete-child Bullet + unity-mcp prefab modify "Assets/Prefabs/Player.prefab" --target Weapon --position "0,1,2" + unity-mcp prefab modify "Assets/Prefabs/Player.prefab" --set-property "Rigidbody.mass=5" + unity-mcp prefab modify "Assets/Prefabs/Player.prefab" --create-child '{"name":"Spawn","primitive_type":"Sphere"}' + """ + config = get_config() + + params: dict[str, Any] = { + "action": "modify_contents", + "prefabPath": path, + } + + if target: + params["target"] = target + if position: + params["position"] = _parse_vector3(position) + if rotation: + params["rotation"] = _parse_vector3(rotation) + if scale: + params["scale"] = _parse_vector3(scale) + if name: + params["name"] = name + if tag: + params["tag"] = tag + if layer: + params["layer"] = layer + if active is not None: + params["setActive"] = active + if parent: + params["parent"] = parent + if add_component: + params["componentsToAdd"] = list(add_component) + if remove_component: + params["componentsToRemove"] = list(remove_component) + if set_property: + component_properties: dict[str, dict[str, Any]] = {} + for prop in set_property: + comp, name_p, val = _parse_property(prop) + if comp not in component_properties: + component_properties[comp] = {} + component_properties[comp][name_p] = val + params["componentProperties"] = component_properties + if delete_child: + params["deleteChild"] = list(delete_child) + if create_child: + try: + params["createChild"] = json.loads(create_child) + except json.JSONDecodeError as e: + raise click.BadParameter(f"Invalid JSON for --create-child: {e}") + + result = run_command("manage_prefabs", params, config) + click.echo(format_output(result, config.format)) + if result.get("success"): + print_success(f"Modified prefab: {path}") diff --git a/Server/src/services/tools/manage_prefabs.py b/Server/src/services/tools/manage_prefabs.py index bfa2f8cf1..e0a486d99 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\": \"Nested\", \"source_prefab_path\": \"Assets/Prefabs/Bullet.prefab\", \"position\": [0,2,0]}]. " + "Use delete_child parameter to remove child GameObjects from the prefab " + "(single name/path or array of paths for batch deletion. " + "Example: delete_child=[\"Child1\", \"Child2/Grandchild\"]). " "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}. " @@ -67,6 +70,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), source_prefab_path (optional: asset path to instantiate as nested prefab, e.g. 'Assets/Prefabs/Bullet.prefab'), primitive_type (optional: Cube, Sphere, Capsule, Cylinder, Plane, Quad), position, rotation, scale, components_to_add, tag, layer, set_active. source_prefab_path and primitive_type are mutually exclusive."] | None = None, + delete_child: Annotated[str | list[str], "Child name(s) or path(s) to remove from the prefab. Supports single string or array for batch deletion (e.g. 'Child1' or ['Child1', 'Child1/Grandchild'])."] | 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}. For Sprite sub-assets: {\"guid\": \"...\", \"spriteName\": \"\"}. Single-sprite textures auto-resolve."] | None = None, ) -> dict[str, Any]: # Back-compat: map 'name' → 'target' for create_from_gameobject (Unity accepts both) @@ -185,6 +189,9 @@ def normalize_child_params(child: Any, index: int | None = None) -> tuple[dict | return {"success": False, "message": err} params["createChild"] = child_params + if delete_child is not None: + params["deleteChild"] = delete_child + # Send command to Unity response = await send_with_unity_instance( async_send_command_with_retry, unity_instance, "manage_prefabs", params diff --git a/Server/tests/test_cli.py b/Server/tests/test_cli.py index 976b03cad..e15f5bfd0 100644 --- a/Server/tests/test_cli.py +++ b/Server/tests/test_cli.py @@ -636,6 +636,115 @@ def test_prefab_create(self, runner, mock_unity_response): ]) assert result.exit_code == 0 + def test_prefab_modify_delete_child(self, runner, mock_unity_response): + """Test prefab modify delete-child command.""" + with patch("cli.commands.prefab.run_command", return_value=mock_unity_response) as mock_run: + result = runner.invoke(cli, [ + "prefab", "modify", "Assets/Prefabs/Player.prefab", + "--delete-child", "Child1", + "--delete-child", "Turret/Barrel" + ]) + assert result.exit_code == 0 + # Verify the correct params were sent + call_args = mock_run.call_args[0] + assert call_args[0] == "manage_prefabs" + params = call_args[1] + assert params["action"] == "modify_contents" + assert params["deleteChild"] == ["Child1", "Turret/Barrel"] + + def test_prefab_modify_transform(self, runner, mock_unity_response): + """Test prefab modify with transform options.""" + with patch("cli.commands.prefab.run_command", return_value=mock_unity_response) as mock_run: + result = runner.invoke(cli, [ + "prefab", "modify", "Assets/Prefabs/Player.prefab", + "--target", "Weapon", + "--position", "1,2,3", + "--rotation", "45,0,90", + "--scale", "2,2,2" + ]) + assert result.exit_code == 0 + call_args = mock_run.call_args[0] + params = call_args[1] + assert params["target"] == "Weapon" + assert params["position"] == [1.0, 2.0, 3.0] + assert params["rotation"] == [45.0, 0.0, 90.0] + assert params["scale"] == [2.0, 2.0, 2.0] + + def test_prefab_modify_set_property(self, runner, mock_unity_response): + """Test prefab modify set-property command.""" + with patch("cli.commands.prefab.run_command", return_value=mock_unity_response) as mock_run: + result = runner.invoke(cli, [ + "prefab", "modify", "Assets/Prefabs/Player.prefab", + "--set-property", "Rigidbody.mass=5", + "--set-property", "Rigidbody.useGravity=false" + ]) + assert result.exit_code == 0 + call_args = mock_run.call_args[0] + params = call_args[1] + assert "componentProperties" in params + assert params["componentProperties"]["Rigidbody"]["mass"] == 5 + assert params["componentProperties"]["Rigidbody"]["useGravity"] is False + + def test_prefab_modify_set_property_float(self, runner, mock_unity_response): + """Test prefab modify set-property with float values.""" + with patch("cli.commands.prefab.run_command", return_value=mock_unity_response) as mock_run: + result = runner.invoke(cli, [ + "prefab", "modify", "Assets/Prefabs/Player.prefab", + "--set-property", "Rigidbody.mass=5.5" + ]) + assert result.exit_code == 0 + call_args = mock_run.call_args[0] + params = call_args[1] + assert params["componentProperties"]["Rigidbody"]["mass"] == 5.5 + + def test_prefab_modify_components(self, runner, mock_unity_response): + """Test prefab modify add/remove components.""" + with patch("cli.commands.prefab.run_command", return_value=mock_unity_response) as mock_run: + result = runner.invoke(cli, [ + "prefab", "modify", "Assets/Prefabs/Player.prefab", + "--add-component", "Rigidbody", + "--add-component", "BoxCollider", + "--remove-component", "SphereCollider" + ]) + assert result.exit_code == 0 + call_args = mock_run.call_args[0] + params = call_args[1] + assert params["componentsToAdd"] == ["Rigidbody", "BoxCollider"] + assert params["componentsToRemove"] == ["SphereCollider"] + + def test_prefab_modify_create_child(self, runner, mock_unity_response): + """Test prefab modify create-child command.""" + with patch("cli.commands.prefab.run_command", return_value=mock_unity_response) as mock_run: + result = runner.invoke(cli, [ + "prefab", "modify", "Assets/Prefabs/Player.prefab", + "--create-child", '{"name":"Spawn","primitive_type":"Sphere"}' + ]) + assert result.exit_code == 0 + call_args = mock_run.call_args[0] + params = call_args[1] + assert params["createChild"]["name"] == "Spawn" + assert params["createChild"]["primitive_type"] == "Sphere" + + def test_prefab_modify_invalid_create_child_json(self, runner, mock_unity_response): + """Test prefab modify with invalid JSON for create-child.""" + result = runner.invoke(cli, [ + "prefab", "modify", "Assets/Prefabs/Player.prefab", + "--create-child", "not valid json" + ]) + assert result.exit_code != 0 + + def test_prefab_modify_active_state(self, runner, mock_unity_response): + """Test prefab modify active/inactive flag.""" + with patch("cli.commands.prefab.run_command", return_value=mock_unity_response) as mock_run: + result = runner.invoke(cli, [ + "prefab", "modify", "Assets/Prefabs/Player.prefab", + "--inactive" + ]) + assert result.exit_code == 0 + call_args = mock_run.call_args[0] + params = call_args[1] + assert params["setActive"] is False + # ============================================================================= # Material Command Tests diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManagePrefabsCrudTests.cs b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManagePrefabsCrudTests.cs index fb8ab86ef..b9f5ddcee 100644 --- a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManagePrefabsCrudTests.cs +++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManagePrefabsCrudTests.cs @@ -722,6 +722,115 @@ public void ModifyContents_CreateChild_ReturnsErrorForInvalidInput() #endregion + #region Delete Child Tests + + [Test] + public void ModifyContents_DeleteChild_DeletesSingleChild() + { + string prefabPath = CreateNestedTestPrefab("DeleteSingleChild"); + + try + { + var result = ToJObject(ManagePrefabs.HandleCommand(new JObject + { + ["action"] = "modify_contents", + ["prefabPath"] = prefabPath, + ["deleteChild"] = "Child1" + })); + + Assert.IsTrue(result.Value("success"), $"Expected success but got: {result}"); + + GameObject reloaded = AssetDatabase.LoadAssetAtPath(prefabPath); + Assert.IsNull(reloaded.transform.Find("Child1"), "Child1 should be deleted"); + Assert.IsNotNull(reloaded.transform.Find("Child2"), "Child2 should still exist"); + } + finally + { + SafeDeleteAsset(prefabPath); + } + } + + [Test] + public void ModifyContents_DeleteChild_DeletesNestedChild() + { + string prefabPath = CreateNestedTestPrefab("DeleteNestedChild"); + + try + { + var result = ToJObject(ManagePrefabs.HandleCommand(new JObject + { + ["action"] = "modify_contents", + ["prefabPath"] = prefabPath, + ["target"] = "Child1", + ["deleteChild"] = "Grandchild" + })); + + Assert.IsTrue(result.Value("success"), $"Expected success but got: {result}"); + + GameObject reloaded = AssetDatabase.LoadAssetAtPath(prefabPath); + Assert.IsNull(reloaded.transform.Find("Child1/Grandchild"), "Grandchild should be deleted"); + Assert.IsNotNull(reloaded.transform.Find("Child1"), "Child1 should still exist"); + } + finally + { + SafeDeleteAsset(prefabPath); + } + } + + [Test] + public void ModifyContents_DeleteChild_DeletesMultipleChildrenFromArray() + { + string prefabPath = CreateNestedTestPrefab("DeleteMultipleChildren"); + + try + { + var result = ToJObject(ManagePrefabs.HandleCommand(new JObject + { + ["action"] = "modify_contents", + ["prefabPath"] = prefabPath, + ["deleteChild"] = new JArray { "Child1", "Child2" } + })); + + Assert.IsTrue(result.Value("success"), $"Expected success but got: {result}"); + + GameObject reloaded = AssetDatabase.LoadAssetAtPath(prefabPath); + Assert.IsNull(reloaded.transform.Find("Child1"), "Child1 should be deleted"); + Assert.IsNull(reloaded.transform.Find("Child2"), "Child2 should be deleted"); + // Only the root should remain + Assert.AreEqual(0, reloaded.transform.childCount, "Root should have no children"); + } + finally + { + SafeDeleteAsset(prefabPath); + } + } + + [Test] + public void ModifyContents_DeleteChild_ReturnsErrorForNonexistentChild() + { + string prefabPath = CreateNestedTestPrefab("DeleteNonexistentChild"); + + try + { + var result = ToJObject(ManagePrefabs.HandleCommand(new JObject + { + ["action"] = "modify_contents", + ["prefabPath"] = prefabPath, + ["deleteChild"] = "DeleteNonexistentChild" // This also tests whether it searches itself + })); + + 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); + } + } + + #endregion + #region Component Properties Tests [Test]