forked from CoplayDev/unity-mcp
-
Notifications
You must be signed in to change notification settings - Fork 0
🔧 Consolidate Shared Services Across MCP Tools #125
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Closed
Closed
Changes from all commits
Commits
Show all changes
13 commits
Select commit
Hold shift + click to select a range
47612b8
feat: Redesign GameObject API for better LLM ergonomics
dsarno 7c647af
feat: Add GameObject API stress tests and NL/T suite updates
dsarno fb9b336
fix: Address code review feedback
dsarno b2b3061
fix: Address more code review feedback
dsarno 707ce0f
docs: update README tools and resources lists
dsarno a85c9a5
chore: Remove accidentally committed test artifacts
dsarno 81b68fe
refactor: remove deprecated manage_gameobject actions
dsarno 6105442
refactor: consolidate shared services across MCP tools
dsarno 6f1d561
Apply code review feedback: consolidate utilities and improve compati…
dsarno 54d923b
Fix animator hash names in test fixture to match parameter names
dsarno 3fd6d68
fix(windows): improve HTTP server detection and auto-start reliability
dsarno 834acb2
fix: auto-create tags and remove deprecated manage_gameobject actions
dsarno 007951d
fix: address code review feedback
dsarno File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,308 @@ | ||
| using System; | ||
| using System.Collections.Generic; | ||
| using System.Reflection; | ||
| using Newtonsoft.Json.Linq; | ||
| using UnityEditor; | ||
| using UnityEngine; | ||
|
|
||
| namespace MCPForUnity.Editor.Helpers | ||
| { | ||
| /// <summary> | ||
| /// Low-level component operations extracted from ManageGameObject and ManageComponents. | ||
| /// Provides pure C# operations without JSON parsing or response formatting. | ||
| /// </summary> | ||
| public static class ComponentOps | ||
| { | ||
| /// <summary> | ||
| /// Adds a component to a GameObject with Undo support. | ||
| /// </summary> | ||
| /// <param name="target">The target GameObject</param> | ||
| /// <param name="componentType">The type of component to add</param> | ||
| /// <param name="error">Error message if operation fails</param> | ||
| /// <returns>The added component, or null if failed</returns> | ||
| public static Component AddComponent(GameObject target, Type componentType, out string error) | ||
| { | ||
| error = null; | ||
|
|
||
| if (target == null) | ||
| { | ||
| error = "Target GameObject is null."; | ||
| return null; | ||
| } | ||
|
|
||
| if (componentType == null || !typeof(Component).IsAssignableFrom(componentType)) | ||
| { | ||
| error = $"Type '{componentType?.Name ?? "null"}' is not a valid Component type."; | ||
| return null; | ||
| } | ||
|
|
||
| // Prevent adding duplicate Transform | ||
| if (componentType == typeof(Transform)) | ||
| { | ||
| error = "Cannot add another Transform component."; | ||
| return null; | ||
| } | ||
|
|
||
| // Check for 2D/3D physics conflicts | ||
| string conflictError = CheckPhysicsConflict(target, componentType); | ||
| if (conflictError != null) | ||
| { | ||
| error = conflictError; | ||
| return null; | ||
| } | ||
|
|
||
| try | ||
| { | ||
| Component newComponent = Undo.AddComponent(target, componentType); | ||
| if (newComponent == null) | ||
| { | ||
| error = $"Failed to add component '{componentType.Name}' to '{target.name}'. It might be disallowed."; | ||
| return null; | ||
| } | ||
|
|
||
| // Apply default values for specific component types | ||
| ApplyDefaultValues(newComponent); | ||
|
|
||
| return newComponent; | ||
| } | ||
| catch (Exception ex) | ||
| { | ||
| error = $"Error adding component '{componentType.Name}': {ex.Message}"; | ||
| return null; | ||
| } | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Removes a component from a GameObject with Undo support. | ||
| /// </summary> | ||
| /// <param name="target">The target GameObject</param> | ||
| /// <param name="componentType">The type of component to remove</param> | ||
| /// <param name="error">Error message if operation fails</param> | ||
| /// <returns>True if component was removed successfully</returns> | ||
| public static bool RemoveComponent(GameObject target, Type componentType, out string error) | ||
| { | ||
| error = null; | ||
|
|
||
| if (target == null) | ||
| { | ||
| error = "Target GameObject is null."; | ||
| return false; | ||
| } | ||
|
|
||
| if (componentType == null) | ||
| { | ||
| error = "Component type is null."; | ||
| return false; | ||
| } | ||
|
|
||
| // Prevent removing Transform | ||
| if (componentType == typeof(Transform)) | ||
| { | ||
| error = "Cannot remove Transform component."; | ||
| return false; | ||
| } | ||
|
|
||
| Component component = target.GetComponent(componentType); | ||
| if (component == null) | ||
| { | ||
| error = $"Component '{componentType.Name}' not found on '{target.name}'."; | ||
| return false; | ||
| } | ||
|
|
||
| try | ||
| { | ||
| Undo.DestroyObjectImmediate(component); | ||
| return true; | ||
| } | ||
| catch (Exception ex) | ||
| { | ||
| error = $"Error removing component '{componentType.Name}': {ex.Message}"; | ||
| return false; | ||
| } | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Sets a property value on a component using reflection. | ||
| /// </summary> | ||
| /// <param name="component">The target component</param> | ||
| /// <param name="propertyName">The property or field name</param> | ||
| /// <param name="value">The value to set (JToken)</param> | ||
| /// <param name="error">Error message if operation fails</param> | ||
| /// <returns>True if property was set successfully</returns> | ||
| public static bool SetProperty(Component component, string propertyName, JToken value, out string error) | ||
| { | ||
| error = null; | ||
|
|
||
| if (component == null) | ||
| { | ||
| error = "Component is null."; | ||
| return false; | ||
| } | ||
|
|
||
| if (string.IsNullOrEmpty(propertyName)) | ||
| { | ||
| error = "Property name is null or empty."; | ||
| return false; | ||
| } | ||
|
|
||
| Type type = component.GetType(); | ||
| BindingFlags flags = BindingFlags.Public | BindingFlags.Instance | BindingFlags.IgnoreCase; | ||
| string normalizedName = ParamCoercion.NormalizePropertyName(propertyName); | ||
|
|
||
| // Try property first - check both original and normalized names for backwards compatibility | ||
| PropertyInfo propInfo = type.GetProperty(propertyName, flags) | ||
| ?? type.GetProperty(normalizedName, flags); | ||
| if (propInfo != null && propInfo.CanWrite) | ||
| { | ||
| try | ||
| { | ||
| object convertedValue = PropertyConversion.ConvertToType(value, propInfo.PropertyType); | ||
| // Detect conversion failure: null result when input wasn't null | ||
| if (convertedValue == null && value.Type != JTokenType.Null) | ||
| { | ||
| error = $"Failed to convert value for property '{propertyName}' to type '{propInfo.PropertyType.Name}'."; | ||
| return false; | ||
| } | ||
| propInfo.SetValue(component, convertedValue); | ||
| return true; | ||
| } | ||
| catch (Exception ex) | ||
| { | ||
| error = $"Failed to set property '{propertyName}': {ex.Message}"; | ||
| return false; | ||
| } | ||
| } | ||
|
|
||
| // Try field - check both original and normalized names for backwards compatibility | ||
| FieldInfo fieldInfo = type.GetField(propertyName, flags) | ||
| ?? type.GetField(normalizedName, flags); | ||
| if (fieldInfo != null && !fieldInfo.IsInitOnly) | ||
| { | ||
| try | ||
| { | ||
| object convertedValue = PropertyConversion.ConvertToType(value, fieldInfo.FieldType); | ||
| // Detect conversion failure: null result when input wasn't null | ||
| if (convertedValue == null && value.Type != JTokenType.Null) | ||
| { | ||
| error = $"Failed to convert value for field '{propertyName}' to type '{fieldInfo.FieldType.Name}'."; | ||
| return false; | ||
| } | ||
| fieldInfo.SetValue(component, convertedValue); | ||
| return true; | ||
| } | ||
| catch (Exception ex) | ||
| { | ||
| error = $"Failed to set field '{propertyName}': {ex.Message}"; | ||
| return false; | ||
| } | ||
| } | ||
|
|
||
| // Try non-public serialized fields - check both original and normalized names | ||
| BindingFlags privateFlags = BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.IgnoreCase; | ||
| fieldInfo = type.GetField(propertyName, privateFlags) | ||
| ?? type.GetField(normalizedName, privateFlags); | ||
| if (fieldInfo != null && fieldInfo.GetCustomAttribute<SerializeField>() != null) | ||
| { | ||
| try | ||
| { | ||
| object convertedValue = PropertyConversion.ConvertToType(value, fieldInfo.FieldType); | ||
| // Detect conversion failure: null result when input wasn't null | ||
| if (convertedValue == null && value.Type != JTokenType.Null) | ||
| { | ||
| error = $"Failed to convert value for serialized field '{propertyName}' to type '{fieldInfo.FieldType.Name}'."; | ||
| return false; | ||
| } | ||
| fieldInfo.SetValue(component, convertedValue); | ||
| return true; | ||
| } | ||
| catch (Exception ex) | ||
| { | ||
| error = $"Failed to set serialized field '{propertyName}': {ex.Message}"; | ||
| return false; | ||
| } | ||
| } | ||
|
|
||
| error = $"Property or field '{propertyName}' not found on component '{type.Name}'."; | ||
| return false; | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Gets all public properties and fields from a component type. | ||
| /// </summary> | ||
| public static List<string> GetAccessibleMembers(Type componentType) | ||
| { | ||
| var members = new List<string>(); | ||
| if (componentType == null) return members; | ||
|
|
||
| BindingFlags flags = BindingFlags.Public | BindingFlags.Instance; | ||
|
|
||
| foreach (var prop in componentType.GetProperties(flags)) | ||
| { | ||
| if (prop.CanWrite && prop.GetSetMethod() != null) | ||
| { | ||
| members.Add(prop.Name); | ||
| } | ||
| } | ||
|
|
||
| foreach (var field in componentType.GetFields(flags)) | ||
| { | ||
| if (!field.IsInitOnly) | ||
| { | ||
| members.Add(field.Name); | ||
| } | ||
| } | ||
|
|
||
| // Include private [SerializeField] fields | ||
| foreach (var field in componentType.GetFields(BindingFlags.NonPublic | BindingFlags.Instance)) | ||
| { | ||
| if (field.GetCustomAttribute<SerializeField>() != null) | ||
| { | ||
| members.Add(field.Name); | ||
| } | ||
| } | ||
|
|
||
| members.Sort(); | ||
| return members; | ||
| } | ||
|
|
||
| // --- Private Helpers --- | ||
|
|
||
| private static string CheckPhysicsConflict(GameObject target, Type componentType) | ||
| { | ||
| bool isAdding2DPhysics = | ||
| typeof(Rigidbody2D).IsAssignableFrom(componentType) || | ||
| typeof(Collider2D).IsAssignableFrom(componentType); | ||
|
|
||
| bool isAdding3DPhysics = | ||
| typeof(Rigidbody).IsAssignableFrom(componentType) || | ||
| typeof(Collider).IsAssignableFrom(componentType); | ||
|
|
||
| if (isAdding2DPhysics) | ||
| { | ||
| if (target.GetComponent<Rigidbody>() != null || target.GetComponent<Collider>() != null) | ||
| { | ||
| return $"Cannot add 2D physics component '{componentType.Name}' because the GameObject '{target.name}' already has a 3D Rigidbody or Collider."; | ||
| } | ||
| } | ||
| else if (isAdding3DPhysics) | ||
| { | ||
| if (target.GetComponent<Rigidbody2D>() != null || target.GetComponent<Collider2D>() != null) | ||
| { | ||
| return $"Cannot add 3D physics component '{componentType.Name}' because the GameObject '{target.name}' already has a 2D Rigidbody or Collider."; | ||
| } | ||
| } | ||
|
|
||
| return null; | ||
| } | ||
|
|
||
| private static void ApplyDefaultValues(Component component) | ||
| { | ||
| // Default newly added Lights to Directional | ||
| if (component is Light light) | ||
| { | ||
| light.type = LightType.Directional; | ||
| } | ||
| } | ||
| } | ||
| } | ||
|
|
||
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Oops, something went wrong.
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.