Skip to content
Closed
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
56 changes: 56 additions & 0 deletions MCPForUnity/Editor/Tools/Prefabs/ManagePrefabs.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -1132,6 +1147,47 @@ private static (bool created, ErrorResponse error) CreateSingleChildInPrefab(JTo
return (true, null);
}

/// <summary>
/// Removes child GameObjects from a prefab.
/// </summary>
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
Expand Down
122 changes: 122 additions & 0 deletions Server/src/cli/commands/prefab.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""Prefab CLI commands."""

import json
import sys
import click
from typing import Optional, Any
Expand Down Expand Up @@ -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]
Comment on lines +252 to +257
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue: Handle non-numeric vector components with a clearer error instead of propagating ValueError.

If any component in value isn’t numeric, float(p.strip()) raises ValueError, which Click will show with its default messaging and stack trace. Consider wrapping the comprehension in try/except and raising click.BadParameter with a clearer message (e.g. including the original value and stating that all three components must be numeric).



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}")
7 changes: 7 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\": \"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}. "
Expand Down Expand Up @@ -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\": \"<name>\"}. Single-sprite textures auto-resolve."] | None = None,
) -> dict[str, Any]:
# Back-compat: map 'name' → 'target' for create_from_gameobject (Unity accepts both)
Expand Down Expand Up @@ -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
Expand Down
109 changes: 109 additions & 0 deletions Server/tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading