Files
asavschaeffer 503d938b4a fix: make manage_gameobject component properties reachable on create
Fixes #1297. At action:"create", component_properties was accepted and
coerced by the C# dispatcher (ManageGameObject.cs) but only ever consumed
by the "modify" handler, so it silently did nothing. Meanwhile the shape
"create" already reads directly out of each componentsToAdd entry
({typeName, properties}) was rejected before it reached Unity, because the
Python schema typed components_to_add as list[str].

- GameObjectComponentHelpers.cs: factor the componentProperties loop +
  error aggregation out of GameObjectModify.cs into a shared
  ApplyComponentProperties helper, so both actions apply it identically.
- GameObjectCreate.cs: call the new helper after components are added,
  destroying the partially-created object and returning the error if any
  property fails to set (matching how component-add failures are handled).
- GameObjectModify.cs: switch to the shared helper (behavior-preserving
  refactor, no functional change on the modify path).
- manage_gameobject.py: widen components_to_add to accept
  {"typeName": ..., "properties": {...}} objects alongside plain strings,
  matching what GameObjectCreate.cs already reads.
- Regenerated website/docs/reference/tools/core/manage_gameobject.md via
  tools/generate_docs_reference.py for the updated parameter docs.

Tested: Server/tests/test_manage_gameobject.py exercises the Python
contract end-to-end, including a real fastmcp/pydantic schema validation
run of the issue's exact repro payloads (confirmed the pre-fix
ValidationError reproduces on the unmodified file, and is gone after).
Added TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/
ManageGameObjectCreateTests.cs coverage for the C# side, but this was not
run against a live Editor.
2026-07-28 15:59:38 -07:00

264 lines
11 KiB
C#

#nullable disable
using System;
using System.Linq;
using MCPForUnity.Editor.Helpers;
using Newtonsoft.Json.Linq;
using UnityEditor;
using UnityEditor.SceneManagement;
using UnityEditorInternal;
using UnityEngine;
namespace MCPForUnity.Editor.Tools.GameObjects
{
internal static class GameObjectModify
{
internal static object Handle(JObject @params, JToken targetToken, string searchMethod)
{
// When setActive=true is specified, we need to search for inactive objects
// otherwise we can't find an inactive object to activate it
JObject findParams = null;
if (@params["setActive"]?.ToObject<bool?>() == true)
{
findParams = new JObject { ["searchInactive"] = true };
}
GameObject targetGo = ManageGameObjectCommon.FindObjectInternal(targetToken, searchMethod, findParams);
if (targetGo == null)
{
return new ErrorResponse($"Target GameObject ('{targetToken}') not found using method '{searchMethod ?? "default"}'.");
}
Undo.RecordObject(targetGo.transform, "Modify GameObject Transform");
Undo.RecordObject(targetGo, "Modify GameObject Properties");
bool modified = false;
string name = @params["name"]?.ToString() ?? @params["new_name"]?.ToString() ?? @params["newName"]?.ToString();
if (!string.IsNullOrEmpty(name) && targetGo.name != name)
{
// Check if we're renaming the root object of an open prefab stage
var prefabStageForRename = PrefabStageUtility.GetCurrentPrefabStage();
bool isRenamingPrefabRoot = prefabStageForRename != null &&
prefabStageForRename.prefabContentsRoot == targetGo;
if (isRenamingPrefabRoot)
{
// Rename the prefab asset file to match the new name (avoids Unity dialog)
string assetPath = prefabStageForRename.assetPath;
string directory = System.IO.Path.GetDirectoryName(assetPath);
string newAssetPath = AssetPathUtility.NormalizeSeparators(System.IO.Path.Combine(directory, name + ".prefab"));
// Only rename if the path actually changes
if (newAssetPath != assetPath)
{
// Check for collision using GUID comparison
string currentGuid = AssetDatabase.AssetPathToGUID(assetPath);
string existingGuid = AssetDatabase.AssetPathToGUID(newAssetPath);
// Collision only if there's a different asset at the new path
if (!string.IsNullOrEmpty(existingGuid) && existingGuid != currentGuid)
{
return new ErrorResponse($"Cannot rename prefab root to '{name}': a prefab already exists at '{newAssetPath}'.");
}
// Rename the asset file
string renameError = AssetDatabase.RenameAsset(assetPath, name);
if (!string.IsNullOrEmpty(renameError))
{
return new ErrorResponse($"Failed to rename prefab asset: {renameError}");
}
McpLog.Info($"[GameObjectModify] Renamed prefab asset from '{assetPath}' to '{newAssetPath}'");
}
}
targetGo.name = name;
modified = true;
}
JToken parentToken = @params["parent"];
if (parentToken != null)
{
GameObject newParentGo = ManageGameObjectCommon.FindObjectInternal(parentToken, "by_id_or_name_or_path");
if (
newParentGo == null
&& !(parentToken.Type == JTokenType.Null
|| (parentToken.Type == JTokenType.String && string.IsNullOrEmpty(parentToken.ToString())))
)
{
return new ErrorResponse($"New parent ('{parentToken}') not found.");
}
if (newParentGo != null && newParentGo.transform.IsChildOf(targetGo.transform))
{
return new ErrorResponse($"Cannot parent '{targetGo.name}' to '{newParentGo.name}', as it would create a hierarchy loop.");
}
if (targetGo.transform.parent != (newParentGo?.transform))
{
targetGo.transform.SetParent(newParentGo?.transform, true);
modified = true;
}
}
bool? setActive = @params["setActive"]?.ToObject<bool?>();
if (setActive.HasValue && targetGo.activeSelf != setActive.Value)
{
targetGo.SetActive(setActive.Value);
modified = true;
}
string tag = @params["tag"]?.ToString();
if (tag != null && targetGo.tag != tag)
{
string tagToSet = string.IsNullOrEmpty(tag) ? "Untagged" : tag;
if (tagToSet != "Untagged" && !System.Linq.Enumerable.Contains(InternalEditorUtility.tags, tagToSet))
{
McpLog.Info($"[ManageGameObject] Tag '{tagToSet}' not found. Creating it.");
try
{
InternalEditorUtility.AddTag(tagToSet);
}
catch (Exception ex)
{
return new ErrorResponse($"Failed to create tag '{tagToSet}': {ex.Message}.");
}
}
try
{
targetGo.tag = tagToSet;
modified = true;
}
catch (Exception ex)
{
return new ErrorResponse($"Failed to set tag to '{tagToSet}': {ex.Message}.");
}
}
string layerName = @params["layer"]?.ToString();
if (!string.IsNullOrEmpty(layerName))
{
int layerId = LayerMask.NameToLayer(layerName);
if (layerId == -1)
{
return new ErrorResponse($"Invalid layer specified: '{layerName}'. Use a valid layer name.");
}
if (layerId != -1 && targetGo.layer != layerId)
{
targetGo.layer = layerId;
modified = true;
}
}
bool? isStatic = @params["isStatic"]?.ToObject<bool?>();
if (isStatic.HasValue)
{
var desiredFlags = isStatic.Value ? (StaticEditorFlags)~0 : 0;
var currentFlags = GameObjectUtility.GetStaticEditorFlags(targetGo);
if (currentFlags != desiredFlags)
{
GameObjectUtility.SetStaticEditorFlags(targetGo, desiredFlags);
modified = true;
}
}
Vector3? position = VectorParsing.ParseVector3(@params["position"]);
Vector3? rotation = VectorParsing.ParseVector3(@params["rotation"]);
Vector3? scale = VectorParsing.ParseVector3(@params["scale"]);
if (position.HasValue && targetGo.transform.localPosition != position.Value)
{
targetGo.transform.localPosition = position.Value;
modified = true;
}
if (rotation.HasValue && targetGo.transform.localEulerAngles != rotation.Value)
{
targetGo.transform.localEulerAngles = rotation.Value;
modified = true;
}
if (scale.HasValue && targetGo.transform.localScale != scale.Value)
{
targetGo.transform.localScale = scale.Value;
modified = true;
}
if (@params["componentsToRemove"] is JArray componentsToRemoveArray)
{
foreach (var compToken in componentsToRemoveArray)
{
string typeName = compToken.ToString();
if (!string.IsNullOrEmpty(typeName))
{
var removeResult = GameObjectComponentHelpers.RemoveComponentInternal(targetGo, typeName);
if (removeResult != null)
return removeResult;
modified = true;
}
}
}
if (@params["componentsToAdd"] is JArray componentsToAddArrayModify)
{
foreach (var compToken in componentsToAddArrayModify)
{
string typeName = null;
JObject properties = null;
if (compToken.Type == JTokenType.String)
typeName = compToken.ToString();
else if (compToken is JObject compObj)
{
typeName = compObj["typeName"]?.ToString();
properties = compObj["properties"] as JObject;
}
if (!string.IsNullOrEmpty(typeName))
{
var addResult = GameObjectComponentHelpers.AddComponentInternal(targetGo, typeName, properties);
if (addResult != null)
return addResult;
modified = true;
}
}
}
var componentPropertiesError = GameObjectComponentHelpers.ApplyComponentProperties(
targetGo, @params["componentProperties"] as JObject, out bool componentPropertiesModified);
if (componentPropertiesError != null)
{
return componentPropertiesError;
}
if (componentPropertiesModified)
{
modified = true;
}
if (!modified)
{
return new SuccessResponse(
$"No modifications applied to GameObject '{targetGo.name}'.",
Helpers.GameObjectSerializer.GetGameObjectData(targetGo)
);
}
EditorUtility.SetDirty(targetGo);
// Mark the appropriate scene as dirty (handles both regular scenes and prefab stages)
var prefabStage = PrefabStageUtility.GetCurrentPrefabStage();
if (prefabStage != null)
{
EditorSceneManager.MarkSceneDirty(prefabStage.scene);
}
else
{
EditorSceneManager.MarkSceneDirty(EditorSceneManager.GetActiveScene());
}
return new SuccessResponse(
$"GameObject '{targetGo.name}' modified successfully.",
Helpers.GameObjectSerializer.GetGameObjectData(targetGo)
);
}
}
}