Skip to content

Commit b43ca22

Browse files
authored
Merge pull request #993 from Scriptwonder/pr-980
Pr 980
2 parents c348a3d + 53509f5 commit b43ca22

10 files changed

Lines changed: 656 additions & 6 deletions

File tree

MCPForUnity/Editor/Tools/Prefabs/ManagePrefabs.cs

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -905,6 +905,21 @@ private static (bool modified, ErrorResponse error) ApplyModificationsToPrefabOb
905905
}
906906
}
907907

908+
// Delete child GameObjects (supports single string or array of paths/names)
909+
JToken deleteChildToken = @params["deleteChild"] ?? @params["delete_child"];
910+
if (deleteChildToken != null)
911+
{
912+
var deleteResult = RemoveChildren(deleteChildToken, targetGo, prefabRoot);
913+
if (deleteResult.error != null)
914+
{
915+
return (false, deleteResult.error);
916+
}
917+
if (deleteResult.removedCount > 0)
918+
{
919+
modified = true;
920+
}
921+
}
922+
908923
// Set properties on existing components
909924
JObject componentProperties = @params["componentProperties"] as JObject ?? @params["component_properties"] as JObject;
910925
if (componentProperties != null && componentProperties.Count > 0)
@@ -1132,6 +1147,47 @@ private static (bool created, ErrorResponse error) CreateSingleChildInPrefab(JTo
11321147
return (true, null);
11331148
}
11341149

1150+
/// <summary>
1151+
/// Removes child GameObjects from a prefab.
1152+
/// </summary>
1153+
private static (int removedCount, ErrorResponse error) RemoveChildren(JToken deleteChildToken, GameObject targetGo, GameObject prefabRoot)
1154+
{
1155+
int removedCount = 0;
1156+
1157+
// Normalize to array
1158+
JArray childrenToDelete;
1159+
if (deleteChildToken is JArray arr)
1160+
{
1161+
childrenToDelete = arr;
1162+
}
1163+
else
1164+
{
1165+
childrenToDelete = new JArray { deleteChildToken };
1166+
}
1167+
1168+
foreach (var childToken in childrenToDelete)
1169+
{
1170+
string childPath = childToken.Type == JTokenType.String ? childToken.ToString() : childToken["name"]?.ToString();
1171+
if (string.IsNullOrEmpty(childPath))
1172+
{
1173+
return (removedCount, new ErrorResponse("'deleteChild'/'delete_child' entries must be a string or object with 'name' field."));
1174+
}
1175+
1176+
// Find the child to remove
1177+
Transform childToRemove = targetGo.transform.Find(childPath);
1178+
if (childToRemove == null)
1179+
{
1180+
return (removedCount, new ErrorResponse($"Child '{childPath}' not found under '{targetGo.name}'."));
1181+
}
1182+
1183+
UnityEngine.Object.DestroyImmediate(childToRemove.gameObject);
1184+
removedCount++;
1185+
McpLog.Info($"[ManagePrefabs] Removed child '{childPath}' under '{targetGo.name}' in prefab.");
1186+
}
1187+
1188+
return (removedCount, null);
1189+
}
1190+
11351191
#endregion
11361192

11371193
#region Hierarchy Builder

Server/src/cli/CLI_USAGE_GUIDE.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -560,6 +560,15 @@ unity-mcp prefab save
560560

561561
# Close prefab stage
562562
unity-mcp prefab close
563+
564+
# Modify prefab contents (headless)
565+
unity-mcp prefab modify "Assets/Prefabs/Player.prefab" --target Weapon --position "0,1,2"
566+
unity-mcp prefab modify "Assets/Prefabs/Player.prefab" --delete-child Child1 --delete-child "Turret/Barrel"
567+
unity-mcp prefab modify "Assets/Prefabs/Player.prefab" --set-property "Rigidbody.mass=5"
568+
unity-mcp prefab modify "Assets/Prefabs/Player.prefab" --add-component BoxCollider --remove-component SphereCollider
569+
unity-mcp prefab modify "Assets/Prefabs/Player.prefab" --create-child '{"name":"Spawn","primitive_type":"Sphere"}'
570+
unity-mcp prefab modify "Assets/Prefabs/Player.prefab" --name NewName --tag Player --layer UI
571+
unity-mcp prefab modify "Assets/Prefabs/Player.prefab" --inactive
563572
```
564573

565574
### UI Commands

Server/src/cli/commands/prefab.py

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
"""Prefab CLI commands."""
22

3+
import json
34
import sys
45
import click
56
from typing import Optional, Any
@@ -246,3 +247,132 @@ def create(target: str, path: str, overwrite: bool, include_inactive: bool, unli
246247
click.echo(format_output(result, config.format))
247248
if result.get("success"):
248249
print_success(f"Created prefab: {path}")
250+
251+
252+
def _parse_vector3(value: str) -> list[float]:
253+
"""Parse 'x,y,z' string to list of floats."""
254+
parts = value.split(",")
255+
if len(parts) != 3:
256+
raise click.BadParameter("Must be 'x,y,z' format")
257+
try:
258+
return [float(p.strip()) for p in parts]
259+
except ValueError as e:
260+
raise click.BadParameter(f"All components must be numeric, got: '{value}'") from e
261+
262+
263+
def _parse_property(prop_str: str) -> tuple[str, str, Any]:
264+
"""Parse 'Component.prop=value' into (component, prop, value)."""
265+
if "=" not in prop_str:
266+
raise click.BadParameter("Must be 'Component.prop=value' format")
267+
comp_prop, val_str = prop_str.split("=", 1)
268+
if "." not in comp_prop:
269+
raise click.BadParameter("Must be 'Component.prop=value' format")
270+
component, prop = comp_prop.rsplit(".", 1)
271+
if not component.strip() or not prop.strip():
272+
raise click.BadParameter(f"Component and property must be non-empty in '{comp_prop}', expected 'Component.prop=value'")
273+
274+
val_str = val_str.strip()
275+
276+
# Parse booleans
277+
if val_str.lower() == "true":
278+
parsed_value: Any = True
279+
elif val_str.lower() == "false":
280+
parsed_value = False
281+
# Parse numbers
282+
elif "." in val_str:
283+
try:
284+
parsed_value = float(val_str)
285+
except ValueError:
286+
parsed_value = val_str
287+
else:
288+
try:
289+
parsed_value = int(val_str)
290+
except ValueError:
291+
parsed_value = val_str
292+
293+
return component.strip(), prop.strip(), parsed_value
294+
295+
296+
@prefab.command("modify")
297+
@click.argument("path")
298+
@click.option("--target", "-t", help="Target object name/path within prefab (default: root)")
299+
@click.option("--position", "-p", help="New local position as 'x,y,z'")
300+
@click.option("--rotation", "-r", help="New local rotation as 'x,y,z'")
301+
@click.option("--scale", "-s", help="New local scale as 'x,y,z'")
302+
@click.option("--name", "-n", help="New name for target")
303+
@click.option("--tag", help="New tag")
304+
@click.option("--layer", help="New layer")
305+
@click.option("--active/--inactive", default=None, help="Set active state")
306+
@click.option("--parent", help="New parent object name/path")
307+
@click.option("--add-component", multiple=True, help="Component type to add (repeatable)")
308+
@click.option("--remove-component", multiple=True, help="Component type to remove (repeatable)")
309+
@click.option("--set-property", multiple=True, help="Property as 'Component.prop=value' (repeatable)")
310+
@click.option("--delete-child", multiple=True, help="Child name/path to remove (repeatable)")
311+
@click.option("--create-child", help="JSON object for child creation")
312+
@handle_unity_errors
313+
def modify(path: str, target: Optional[str], position: Optional[str], rotation: Optional[str],
314+
scale: Optional[str], name: Optional[str], tag: Optional[str], layer: Optional[str],
315+
active: Optional[bool], parent: Optional[str], add_component: tuple, remove_component: tuple,
316+
set_property: tuple, delete_child: tuple, create_child: Optional[str]):
317+
"""Modify a prefab's contents (headless, no UI).
318+
319+
\b
320+
Examples:
321+
unity-mcp prefab modify "Assets/Prefabs/Player.prefab" --delete-child Child1
322+
unity-mcp prefab modify "Assets/Prefabs/Player.prefab" --delete-child "Turret/Barrel" --delete-child Bullet
323+
unity-mcp prefab modify "Assets/Prefabs/Player.prefab" --target Weapon --position "0,1,2"
324+
unity-mcp prefab modify "Assets/Prefabs/Player.prefab" --set-property "Rigidbody.mass=5"
325+
unity-mcp prefab modify "Assets/Prefabs/Player.prefab" --create-child '{"name":"Spawn","primitive_type":"Sphere"}'
326+
"""
327+
config = get_config()
328+
329+
params: dict[str, Any] = {
330+
"action": "modify_contents",
331+
"prefabPath": path,
332+
}
333+
334+
if target:
335+
params["target"] = target
336+
if position:
337+
params["position"] = _parse_vector3(position)
338+
if rotation:
339+
params["rotation"] = _parse_vector3(rotation)
340+
if scale:
341+
params["scale"] = _parse_vector3(scale)
342+
if name:
343+
params["name"] = name
344+
if tag:
345+
params["tag"] = tag
346+
if layer:
347+
params["layer"] = layer
348+
if active is not None:
349+
params["setActive"] = active
350+
if parent:
351+
params["parent"] = parent
352+
if add_component:
353+
params["componentsToAdd"] = list(add_component)
354+
if remove_component:
355+
params["componentsToRemove"] = list(remove_component)
356+
if set_property:
357+
component_properties: dict[str, dict[str, Any]] = {}
358+
for prop in set_property:
359+
comp, name_p, val = _parse_property(prop)
360+
if comp not in component_properties:
361+
component_properties[comp] = {}
362+
component_properties[comp][name_p] = val
363+
params["componentProperties"] = component_properties
364+
if delete_child:
365+
params["deleteChild"] = list(delete_child)
366+
if create_child:
367+
try:
368+
parsed = json.loads(create_child)
369+
except json.JSONDecodeError as e:
370+
raise click.BadParameter(f"Invalid JSON for --create-child: {e}") from e
371+
if not isinstance(parsed, dict):
372+
raise click.BadParameter(f"--create-child must be a JSON object, got {type(parsed).__name__}")
373+
params["createChild"] = parsed
374+
375+
result = run_command("manage_prefabs", params, config)
376+
click.echo(format_output(result, config.format))
377+
if result.get("success"):
378+
print_success(f"Modified prefab: {path}")

Server/src/services/tools/manage_prefabs.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,9 @@
2929
"(single object or array for batch creation in one save). "
3030
"Example: create_child=[{\"name\": \"Child1\", \"primitive_type\": \"Sphere\", \"position\": [1,0,0]}, "
3131
"{\"name\": \"Nested\", \"source_prefab_path\": \"Assets/Prefabs/Bullet.prefab\", \"position\": [0,2,0]}]. "
32+
"Use delete_child parameter to remove child GameObjects from the prefab "
33+
"(single name/path or array of paths for batch deletion. "
34+
"Example: delete_child=[\"Child1\", \"Child2/Grandchild\"]). "
3235
"Use component_properties with modify_contents to set serialized fields on existing components "
3336
"(e.g. component_properties={\"Rigidbody\": {\"mass\": 5.0}, \"MyScript\": {\"health\": 100}}). "
3437
"Supports object references via {\"guid\": \"...\"}, {\"path\": \"Assets/...\"}, or {\"instanceID\": 123}. "
@@ -67,6 +70,7 @@ async def manage_prefabs(
6770
components_to_add: Annotated[list[str], "Component types to add in modify_contents."] | None = None,
6871
components_to_remove: Annotated[list[str], "Component types to remove in modify_contents."] | None = None,
6972
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,
73+
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,
7074
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,
7175
) -> dict[str, Any]:
7276
# 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 |
185189
return {"success": False, "message": err}
186190
params["createChild"] = child_params
187191

192+
if delete_child is not None:
193+
params["deleteChild"] = delete_child
194+
188195
# Send command to Unity
189196
response = await send_with_unity_instance(
190197
async_send_command_with_retry, unity_instance, "manage_prefabs", params

0 commit comments

Comments
 (0)