diff --git a/MCPForUnity/Editor/Tools/Prefabs/ManagePrefabs.cs b/MCPForUnity/Editor/Tools/Prefabs/ManagePrefabs.cs index b24eee044..bedd4a36d 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("'deleteChild'/'delete_child' entries 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/CLI_USAGE_GUIDE.md b/Server/src/cli/CLI_USAGE_GUIDE.md index 4c6413eae..a276bd15a 100644 --- a/Server/src/cli/CLI_USAGE_GUIDE.md +++ b/Server/src/cli/CLI_USAGE_GUIDE.md @@ -560,6 +560,15 @@ unity-mcp prefab save # Close prefab stage unity-mcp prefab close + +# Modify prefab contents (headless) +unity-mcp prefab modify "Assets/Prefabs/Player.prefab" --target Weapon --position "0,1,2" +unity-mcp prefab modify "Assets/Prefabs/Player.prefab" --delete-child Child1 --delete-child "Turret/Barrel" +unity-mcp prefab modify "Assets/Prefabs/Player.prefab" --set-property "Rigidbody.mass=5" +unity-mcp prefab modify "Assets/Prefabs/Player.prefab" --add-component BoxCollider --remove-component SphereCollider +unity-mcp prefab modify "Assets/Prefabs/Player.prefab" --create-child '{"name":"Spawn","primitive_type":"Sphere"}' +unity-mcp prefab modify "Assets/Prefabs/Player.prefab" --name NewName --tag Player --layer UI +unity-mcp prefab modify "Assets/Prefabs/Player.prefab" --inactive ``` ### UI Commands diff --git a/Server/src/cli/commands/prefab.py b/Server/src/cli/commands/prefab.py index 1081476d4..7278428de 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,132 @@ 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") + try: + return [float(p.strip()) for p in parts] + except ValueError as e: + raise click.BadParameter(f"All components must be numeric, got: '{value}'") from e + + +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) + if not component.strip() or not prop.strip(): + raise click.BadParameter(f"Component and property must be non-empty in '{comp_prop}', expected 'Component.prop=value'") + + 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: + parsed = json.loads(create_child) + except json.JSONDecodeError as e: + raise click.BadParameter(f"Invalid JSON for --create-child: {e}") from e + if not isinstance(parsed, dict): + raise click.BadParameter(f"--create-child must be a JSON object, got {type(parsed).__name__}") + params["createChild"] = parsed + + 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..ccaebaa26 100644 --- a/Server/tests/test_cli.py +++ b/Server/tests/test_cli.py @@ -636,6 +636,214 @@ 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 + assert "Invalid JSON" in result.output + + def test_prefab_modify_create_child_non_object_json(self, runner, mock_unity_response): + """Test prefab modify rejects non-object JSON for create-child.""" + result = runner.invoke(cli, [ + "prefab", "modify", "Assets/Prefabs/Player.prefab", + "--create-child", '"just a string"' + ]) + assert result.exit_code != 0 + assert "must be a JSON object" in result.output + + 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 + + def test_prefab_modify_active_flag(self, runner, mock_unity_response): + """Test prefab modify --active 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", + "--active" + ]) + assert result.exit_code == 0 + call_args = mock_run.call_args[0] + params = call_args[1] + assert params["setActive"] is True + + def test_prefab_modify_name_tag_layer_parent(self, runner, mock_unity_response): + """Test prefab modify with name, tag, layer, and parent 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", "Child1", + "--name", "RenamedChild", + "--tag", "Player", + "--layer", "UI", + "--parent", "NewParent" + ]) + assert result.exit_code == 0 + call_args = mock_run.call_args[0] + params = call_args[1] + assert params["target"] == "Child1" + assert params["name"] == "RenamedChild" + assert params["tag"] == "Player" + assert params["layer"] == "UI" + assert params["parent"] == "NewParent" + + def test_prefab_modify_invalid_vector_non_numeric(self, runner, mock_unity_response): + """Test prefab modify rejects non-numeric vector components.""" + result = runner.invoke(cli, [ + "prefab", "modify", "Assets/Prefabs/Player.prefab", + "--position", "1,foo,3" + ]) + assert result.exit_code != 0 + + def test_prefab_modify_invalid_vector_wrong_count(self, runner, mock_unity_response): + """Test prefab modify rejects vectors with wrong component count.""" + result = runner.invoke(cli, [ + "prefab", "modify", "Assets/Prefabs/Player.prefab", + "--position", "1,2" + ]) + assert result.exit_code != 0 + + def test_prefab_modify_set_property_string_value(self, runner, mock_unity_response): + """Test prefab modify set-property with string 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", "MyScript.label=hello world" + ]) + assert result.exit_code == 0 + call_args = mock_run.call_args[0] + params = call_args[1] + assert params["componentProperties"]["MyScript"]["label"] == "hello world" + + def test_prefab_modify_set_property_empty_component(self, runner, mock_unity_response): + """Test prefab modify rejects empty component name in set-property.""" + result = runner.invoke(cli, [ + "prefab", "modify", "Assets/Prefabs/Player.prefab", + "--set-property", ".mass=5" + ]) + assert result.exit_code != 0 + assert "non-empty" in result.output + + def test_prefab_modify_set_property_empty_prop(self, runner, mock_unity_response): + """Test prefab modify rejects empty property name in set-property.""" + result = runner.invoke(cli, [ + "prefab", "modify", "Assets/Prefabs/Player.prefab", + "--set-property", "Rigidbody.=5" + ]) + assert result.exit_code != 0 + assert "non-empty" in result.output + + def test_prefab_modify_no_options_sends_minimal_params(self, runner, mock_unity_response): + """Test prefab modify with no options sends only action and prefabPath.""" + 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" + ]) + assert result.exit_code == 0 + call_args = mock_run.call_args[0] + params = call_args[1] + assert params == {"action": "modify_contents", "prefabPath": "Assets/Prefabs/Player.prefab"} + # ============================================================================= # Material Command Tests diff --git a/Server/tests/test_manage_prefabs.py b/Server/tests/test_manage_prefabs.py index be441581c..ace42957d 100644 --- a/Server/tests/test_manage_prefabs.py +++ b/Server/tests/test_manage_prefabs.py @@ -1,8 +1,45 @@ -"""Tests for manage_prefabs tool - component_properties parameter.""" +"""Tests for manage_prefabs tool.""" +import asyncio import inspect +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest from services.tools.manage_prefabs import manage_prefabs +from services.registry import get_registered_tools + + +# ── Fixture ────────────────────────────────────────────────────────── + + +@pytest.fixture +def mock_unity(monkeypatch): + captured: dict[str, object] = {} + + async def fake_send(send_fn, unity_instance, tool_name, params): + captured["unity_instance"] = unity_instance + captured["tool_name"] = tool_name + captured["params"] = params + return {"success": True, "message": "ok"} + + monkeypatch.setattr( + "services.tools.manage_prefabs.get_unity_instance_from_context", + AsyncMock(return_value="unity-instance-1"), + ) + monkeypatch.setattr( + "services.tools.manage_prefabs.send_with_unity_instance", + fake_send, + ) + monkeypatch.setattr( + "services.tools.manage_prefabs.preflight", + AsyncMock(return_value=None), + ) + return captured + + +# ── component_properties ───────────────────────────────────────────── class TestManagePrefabsComponentProperties: @@ -21,13 +58,10 @@ def test_component_properties_parameter_is_optional(self): 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 + (t for t in get_registered_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 @@ -36,3 +70,68 @@ def test_required_params_include_modify_contents(self): from services.tools.manage_prefabs import REQUIRED_PARAMS assert "modify_contents" in REQUIRED_PARAMS assert "prefab_path" in REQUIRED_PARAMS["modify_contents"] + + +# ── delete_child ───────────────────────────────────────────────────── + + +class TestManagePrefabsDeleteChild: + """Tests for the delete_child parameter on manage_prefabs.""" + + def test_delete_child_parameter_exists(self): + """The manage_prefabs tool should have a delete_child parameter.""" + sig = inspect.signature(manage_prefabs) + assert "delete_child" in sig.parameters + + def test_delete_child_parameter_is_optional(self): + """delete_child should default to None.""" + sig = inspect.signature(manage_prefabs) + param = sig.parameters["delete_child"] + assert param.default is None + + def test_tool_description_mentions_delete_child(self): + """The tool description should mention delete_child.""" + prefab_tool = next( + (t for t in get_registered_tools() if t["name"] == "manage_prefabs"), None + ) + assert prefab_tool is not None + desc = prefab_tool.get("description") or prefab_tool.get("kwargs", {}).get("description", "") + assert "delete_child" in desc + + def test_delete_child_string_forwards_to_unity(self, mock_unity): + """A single string delete_child should be forwarded as-is.""" + result = asyncio.run( + manage_prefabs( + SimpleNamespace(), + action="modify_contents", + prefab_path="Assets/Prefabs/Test.prefab", + delete_child="Child1", + ) + ) + assert result["success"] is True + assert mock_unity["tool_name"] == "manage_prefabs" + assert mock_unity["params"]["deleteChild"] == "Child1" + + def test_delete_child_list_forwards_to_unity(self, mock_unity): + """A list of delete_child paths should be forwarded as-is.""" + result = asyncio.run( + manage_prefabs( + SimpleNamespace(), + action="modify_contents", + prefab_path="Assets/Prefabs/Test.prefab", + delete_child=["Child1", "Child2/Grandchild"], + ) + ) + assert result["success"] is True + assert mock_unity["params"]["deleteChild"] == ["Child1", "Child2/Grandchild"] + + def test_delete_child_none_omitted_from_params(self, mock_unity): + """When delete_child is None, deleteChild should not appear in params.""" + asyncio.run( + manage_prefabs( + SimpleNamespace(), + action="modify_contents", + prefab_path="Assets/Prefabs/Test.prefab", + ) + ) + assert "deleteChild" not in mock_unity["params"] 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] diff --git a/docs/guides/CLI_EXAMPLE.md b/docs/guides/CLI_EXAMPLE.md index dbddaee81..6034b5147 100644 --- a/docs/guides/CLI_EXAMPLE.md +++ b/docs/guides/CLI_EXAMPLE.md @@ -120,6 +120,9 @@ unity-mcp prefab open "Assets/Prefabs/File.prefab" unity-mcp prefab save unity-mcp prefab close unity-mcp prefab create "GameObject" --path "Assets/Prefabs" +unity-mcp prefab modify "Assets/Prefabs/File.prefab" --delete-child Child1 +unity-mcp prefab modify "Assets/Prefabs/File.prefab" --target Weapon --position "0,1,2" +unity-mcp prefab modify "Assets/Prefabs/File.prefab" --set-property "Rigidbody.mass=5" ``` **Material Operations** diff --git a/docs/guides/CLI_USAGE.md b/docs/guides/CLI_USAGE.md index 0c610503f..062dfb524 100644 --- a/docs/guides/CLI_USAGE.md +++ b/docs/guides/CLI_USAGE.md @@ -298,6 +298,13 @@ unity-mcp prefab close # Create from GameObject unity-mcp prefab create "Player" --path "Assets/Prefabs" + +# Modify prefab contents (headless, no UI) +unity-mcp prefab modify "Assets/Prefabs/Player.prefab" --target Weapon --position "0,1,2" +unity-mcp prefab modify "Assets/Prefabs/Player.prefab" --delete-child Child1 --delete-child "Turret/Barrel" +unity-mcp prefab modify "Assets/Prefabs/Player.prefab" --set-property "Rigidbody.mass=5" +unity-mcp prefab modify "Assets/Prefabs/Player.prefab" --add-component BoxCollider +unity-mcp prefab modify "Assets/Prefabs/Player.prefab" --create-child '{"name":"Spawn","primitive_type":"Sphere"}' ``` ### Asset Operations @@ -487,7 +494,7 @@ unity-mcp raw manage_packages '{"action": "list_packages"}' | `shader` | `create`, `read`, `update`, `delete` | | `editor` | `play`, `pause`, `stop`, `refresh`, `console`, `menu`, `tool`, `add-tag`, `remove-tag`, `add-layer`, `remove-layer`, `tests`, `poll-test`, `custom-tool` | | `asset` | `search`, `info`, `create`, `delete`, `duplicate`, `move`, `rename`, `import`, `mkdir` | -| `prefab` | `open`, `close`, `save`, `create` | +| `prefab` | `open`, `close`, `save`, `create`, `modify` | | `material` | `info`, `create`, `set-color`, `set-property`, `assign`, `set-renderer-color` | | `camera` | `ping`, `list`, `create`, `set-target`, `set-lens`, `set-priority`, `set-body`, `set-aim`, `set-noise`, `add-extension`, `remove-extension`, `ensure-brain`, `brain-status`, `set-blend`, `force`, `release`, `screenshot`, `screenshot-multiview` | | `graphics` | `ping`, `volume-create`, `volume-add-effect`, `volume-set-effect`, `volume-remove-effect`, `volume-info`, `volume-set-properties`, `volume-list-effects`, `volume-create-profile`, `pipeline-info`, `pipeline-settings`, `pipeline-set-quality`, `pipeline-set-settings`, `bake-start`, `bake-cancel`, `bake-status`, `bake-clear`, `bake-settings`, `bake-set-settings`, `bake-reflection-probe`, `bake-create-probes`, `bake-create-reflection`, `stats`, `stats-memory`, `stats-debug-mode`, `feature-list`, `feature-add`, `feature-remove`, `feature-configure`, `feature-reorder`, `feature-toggle`, `skybox-info`, `skybox-set-material`, `skybox-set-properties`, `skybox-set-ambient`, `skybox-set-fog`, `skybox-set-reflection`, `skybox-set-sun` | diff --git a/unity-mcp-skill/references/tools-reference.md b/unity-mcp-skill/references/tools-reference.md index 5ac557510..9bf9cb9ce 100644 --- a/unity-mcp-skill/references/tools-reference.md +++ b/unity-mcp-skill/references/tools-reference.md @@ -524,6 +524,28 @@ manage_prefabs( position=[0, 1, 0], components_to_add=["AudioSource"] ) + +# Delete child GameObjects from prefab +manage_prefabs( + action="modify_contents", + prefab_path="Assets/Prefabs/Player.prefab", + delete_child=["OldChild", "Turret/Barrel"] # single string or list +) + +# Create child GameObject in prefab +manage_prefabs( + action="modify_contents", + prefab_path="Assets/Prefabs/Player.prefab", + create_child={"name": "SpawnPoint", "primitive_type": "Sphere", "position": [0, 2, 0]} +) + +# Set component properties on prefab contents +manage_prefabs( + action="modify_contents", + prefab_path="Assets/Prefabs/Player.prefab", + target="ChildObject", + component_properties={"Rigidbody": {"mass": 5.0}, "MyScript": {"health": 100}} +) ``` ---