unity-mcp/MCPForUnity/Editor/Tools/ManageComponents.cs

421 lines
16 KiB
C#
Raw Normal View History

🎮 GameObject Toolset Redesign and Streamlining (#518) * feat: Redesign GameObject API for better LLM ergonomics ## New Tools - find_gameobjects: Search GameObjects, returns paginated instance IDs only - manage_components: Component lifecycle (add, remove, set_property) ## New Resources - unity://scene/gameobject/{id}: Single GameObject data (no component serialization) - unity://scene/gameobject/{id}/components: All components (paginated) - unity://scene/gameobject/{id}/component/{name}: Single component by type ## Updated - manage_scene get_hierarchy: Now includes componentTypes array - manage_gameobject: Slimmed to lifecycle only (create, modify, delete) - Legacy actions (find, get_components, etc.) log deprecation warnings ## Extracted Utilities - ParamCoercion: Centralized int/bool/float/string coercion - VectorParsing: Vector3/Vector2/Quaternion/Color parsing - GameObjectLookup: Centralized GameObject search logic ## Test Coverage - 76 new Unity EditMode tests for ManageGameObject actions - 21 new pytest tests for Python tools/resources - New NL/T CI suite for GameObject API (GO-0 to GO-5) Addresses LLM confusion with parameter overload by splitting into focused tools and read-only resources. * feat: Add static gameobject_api helper resource for UI discoverability Adds unity://scene/gameobject-api resource that: - Shows in Cursor's resource list UI (no parameters needed) - Documents the parameterized gameobject resources - Explains the workflow: find_gameobjects → read resource - Lists examples and related tools * feat: Add GO tests to main NL/T CI workflow - Adds GO pass (GO-0 to GO-5) after T pass in claude-nl-suite.yml - Includes retry logic for incomplete GO tests - Updates all regex patterns to recognize GO-* test IDs - Updates DESIRED lists to include all 21 tests (NL-0..4, T-A..J, GO-0..5) - Updates default_titles for GO tests in markdown summary - Keeps separate claude-gameobject-suite.yml for standalone runs * feat: Add GameObject API stress tests and NL/T suite updates Stress Tests (12 new tests): - BulkCreate small/medium batches - FindGameObjects pagination with by_component search - AddComponents to single object - GetComponents with full serialization - SetComponentProperties (complex Rigidbody) - Deep hierarchy creation and path lookup - GetHierarchy with large scenes - Resource read performance tests - RapidFire create-modify-delete cycles NL/T Suite Updates: - Added GO-0..GO-10 tests in nl-gameobject-suite.md - Fixed tool naming: mcp__unity__ → mcp__UnityMCP__ Other: - Fixed LongUnityScriptClaudeTest.cs compilation errors - Added reports/, .claude/local/, scripts/local-test/ to .gitignore All 254 EditMode tests pass (250 run, 4 explicit skips) * fix: Address code review feedback - ParamCoercion: Use CultureInfo.InvariantCulture for float parsing - ManageComponents: Move Transform removal check before GetComponent - ManageGameObjectFindTests: Use try-finally for LogAssert.ignoreFailingMessages - VectorParsing: Document that quaternions are not auto-normalized - gameobject.py: Prefix unused ctx parameter with underscore * fix: Address additional code review feedback - ManageComponents: Reuse GameObjectLookup.FindComponentType instead of duplicate - ManageComponents: Log warnings when SetPropertiesOnComponent fails - GameObjectLookup: Make FindComponentType public for reuse - gameobject.py: Extract _normalize_response helper to reduce duplication - gameobject.py: Add TODO comment for unused typed response classes * fix: Address more code review feedback NL/T Prompt Fixes: - nl-gameobject-suite.md: Remove non-existent list_resources/read_resource from AllowedTools - nl-gameobject-suite.md: Fix parameter names (component_type, properties) - nl-unity-suite-nl.md: Remove unused manage_editor from AllowedTools Test Fixes: - GameObjectAPIStressTests: Add null check to ToJObject helper - GameObjectAPIStressTests: Clarify AudioSource usage comment - ManageGameObjectFindTests: Use built-in 'UI' layer instead of 'Water' - LongUnityScriptClaudeTest: Clean up NL/T test artifacts (Counte42 typo, HasTarget) * docs: Add documentation for API limitations and behaviors - GameObjectLookup.SearchByPath: Document and warn that includeInactive has no effect (Unity API limitation) - ManageComponents.TrySetProperty: Document case-insensitive lookup behavior * More test fixes and tighten parameters on python tools * fix: Align test expectation with implementation error message case * docs: update README tools and resources lists - Add missing tools: manage_components, batch_execute, find_gameobjects, refresh_unity - Add missing resources: gameobject_api, editor_state_v2 - Make descriptions more concise across all tools and resources - Ensure documentation matches current MCP server functionality * fix: Address code review feedback - ParamCoercion: Use InvariantCulture for int/double parsing consistency - ManageComponents: Remove redundant Undo.RecordObject (AddComponent handles undo) - ManageScene: Replace deprecated FindObjectsOfType with FindObjectsByType - GameObjectLookup: Add explanatory comment to empty catch block - gameobject.py: Extract _validate_instance_id helper to reduce duplication - Tests: Fix assertion for instanceID (Unity IDs can be negative) * chore: Remove accidentally committed test artifacts - Remove Materials folder (40 .mat files from interactive testing) - Remove Shaders folder (5 noise shaders from testing) - Remove test scripts (Bounce*, CylinderBounce* from testing) - Remove Temp.meta and commit.sh * test: Improve delete tests to verify actual deletion - Delete_ByTag_DeletesMatchingObjects: Verify objects are actually destroyed - Delete_ByLayer_DeletesMatchingObjects: Assert deletion using Unity null check - Delete_MultipleObjectsSameName_DeletesCorrectly: Document first-match behavior - Delete_Success_ReturnsDeletedCount: Verify count value if present All tests now verify deletion occurred rather than just checking for a result. * refactor: remove deprecated manage_gameobject actions - Remove deprecated switch cases: find, get_components, get_component, add_component, remove_component, set_component_property - Remove deprecated wrapper methods (423 lines deleted from ManageGameObject.cs) - Delete ManageGameObjectFindTests.cs (tests deprecated 'find' action) - Remove deprecated test methods from ManageGameObjectTests.cs - Add GameObject resource URIs to README documentation - Add batch_execute performance tips to README, tool description, and gameobject_api resource - Enhance batch_execute description to emphasize 10-100x performance gains Total: ~1200 lines removed. New API (find_gameobjects, manage_components, resources) is the recommended path forward. * fix: Remove starlette stubs from conftest.py Starlette is now a proper dependency via the mcp package, so we don't need to stub it anymore. The real package handles all HTTP transport needs.
2026-01-07 02:13:45 +08:00
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using MCPForUnity.Editor.Helpers;
using Newtonsoft.Json.Linq;
using UnityEditor;
using UnityEngine;
namespace MCPForUnity.Editor.Tools
{
/// <summary>
/// Tool for managing components on GameObjects.
/// Actions: add, remove, set_property
///
/// This is a focused tool for component lifecycle operations.
/// For reading component data, use the unity://scene/gameobject/{id}/components resource.
/// </summary>
[McpForUnityTool("manage_components")]
public static class ManageComponents
{
/// <summary>
/// Handles the manage_components command.
/// </summary>
/// <param name="params">Command parameters</param>
/// <returns>Result of the component operation</returns>
public static object HandleCommand(JObject @params)
{
if (@params == null)
{
return new ErrorResponse("Parameters cannot be null.");
}
string action = ParamCoercion.CoerceString(@params["action"], null)?.ToLowerInvariant();
if (string.IsNullOrEmpty(action))
{
return new ErrorResponse("'action' parameter is required (add, remove, set_property).");
}
// Target resolution
JToken targetToken = @params["target"];
string searchMethod = ParamCoercion.CoerceString(@params["searchMethod"] ?? @params["search_method"], null);
if (targetToken == null)
{
return new ErrorResponse("'target' parameter is required.");
}
try
{
return action switch
{
"add" => AddComponent(@params, targetToken, searchMethod),
"remove" => RemoveComponent(@params, targetToken, searchMethod),
"set_property" => SetProperty(@params, targetToken, searchMethod),
_ => new ErrorResponse($"Unknown action: '{action}'. Supported actions: add, remove, set_property")
};
}
catch (Exception e)
{
Debug.LogError($"[ManageComponents] Action '{action}' failed: {e}");
return new ErrorResponse($"Internal error processing action '{action}': {e.Message}");
}
}
#region Action Implementations
private static object AddComponent(JObject @params, JToken targetToken, string searchMethod)
{
GameObject targetGo = FindTarget(targetToken, searchMethod);
if (targetGo == null)
{
return new ErrorResponse($"Target GameObject ('{targetToken}') not found using method '{searchMethod ?? "default"}'.");
}
string componentType = ParamCoercion.CoerceString(@params["componentType"] ?? @params["component_type"], null);
if (string.IsNullOrEmpty(componentType))
{
return new ErrorResponse("'componentType' parameter is required for 'add' action.");
}
// Resolve component type
Type type = FindComponentType(componentType);
if (type == null)
{
return new ErrorResponse($"Component type '{componentType}' not found. Use a fully-qualified name if needed.");
}
// Optional properties to set on the new component
JObject properties = @params["properties"] as JObject ?? @params["componentProperties"] as JObject;
try
{
// Undo.AddComponent creates its own undo record, no need for RecordObject
Component newComponent = Undo.AddComponent(targetGo, type);
if (newComponent == null)
{
return new ErrorResponse($"Failed to add component '{componentType}' to '{targetGo.name}'.");
}
// Set properties if provided
if (properties != null && properties.HasValues)
{
SetPropertiesOnComponent(newComponent, properties);
}
EditorUtility.SetDirty(targetGo);
return new
{
success = true,
message = $"Component '{componentType}' added to '{targetGo.name}'.",
data = new
{
instanceID = targetGo.GetInstanceID(),
componentType = type.FullName,
componentInstanceID = newComponent.GetInstanceID()
}
};
}
catch (Exception e)
{
return new ErrorResponse($"Error adding component '{componentType}': {e.Message}");
}
}
private static object RemoveComponent(JObject @params, JToken targetToken, string searchMethod)
{
GameObject targetGo = FindTarget(targetToken, searchMethod);
if (targetGo == null)
{
return new ErrorResponse($"Target GameObject ('{targetToken}') not found using method '{searchMethod ?? "default"}'.");
}
string componentType = ParamCoercion.CoerceString(@params["componentType"] ?? @params["component_type"], null);
if (string.IsNullOrEmpty(componentType))
{
return new ErrorResponse("'componentType' parameter is required for 'remove' action.");
}
// Resolve component type
Type type = FindComponentType(componentType);
if (type == null)
{
return new ErrorResponse($"Component type '{componentType}' not found.");
}
// Prevent removal of Transform (check early before GetComponent)
if (type == typeof(Transform))
{
return new ErrorResponse("Cannot remove the Transform component.");
}
Component component = targetGo.GetComponent(type);
if (component == null)
{
return new ErrorResponse($"Component '{componentType}' not found on '{targetGo.name}'.");
}
try
{
Undo.DestroyObjectImmediate(component);
EditorUtility.SetDirty(targetGo);
return new
{
success = true,
message = $"Component '{componentType}' removed from '{targetGo.name}'.",
data = new
{
instanceID = targetGo.GetInstanceID()
}
};
}
catch (Exception e)
{
return new ErrorResponse($"Error removing component '{componentType}': {e.Message}");
}
}
private static object SetProperty(JObject @params, JToken targetToken, string searchMethod)
{
GameObject targetGo = FindTarget(targetToken, searchMethod);
if (targetGo == null)
{
return new ErrorResponse($"Target GameObject ('{targetToken}') not found using method '{searchMethod ?? "default"}'.");
}
string componentType = ParamCoercion.CoerceString(@params["componentType"] ?? @params["component_type"], null);
if (string.IsNullOrEmpty(componentType))
{
return new ErrorResponse("'componentType' parameter is required for 'set_property' action.");
}
// Resolve component type
Type type = FindComponentType(componentType);
if (type == null)
{
return new ErrorResponse($"Component type '{componentType}' not found.");
}
Component component = targetGo.GetComponent(type);
if (component == null)
{
return new ErrorResponse($"Component '{componentType}' not found on '{targetGo.name}'.");
}
// Get property and value
string propertyName = ParamCoercion.CoerceString(@params["property"], null);
JToken valueToken = @params["value"];
// Support both single property or properties object
JObject properties = @params["properties"] as JObject;
if (string.IsNullOrEmpty(propertyName) && (properties == null || !properties.HasValues))
{
return new ErrorResponse("Either 'property'+'value' or 'properties' object is required for 'set_property' action.");
}
var errors = new List<string>();
try
{
Undo.RecordObject(component, $"Set property on {componentType}");
if (!string.IsNullOrEmpty(propertyName) && valueToken != null)
{
// Single property mode
var error = TrySetProperty(component, propertyName, valueToken);
if (error != null)
{
errors.Add(error);
}
}
if (properties != null && properties.HasValues)
{
// Multiple properties mode
foreach (var prop in properties.Properties())
{
var error = TrySetProperty(component, prop.Name, prop.Value);
if (error != null)
{
errors.Add(error);
}
}
}
EditorUtility.SetDirty(component);
if (errors.Count > 0)
{
return new
{
success = false,
message = $"Some properties failed to set on '{componentType}'.",
data = new
{
instanceID = targetGo.GetInstanceID(),
errors = errors
}
};
}
return new
{
success = true,
message = $"Properties set on component '{componentType}' on '{targetGo.name}'.",
data = new
{
instanceID = targetGo.GetInstanceID()
}
};
}
catch (Exception e)
{
return new ErrorResponse($"Error setting properties on component '{componentType}': {e.Message}");
}
}
#endregion
#region Helpers
private static GameObject FindTarget(JToken targetToken, string searchMethod)
{
if (targetToken == null)
return null;
// Try instance ID first
if (targetToken.Type == JTokenType.Integer)
{
int instanceId = targetToken.Value<int>();
return GameObjectLookup.FindById(instanceId);
}
string targetStr = targetToken.ToString();
// Try parsing as instance ID
if (int.TryParse(targetStr, out int parsedId))
{
var byId = GameObjectLookup.FindById(parsedId);
if (byId != null)
return byId;
}
// Use GameObjectLookup for search
return GameObjectLookup.FindByTarget(targetToken, searchMethod ?? "by_name", true);
}
/// <summary>
/// Finds a component type by name. Delegates to GameObjectLookup.FindComponentType.
/// </summary>
private static Type FindComponentType(string typeName)
{
if (string.IsNullOrEmpty(typeName))
return null;
return GameObjectLookup.FindComponentType(typeName);
}
private static void SetPropertiesOnComponent(Component component, JObject properties)
{
if (component == null || properties == null)
return;
var errors = new List<string>();
foreach (var prop in properties.Properties())
{
var error = TrySetProperty(component, prop.Name, prop.Value);
if (error != null)
errors.Add(error);
}
if (errors.Count > 0)
{
Debug.LogWarning($"[ManageComponents] Some properties failed to set on {component.GetType().Name}: {string.Join(", ", errors)}");
}
}
/// <summary>
/// Attempts to set a property or field on a component.
/// Note: Property/field lookup is case-insensitive for better usability with external callers.
/// </summary>
private static string TrySetProperty(Component component, string propertyName, JToken value)
{
if (component == null || string.IsNullOrEmpty(propertyName))
return $"Invalid component or property name";
var type = component.GetType();
// Try property first
var propInfo = type.GetProperty(propertyName, BindingFlags.Public | BindingFlags.Instance | BindingFlags.IgnoreCase);
if (propInfo != null && propInfo.CanWrite)
{
try
{
var convertedValue = ConvertValue(value, propInfo.PropertyType);
propInfo.SetValue(component, convertedValue);
return null; // Success
}
catch (Exception e)
{
Debug.LogWarning($"[ManageComponents] Failed to set property '{propertyName}': {e.Message}");
return $"Failed to set property '{propertyName}': {e.Message}";
}
}
// Try field
var fieldInfo = type.GetField(propertyName, BindingFlags.Public | BindingFlags.Instance | BindingFlags.IgnoreCase);
if (fieldInfo != null)
{
try
{
var convertedValue = ConvertValue(value, fieldInfo.FieldType);
fieldInfo.SetValue(component, convertedValue);
return null; // Success
}
catch (Exception e)
{
Debug.LogWarning($"[ManageComponents] Failed to set field '{propertyName}': {e.Message}");
return $"Failed to set field '{propertyName}': {e.Message}";
}
}
Debug.LogWarning($"[ManageComponents] Property or field '{propertyName}' not found on {type.Name}");
return $"Property '{propertyName}' not found on {type.Name}";
}
private static object ConvertValue(JToken token, Type targetType)
{
if (token == null || token.Type == JTokenType.Null)
return null;
// Handle Unity types
if (targetType == typeof(Vector3))
{
return VectorParsing.ParseVector3OrDefault(token);
}
if (targetType == typeof(Vector2))
{
return VectorParsing.ParseVector2(token) ?? Vector2.zero;
}
if (targetType == typeof(Quaternion))
{
return VectorParsing.ParseQuaternion(token) ?? Quaternion.identity;
}
if (targetType == typeof(Color))
{
return VectorParsing.ParseColor(token) ?? Color.white;
}
// Use Newtonsoft for other types
return token.ToObject(targetType);
}
#endregion
}
}