🔧 Clean up & Consolidate Shared Services Across MCP Tools (#519)
* feat: Redesign GameObject API for better LLM ergonomics
- find_gameobjects: Search GameObjects, returns paginated instance IDs only
- manage_components: Component lifecycle (add, remove, set_property)
- 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
- 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
- ParamCoercion: Centralized int/bool/float/string coercion
- VectorParsing: Vector3/Vector2/Quaternion/Color parsing
- GameObjectLookup: Centralized GameObject search logic
- 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 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 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: 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
* 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
* 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.
* refactor: consolidate shared services across MCP tools
Major architectural improvements:
- Create UnityJsonSerializer for shared JSON/Unity type conversion
- Create ObjectResolver for unified object resolution (GameObjects, Components, Assets)
- Create UnityTypeResolver for consolidated type resolution with caching
- Create PropertyConversion for unified JSON→Unity property conversion
- Create ComponentOps for low-level component operations
- Create Pagination helpers for standardized pagination across tools
Tool simplifications:
- ManageGameObject: Remove 68-line prefab redirect anti-pattern, delegate to helpers
- ManageAsset: Remove ~80 lines duplicate ConvertJTokenToType
- ManageScriptableObject: Remove ~40 lines duplicate ResolveType
- ManageComponents: Use ComponentOps, UnityTypeResolver (~90 lines saved)
- ManageMaterial: Standardize to SuccessResponse/ErrorResponse patterns
- FindGameObjects: Use PaginationRequest/PaginationResponse
- GameObjectLookup: FindComponentType delegates to UnityTypeResolver
Tests: 242/246 passed, 4 skipped (expected)
* Apply code review feedback: consolidate utilities and improve compatibility
Python Server:
- Extract normalize_properties() to shared utils.py (removes duplication)
- Move search_term validation before preflight() for fail-fast
- Fix manage_script.py documentation (remove incorrect 'update' reference)
- Remove stale comments in execute_menu_item.py, manage_editor.py
- Remove misleading destructiveHint from manage_shader.py
C# Unity:
- Add Vector4Converter (commonly used, was missing)
- Fix Unity 2021 compatibility: replace FindObjectsByType with FindObjectsOfType
- Add path normalization in ObjectResolver before StartsWith check
- Improve ComponentOps.SetProperty conversion error detection
- Add Undo.RecordObject in ManageComponents before property modifications
- Improve error message clarity in ManageMaterial.cs
- Add defensive error handling to stress test ToJObject helper
- Increase CI timeout thresholds for test stability
GitHub Workflows:
- Fix GO test sorting in markdown output (GO-10 now sorts after GO-9)
- Add warning logging for fragment parsing errors
* Fix animator hash names in test fixture to match parameter names
BlendXHash/BlendYHash now use 'reachX'/'reachY' to match the
actual animator parameter names.
* fix(windows): improve HTTP server detection and auto-start reliability
- Fix netstat detection on Windows by running netstat.exe directly instead
of piping through findstr (findstr returns exit code 1 when no matches,
causing false detection failures)
- Increase auto-start retry attempts (20→30) and delays (2s→3s) to handle
slow server starts during first install, version upgrades, and dev mode
- Only attempt blind connection after 20 failed detection attempts to reduce
connection error spam during server startup
- Remove verbose debug logs that were spamming the console every frame
* fix: auto-create tags and remove deprecated manage_gameobject actions
- ManageGameObject.cs: Check tag existence before setting; auto-create
undefined tags using InternalEditorUtility.AddTag() instead of relying
on exception handling (Unity logs warning, doesn't throw)
- manage_gameobject.py: Remove deprecated actions (find, get_components,
add_component, remove_component, set_component_property, get_component)
from Literal type - these are now handled by find_gameobjects and
manage_components tools
- Update test suite and unit tests to reflect new auto-create behavior
* fix: address code review feedback
Bug fixes:
- Fix searchInactive flag ignored in FindObjectsOfType (use includeInactive overload)
- Fix property lookup to try both original and normalized names for backwards compat
- Remove dead code for deprecated 'find' action validation
- Update error message to list only valid actions
Improvements:
- Add destructiveHint=True to manage_shader tool
- Limit fallback connection attempts (every 3rd attempt) to avoid spamming errors
- Consolidate PropertyConversion exception handlers to single catch block
- Add tag existence assertion and cleanup in tag auto-creation tests
Test fixes:
- Update SetComponentProperties_ContinuesAfterException log regex for new error format
- Update test_manage_gameobject_param_coercion to test valid actions only
main
parent
dbdaa546b2
commit
5511a2b8ad
|
|
@ -115,13 +115,12 @@ AllowedTools: Write,mcp__UnityMCP__manage_editor,mcp__UnityMCP__manage_gameobjec
|
|||
- Clean up: `mcp__UnityMCP__manage_gameobject(action="delete", target="GO_Test_Object")`
|
||||
- **Pass criteria**: Pagination works (cursor present when more results available)
|
||||
|
||||
### GO-10. Deprecation Warnings
|
||||
**Goal**: Verify legacy actions log deprecation warnings
|
||||
### GO-10. Removed Actions Return Error
|
||||
**Goal**: Verify legacy actions (find, get_components, etc.) return clear errors directing to new tools
|
||||
**Actions**:
|
||||
- Call legacy action: `mcp__UnityMCP__manage_gameobject(action="find", search_term="Camera", search_method="by_component")`
|
||||
- Read console using `mcp__UnityMCP__read_console` for deprecation warning
|
||||
- Verify warning mentions "find_gameobjects" as replacement
|
||||
- **Pass criteria**: Deprecation warning logged
|
||||
- Call removed action: `mcp__UnityMCP__manage_gameobject(action="find", search_term="Camera", search_method="by_component")`
|
||||
- Verify response contains error indicating action is unknown/removed
|
||||
- **Pass criteria**: Error response received (legacy actions were removed, not deprecated)
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -473,8 +473,8 @@ jobs:
|
|||
if localname(froot.tag) == 'testcase':
|
||||
suite.append(froot)
|
||||
added += 1
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
print(f"Warning: Could not parse fragment {frag}: {e}")
|
||||
|
||||
if added:
|
||||
for tc in list(suite.findall('.//testcase')):
|
||||
|
|
|
|||
|
|
@ -1235,7 +1235,12 @@ jobs:
|
|||
return (0, 999)
|
||||
if n.startswith('T-') and len(n) > 2:
|
||||
return (1, ord(n[2]))
|
||||
return (2, n)
|
||||
if n.startswith('GO-'):
|
||||
try:
|
||||
return (2, int(n.split('-')[1]))
|
||||
except:
|
||||
return (2, 999)
|
||||
return (3, n)
|
||||
|
||||
MAX_CHARS = 2000
|
||||
seen = set()
|
||||
|
|
|
|||
|
|
@ -14,6 +14,17 @@ namespace MCPForUnity.Editor.Helpers
|
|||
/// </summary>
|
||||
public static class AssetPathUtility
|
||||
{
|
||||
/// <summary>
|
||||
/// Normalizes path separators to forward slashes without modifying the path structure.
|
||||
/// Use this for non-asset paths (e.g., file system paths, relative directories).
|
||||
/// </summary>
|
||||
public static string NormalizeSeparators(string path)
|
||||
{
|
||||
if (string.IsNullOrEmpty(path))
|
||||
return path;
|
||||
return path.Replace('\\', '/');
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Normalizes a Unity asset path by ensuring forward slashes are used and that it is rooted under "Assets/".
|
||||
/// </summary>
|
||||
|
|
@ -24,7 +35,7 @@ namespace MCPForUnity.Editor.Helpers
|
|||
return path;
|
||||
}
|
||||
|
||||
path = path.Replace('\\', '/');
|
||||
path = NormalizeSeparators(path);
|
||||
if (!path.StartsWith("Assets/", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return "Assets/" + path.TrimStart('/');
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 13dead161bc4540eeb771961df437779
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
@ -282,41 +282,12 @@ namespace MCPForUnity.Editor.Helpers
|
|||
/// <summary>
|
||||
/// Finds a component type by name, searching loaded assemblies.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Delegates to UnityTypeResolver.ResolveComponent() for unified type resolution.
|
||||
/// </remarks>
|
||||
public static Type FindComponentType(string typeName)
|
||||
{
|
||||
// Try direct type lookup first
|
||||
var type = Type.GetType(typeName);
|
||||
if (type != null && typeof(Component).IsAssignableFrom(type))
|
||||
return type;
|
||||
|
||||
// Search in UnityEngine
|
||||
type = typeof(Component).Assembly.GetType($"UnityEngine.{typeName}");
|
||||
if (type != null && typeof(Component).IsAssignableFrom(type))
|
||||
return type;
|
||||
|
||||
// Search all loaded assemblies
|
||||
foreach (var assembly in AppDomain.CurrentDomain.GetAssemblies())
|
||||
{
|
||||
try
|
||||
{
|
||||
// Try exact match
|
||||
type = assembly.GetType(typeName);
|
||||
if (type != null && typeof(Component).IsAssignableFrom(type))
|
||||
return type;
|
||||
|
||||
// Try with UnityEngine prefix
|
||||
type = assembly.GetType($"UnityEngine.{typeName}");
|
||||
if (type != null && typeof(Component).IsAssignableFrom(type))
|
||||
return type;
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// Skip assemblies that can't be searched (e.g., dynamic, reflection-only)
|
||||
// This is expected for some assemblies in Unity's domain
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
return UnityTypeResolver.ResolveComponent(typeName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,201 @@
|
|||
using System;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
|
||||
namespace MCPForUnity.Editor.Helpers
|
||||
{
|
||||
/// <summary>
|
||||
/// Resolves Unity Objects by instruction (handles GameObjects, Components, Assets).
|
||||
/// Extracted from ManageGameObject to eliminate cross-tool dependencies.
|
||||
/// </summary>
|
||||
public static class ObjectResolver
|
||||
{
|
||||
/// <summary>
|
||||
/// Resolves any Unity Object by instruction.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The type of Unity Object to resolve</typeparam>
|
||||
/// <param name="instruction">JObject with "find" (required), "method" (optional), "component" (optional)</param>
|
||||
/// <returns>The resolved object, or null if not found</returns>
|
||||
public static T Resolve<T>(JObject instruction) where T : UnityEngine.Object
|
||||
{
|
||||
return Resolve(instruction, typeof(T)) as T;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves any Unity Object by instruction.
|
||||
/// </summary>
|
||||
/// <param name="instruction">JObject with "find" (required), "method" (optional), "component" (optional)</param>
|
||||
/// <param name="targetType">The type of Unity Object to resolve</param>
|
||||
/// <returns>The resolved object, or null if not found</returns>
|
||||
public static UnityEngine.Object Resolve(JObject instruction, Type targetType)
|
||||
{
|
||||
if (instruction == null)
|
||||
return null;
|
||||
|
||||
string findTerm = instruction["find"]?.ToString();
|
||||
string method = instruction["method"]?.ToString()?.ToLower();
|
||||
string componentName = instruction["component"]?.ToString();
|
||||
|
||||
if (string.IsNullOrEmpty(findTerm))
|
||||
{
|
||||
Debug.LogWarning("[ObjectResolver] Find instruction missing 'find' term.");
|
||||
return null;
|
||||
}
|
||||
|
||||
// Use a flexible default search method if none provided
|
||||
string searchMethodToUse = string.IsNullOrEmpty(method) ? "by_id_or_name_or_path" : method;
|
||||
|
||||
// --- Asset Search ---
|
||||
// Normalize path separators before checking asset paths
|
||||
string normalizedPath = AssetPathUtility.NormalizeSeparators(findTerm);
|
||||
|
||||
// If the target is an asset type, try AssetDatabase first
|
||||
if (IsAssetType(targetType) ||
|
||||
(typeof(GameObject).IsAssignableFrom(targetType) && normalizedPath.StartsWith("Assets/")))
|
||||
{
|
||||
UnityEngine.Object asset = TryLoadAsset(normalizedPath, targetType);
|
||||
if (asset != null)
|
||||
return asset;
|
||||
// If still not found, fall through to scene search
|
||||
}
|
||||
|
||||
// --- Scene Object Search ---
|
||||
GameObject foundGo = GameObjectLookup.FindByTarget(new JValue(findTerm), searchMethodToUse, includeInactive: false);
|
||||
|
||||
if (foundGo == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// Get the target object/component from the found GameObject
|
||||
if (targetType == typeof(GameObject))
|
||||
{
|
||||
return foundGo;
|
||||
}
|
||||
else if (typeof(Component).IsAssignableFrom(targetType))
|
||||
{
|
||||
Type componentToGetType = targetType;
|
||||
if (!string.IsNullOrEmpty(componentName))
|
||||
{
|
||||
Type specificCompType = GameObjectLookup.FindComponentType(componentName);
|
||||
if (specificCompType != null && typeof(Component).IsAssignableFrom(specificCompType))
|
||||
{
|
||||
componentToGetType = specificCompType;
|
||||
}
|
||||
else
|
||||
{
|
||||
Debug.LogWarning($"[ObjectResolver] Could not find component type '{componentName}'. Falling back to target type '{targetType.Name}'.");
|
||||
}
|
||||
}
|
||||
|
||||
Component foundComp = foundGo.GetComponent(componentToGetType);
|
||||
if (foundComp == null)
|
||||
{
|
||||
Debug.LogWarning($"[ObjectResolver] Found GameObject '{foundGo.name}' but could not find component of type '{componentToGetType.Name}'.");
|
||||
}
|
||||
return foundComp;
|
||||
}
|
||||
else
|
||||
{
|
||||
Debug.LogWarning($"[ObjectResolver] Find instruction handling not implemented for target type: {targetType.Name}");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Convenience method to resolve a GameObject.
|
||||
/// </summary>
|
||||
public static GameObject ResolveGameObject(JToken target, string searchMethod = null)
|
||||
{
|
||||
if (target == null)
|
||||
return null;
|
||||
|
||||
// If target is a simple value, use GameObjectLookup directly
|
||||
if (target.Type != JTokenType.Object)
|
||||
{
|
||||
return GameObjectLookup.FindByTarget(target, searchMethod ?? "by_id_or_name_or_path");
|
||||
}
|
||||
|
||||
// If target is an instruction object
|
||||
var instruction = target as JObject;
|
||||
if (instruction != null)
|
||||
{
|
||||
return Resolve<GameObject>(instruction);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Convenience method to resolve a Material.
|
||||
/// </summary>
|
||||
public static Material ResolveMaterial(string pathOrName)
|
||||
{
|
||||
if (string.IsNullOrEmpty(pathOrName))
|
||||
return null;
|
||||
|
||||
var instruction = new JObject { ["find"] = pathOrName };
|
||||
return Resolve<Material>(instruction);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Convenience method to resolve a Texture.
|
||||
/// </summary>
|
||||
public static Texture ResolveTexture(string pathOrName)
|
||||
{
|
||||
if (string.IsNullOrEmpty(pathOrName))
|
||||
return null;
|
||||
|
||||
var instruction = new JObject { ["find"] = pathOrName };
|
||||
return Resolve<Texture>(instruction);
|
||||
}
|
||||
|
||||
// --- Private Helpers ---
|
||||
|
||||
private static bool IsAssetType(Type type)
|
||||
{
|
||||
return typeof(Material).IsAssignableFrom(type) ||
|
||||
typeof(Texture).IsAssignableFrom(type) ||
|
||||
typeof(ScriptableObject).IsAssignableFrom(type) ||
|
||||
type.FullName?.StartsWith("UnityEngine.U2D") == true ||
|
||||
typeof(AudioClip).IsAssignableFrom(type) ||
|
||||
typeof(AnimationClip).IsAssignableFrom(type) ||
|
||||
typeof(Font).IsAssignableFrom(type) ||
|
||||
typeof(Shader).IsAssignableFrom(type) ||
|
||||
typeof(ComputeShader).IsAssignableFrom(type);
|
||||
}
|
||||
|
||||
private static UnityEngine.Object TryLoadAsset(string findTerm, Type targetType)
|
||||
{
|
||||
// Try loading directly by path first
|
||||
UnityEngine.Object asset = AssetDatabase.LoadAssetAtPath(findTerm, targetType);
|
||||
if (asset != null)
|
||||
return asset;
|
||||
|
||||
// Try generic load if type-specific failed
|
||||
asset = AssetDatabase.LoadAssetAtPath<UnityEngine.Object>(findTerm);
|
||||
if (asset != null && targetType.IsAssignableFrom(asset.GetType()))
|
||||
return asset;
|
||||
|
||||
// Try finding by name/type using FindAssets
|
||||
string searchFilter = $"t:{targetType.Name} {System.IO.Path.GetFileNameWithoutExtension(findTerm)}";
|
||||
string[] guids = AssetDatabase.FindAssets(searchFilter);
|
||||
|
||||
if (guids.Length == 1)
|
||||
{
|
||||
asset = AssetDatabase.LoadAssetAtPath(AssetDatabase.GUIDToAssetPath(guids[0]), targetType);
|
||||
if (asset != null)
|
||||
return asset;
|
||||
}
|
||||
else if (guids.Length > 1)
|
||||
{
|
||||
Debug.LogWarning($"[ObjectResolver] Ambiguous asset find: Found {guids.Length} assets matching filter '{searchFilter}'. Provide a full path or unique name.");
|
||||
return null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
fileFormatVersion: 2
|
||||
guid: ad678f7b0a2e6458bbdb38a15d857acf
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
@ -0,0 +1,149 @@
|
|||
using System.Collections.Generic;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
|
||||
namespace MCPForUnity.Editor.Helpers
|
||||
{
|
||||
/// <summary>
|
||||
/// Standard pagination request for all paginated tool operations.
|
||||
/// Provides consistent handling of page_size/pageSize and cursor/page_number parameters.
|
||||
/// </summary>
|
||||
public class PaginationRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// Number of items per page. Default is 50.
|
||||
/// </summary>
|
||||
public int PageSize { get; set; } = 50;
|
||||
|
||||
/// <summary>
|
||||
/// 0-based cursor position for the current page.
|
||||
/// </summary>
|
||||
public int Cursor { get; set; } = 0;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a PaginationRequest from JObject parameters.
|
||||
/// Accepts both snake_case and camelCase parameter names for flexibility.
|
||||
/// Converts 1-based page_number to 0-based cursor if needed.
|
||||
/// </summary>
|
||||
public static PaginationRequest FromParams(JObject @params, int defaultPageSize = 50)
|
||||
{
|
||||
if (@params == null)
|
||||
return new PaginationRequest { PageSize = defaultPageSize };
|
||||
|
||||
// Accept both page_size and pageSize
|
||||
int pageSize = ParamCoercion.CoerceInt(
|
||||
@params["page_size"] ?? @params["pageSize"],
|
||||
defaultPageSize
|
||||
);
|
||||
|
||||
// Accept both cursor (0-based) and page_number (convert 1-based to 0-based)
|
||||
var cursorToken = @params["cursor"];
|
||||
var pageNumberToken = @params["page_number"] ?? @params["pageNumber"];
|
||||
|
||||
int cursor;
|
||||
if (cursorToken != null)
|
||||
{
|
||||
cursor = ParamCoercion.CoerceInt(cursorToken, 0);
|
||||
}
|
||||
else if (pageNumberToken != null)
|
||||
{
|
||||
// Convert 1-based page_number to 0-based cursor
|
||||
int pageNumber = ParamCoercion.CoerceInt(pageNumberToken, 1);
|
||||
cursor = (pageNumber - 1) * pageSize;
|
||||
if (cursor < 0) cursor = 0;
|
||||
}
|
||||
else
|
||||
{
|
||||
cursor = 0;
|
||||
}
|
||||
|
||||
return new PaginationRequest
|
||||
{
|
||||
PageSize = pageSize > 0 ? pageSize : defaultPageSize,
|
||||
Cursor = cursor
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Standard pagination response for all paginated tool operations.
|
||||
/// Provides consistent response structure across all tools.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The type of items in the paginated list</typeparam>
|
||||
public class PaginationResponse<T>
|
||||
{
|
||||
/// <summary>
|
||||
/// The items on the current page.
|
||||
/// </summary>
|
||||
[JsonProperty("items")]
|
||||
public List<T> Items { get; set; } = new List<T>();
|
||||
|
||||
/// <summary>
|
||||
/// The cursor position for the current page (0-based).
|
||||
/// </summary>
|
||||
[JsonProperty("cursor")]
|
||||
public int Cursor { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The cursor for the next page, or null if this is the last page.
|
||||
/// </summary>
|
||||
[JsonProperty("nextCursor")]
|
||||
public int? NextCursor { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Total number of items across all pages.
|
||||
/// </summary>
|
||||
[JsonProperty("totalCount")]
|
||||
public int TotalCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of items per page.
|
||||
/// </summary>
|
||||
[JsonProperty("pageSize")]
|
||||
public int PageSize { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether there are more items after this page.
|
||||
/// </summary>
|
||||
[JsonProperty("hasMore")]
|
||||
public bool HasMore => NextCursor.HasValue;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a PaginationResponse from a full list of items and pagination parameters.
|
||||
/// </summary>
|
||||
/// <param name="allItems">The full list of items to paginate</param>
|
||||
/// <param name="request">The pagination request parameters</param>
|
||||
/// <returns>A paginated response with the appropriate slice of items</returns>
|
||||
public static PaginationResponse<T> Create(IList<T> allItems, PaginationRequest request)
|
||||
{
|
||||
int totalCount = allItems.Count;
|
||||
int cursor = request.Cursor;
|
||||
int pageSize = request.PageSize;
|
||||
|
||||
// Clamp cursor to valid range
|
||||
if (cursor < 0) cursor = 0;
|
||||
if (cursor > totalCount) cursor = totalCount;
|
||||
|
||||
// Get the page of items
|
||||
var items = new List<T>();
|
||||
int endIndex = System.Math.Min(cursor + pageSize, totalCount);
|
||||
for (int i = cursor; i < endIndex; i++)
|
||||
{
|
||||
items.Add(allItems[i]);
|
||||
}
|
||||
|
||||
// Calculate next cursor
|
||||
int? nextCursor = endIndex < totalCount ? endIndex : (int?)null;
|
||||
|
||||
return new PaginationResponse<T>
|
||||
{
|
||||
Items = items,
|
||||
Cursor = cursor,
|
||||
NextCursor = nextCursor,
|
||||
TotalCount = totalCount,
|
||||
PageSize = pageSize
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 745564d5894d74c0ca24db39c77bab2c
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
@ -155,6 +155,48 @@ namespace MCPForUnity.Editor.Helpers
|
|||
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Normalizes a property name by removing separators and converting to camelCase.
|
||||
/// Handles common naming variations from LLMs and humans.
|
||||
/// Examples:
|
||||
/// "Use Gravity" → "useGravity"
|
||||
/// "is_kinematic" → "isKinematic"
|
||||
/// "max-angular-velocity" → "maxAngularVelocity"
|
||||
/// "Angular Drag" → "angularDrag"
|
||||
/// </summary>
|
||||
/// <param name="input">The property name to normalize</param>
|
||||
/// <returns>The normalized camelCase property name</returns>
|
||||
public static string NormalizePropertyName(string input)
|
||||
{
|
||||
if (string.IsNullOrEmpty(input))
|
||||
return input;
|
||||
|
||||
// Split on common separators: space, underscore, dash
|
||||
var parts = input.Split(new[] { ' ', '_', '-' }, StringSplitOptions.RemoveEmptyEntries);
|
||||
if (parts.Length == 0)
|
||||
return input;
|
||||
|
||||
// First word is lowercase, subsequent words are Title case (camelCase)
|
||||
var sb = new System.Text.StringBuilder();
|
||||
for (int i = 0; i < parts.Length; i++)
|
||||
{
|
||||
string part = parts[i];
|
||||
if (i == 0)
|
||||
{
|
||||
// First word: all lowercase
|
||||
sb.Append(part.ToLowerInvariant());
|
||||
}
|
||||
else
|
||||
{
|
||||
// Subsequent words: capitalize first letter, lowercase rest
|
||||
sb.Append(char.ToUpperInvariant(part[0]));
|
||||
if (part.Length > 1)
|
||||
sb.Append(part.Substring(1).ToLowerInvariant());
|
||||
}
|
||||
}
|
||||
return sb.ToString();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,92 @@
|
|||
using System;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
|
||||
namespace MCPForUnity.Editor.Helpers
|
||||
{
|
||||
/// <summary>
|
||||
/// Unified property conversion from JSON to Unity types.
|
||||
/// Uses UnityJsonSerializer for consistent type handling.
|
||||
/// </summary>
|
||||
public static class PropertyConversion
|
||||
{
|
||||
/// <summary>
|
||||
/// Converts a JToken to the specified target type using Unity type converters.
|
||||
/// </summary>
|
||||
/// <param name="token">The JSON token to convert</param>
|
||||
/// <param name="targetType">The target type to convert to</param>
|
||||
/// <returns>The converted object, or null if conversion fails</returns>
|
||||
public static object ConvertToType(JToken token, Type targetType)
|
||||
{
|
||||
if (token == null || token.Type == JTokenType.Null)
|
||||
{
|
||||
if (targetType.IsValueType && Nullable.GetUnderlyingType(targetType) == null)
|
||||
{
|
||||
Debug.LogWarning($"[PropertyConversion] Cannot assign null to non-nullable value type {targetType.Name}. Returning default value.");
|
||||
return Activator.CreateInstance(targetType);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// Use the shared Unity serializer with custom converters
|
||||
return token.ToObject(targetType, UnityJsonSerializer.Instance);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Debug.LogError($"Error converting token to {targetType.FullName}: {ex.Message}\nToken: {token.ToString(Formatting.None)}");
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tries to convert a JToken to the specified target type.
|
||||
/// Returns null and logs warning on failure (does not throw).
|
||||
/// </summary>
|
||||
public static object TryConvertToType(JToken token, Type targetType)
|
||||
{
|
||||
try
|
||||
{
|
||||
return ConvertToType(token, targetType);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generic version of ConvertToType.
|
||||
/// </summary>
|
||||
public static T ConvertTo<T>(JToken token)
|
||||
{
|
||||
return (T)ConvertToType(token, typeof(T));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts a JToken to a Unity asset by loading from path.
|
||||
/// </summary>
|
||||
/// <param name="token">JToken containing asset path</param>
|
||||
/// <param name="targetType">Expected asset type</param>
|
||||
/// <returns>The loaded asset, or null if not found</returns>
|
||||
public static UnityEngine.Object LoadAssetFromToken(JToken token, Type targetType)
|
||||
{
|
||||
if (token == null || token.Type != JTokenType.String)
|
||||
return null;
|
||||
|
||||
string assetPath = AssetPathUtility.SanitizeAssetPath(token.ToString());
|
||||
UnityEngine.Object loadedAsset = AssetDatabase.LoadAssetAtPath(assetPath, targetType);
|
||||
|
||||
if (loadedAsset == null)
|
||||
{
|
||||
Debug.LogWarning($"[PropertyConversion] Could not load asset of type {targetType.Name} from path: {assetPath}");
|
||||
}
|
||||
|
||||
return loadedAsset;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 4b4187d5b338a453fbe0baceaeea6bcd
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
using System.Collections.Generic;
|
||||
using Newtonsoft.Json;
|
||||
using MCPForUnity.Runtime.Serialization;
|
||||
|
||||
namespace MCPForUnity.Editor.Helpers
|
||||
{
|
||||
/// <summary>
|
||||
/// Shared JsonSerializer with Unity type converters.
|
||||
/// Extracted from ManageGameObject to eliminate cross-tool dependencies.
|
||||
/// </summary>
|
||||
public static class UnityJsonSerializer
|
||||
{
|
||||
/// <summary>
|
||||
/// Shared JsonSerializer instance with converters for Unity types.
|
||||
/// Use this for all JToken-to-Unity-type conversions.
|
||||
/// </summary>
|
||||
public static readonly JsonSerializer Instance = JsonSerializer.Create(new JsonSerializerSettings
|
||||
{
|
||||
Converters = new List<JsonConverter>
|
||||
{
|
||||
new Vector2Converter(),
|
||||
new Vector3Converter(),
|
||||
new Vector4Converter(),
|
||||
new QuaternionConverter(),
|
||||
new ColorConverter(),
|
||||
new RectConverter(),
|
||||
new BoundsConverter(),
|
||||
new UnityEngineObjectConverter()
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 24d94c9c030bd4ff1ab208c748f26b01
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
@ -0,0 +1,217 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using UnityEngine;
|
||||
#if UNITY_EDITOR
|
||||
using UnityEditor;
|
||||
using UnityEditor.Compilation;
|
||||
#endif
|
||||
|
||||
namespace MCPForUnity.Editor.Helpers
|
||||
{
|
||||
/// <summary>
|
||||
/// Unified type resolution for Unity types (Components, ScriptableObjects, etc.).
|
||||
/// Extracted from ComponentResolver in ManageGameObject and ResolveType in ManageScriptableObject.
|
||||
/// Features: caching, prioritizes Player assemblies over Editor assemblies, uses TypeCache.
|
||||
/// </summary>
|
||||
public static class UnityTypeResolver
|
||||
{
|
||||
private static readonly Dictionary<string, Type> CacheByFqn = new(StringComparer.Ordinal);
|
||||
private static readonly Dictionary<string, Type> CacheByName = new(StringComparer.Ordinal);
|
||||
|
||||
/// <summary>
|
||||
/// Resolves a type by name, with optional base type constraint.
|
||||
/// Caches results for performance. Prefers runtime assemblies over Editor assemblies.
|
||||
/// </summary>
|
||||
/// <param name="typeName">The short name or fully-qualified name of the type</param>
|
||||
/// <param name="type">The resolved type, or null if not found</param>
|
||||
/// <param name="error">Error message if resolution failed</param>
|
||||
/// <param name="requiredBaseType">Optional base type constraint (e.g., typeof(Component))</param>
|
||||
/// <returns>True if type was resolved successfully</returns>
|
||||
public static bool TryResolve(string typeName, out Type type, out string error, Type requiredBaseType = null)
|
||||
{
|
||||
error = string.Empty;
|
||||
type = null;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(typeName))
|
||||
{
|
||||
error = "Type name cannot be null or empty";
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check caches
|
||||
if (CacheByFqn.TryGetValue(typeName, out type) && PassesConstraint(type, requiredBaseType))
|
||||
return true;
|
||||
if (!typeName.Contains(".") && CacheByName.TryGetValue(typeName, out type) && PassesConstraint(type, requiredBaseType))
|
||||
return true;
|
||||
|
||||
// Try direct Type.GetType
|
||||
type = Type.GetType(typeName, throwOnError: false);
|
||||
if (type != null && PassesConstraint(type, requiredBaseType))
|
||||
{
|
||||
Cache(type);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Search loaded assemblies (prefer Player assemblies)
|
||||
var candidates = FindCandidates(typeName, requiredBaseType);
|
||||
if (candidates.Count == 1)
|
||||
{
|
||||
type = candidates[0];
|
||||
Cache(type);
|
||||
return true;
|
||||
}
|
||||
if (candidates.Count > 1)
|
||||
{
|
||||
error = FormatAmbiguityError(typeName, candidates);
|
||||
type = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
#if UNITY_EDITOR
|
||||
// Last resort: TypeCache (fast index)
|
||||
if (requiredBaseType != null)
|
||||
{
|
||||
var tc = TypeCache.GetTypesDerivedFrom(requiredBaseType)
|
||||
.Where(t => NamesMatch(t, typeName));
|
||||
candidates = PreferPlayer(tc).ToList();
|
||||
if (candidates.Count == 1)
|
||||
{
|
||||
type = candidates[0];
|
||||
Cache(type);
|
||||
return true;
|
||||
}
|
||||
if (candidates.Count > 1)
|
||||
{
|
||||
error = FormatAmbiguityError(typeName, candidates);
|
||||
type = null;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
error = $"Type '{typeName}' not found in loaded runtime assemblies. " +
|
||||
"Use a fully-qualified name (Namespace.TypeName) and ensure the script compiled.";
|
||||
type = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Convenience method to resolve a Component type.
|
||||
/// </summary>
|
||||
public static Type ResolveComponent(string typeName)
|
||||
{
|
||||
if (TryResolve(typeName, out Type type, out _, typeof(Component)))
|
||||
return type;
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Convenience method to resolve a ScriptableObject type.
|
||||
/// </summary>
|
||||
public static Type ResolveScriptableObject(string typeName)
|
||||
{
|
||||
if (TryResolve(typeName, out Type type, out _, typeof(ScriptableObject)))
|
||||
return type;
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Convenience method to resolve any type without constraints.
|
||||
/// </summary>
|
||||
public static Type ResolveAny(string typeName)
|
||||
{
|
||||
if (TryResolve(typeName, out Type type, out _, null))
|
||||
return type;
|
||||
return null;
|
||||
}
|
||||
|
||||
// --- Private Helpers ---
|
||||
|
||||
private static bool PassesConstraint(Type type, Type requiredBaseType)
|
||||
{
|
||||
if (type == null) return false;
|
||||
if (requiredBaseType == null) return true;
|
||||
return requiredBaseType.IsAssignableFrom(type);
|
||||
}
|
||||
|
||||
private static bool NamesMatch(Type t, string query) =>
|
||||
t.Name.Equals(query, StringComparison.Ordinal) ||
|
||||
(t.FullName?.Equals(query, StringComparison.Ordinal) ?? false);
|
||||
|
||||
private static void Cache(Type t)
|
||||
{
|
||||
if (t == null) return;
|
||||
if (t.FullName != null) CacheByFqn[t.FullName] = t;
|
||||
CacheByName[t.Name] = t;
|
||||
}
|
||||
|
||||
private static List<Type> FindCandidates(string query, Type requiredBaseType)
|
||||
{
|
||||
bool isShort = !query.Contains('.');
|
||||
var loaded = AppDomain.CurrentDomain.GetAssemblies();
|
||||
|
||||
#if UNITY_EDITOR
|
||||
// Names of Player (runtime) script assemblies
|
||||
var playerAsmNames = new HashSet<string>(
|
||||
CompilationPipeline.GetAssemblies(AssembliesType.Player).Select(a => a.name),
|
||||
StringComparer.Ordinal);
|
||||
|
||||
var playerAsms = loaded.Where(a => playerAsmNames.Contains(a.GetName().Name));
|
||||
var editorAsms = loaded.Except(playerAsms);
|
||||
#else
|
||||
var playerAsms = loaded;
|
||||
var editorAsms = Array.Empty<System.Reflection.Assembly>();
|
||||
#endif
|
||||
|
||||
Func<Type, bool> match = isShort
|
||||
? (t => t.Name.Equals(query, StringComparison.Ordinal))
|
||||
: (t => t.FullName?.Equals(query, StringComparison.Ordinal) ?? false);
|
||||
|
||||
var fromPlayer = playerAsms.SelectMany(SafeGetTypes)
|
||||
.Where(t => PassesConstraint(t, requiredBaseType))
|
||||
.Where(match);
|
||||
var fromEditor = editorAsms.SelectMany(SafeGetTypes)
|
||||
.Where(t => PassesConstraint(t, requiredBaseType))
|
||||
.Where(match);
|
||||
|
||||
// Prefer Player over Editor
|
||||
var candidates = fromPlayer.ToList();
|
||||
if (candidates.Count == 0)
|
||||
candidates = fromEditor.ToList();
|
||||
|
||||
return candidates;
|
||||
}
|
||||
|
||||
private static IEnumerable<Type> SafeGetTypes(System.Reflection.Assembly assembly)
|
||||
{
|
||||
try { return assembly.GetTypes(); }
|
||||
catch (ReflectionTypeLoadException rtle) { return rtle.Types.Where(t => t != null); }
|
||||
catch { return Enumerable.Empty<Type>(); }
|
||||
}
|
||||
|
||||
private static IEnumerable<Type> PreferPlayer(IEnumerable<Type> types)
|
||||
{
|
||||
#if UNITY_EDITOR
|
||||
var playerAsmNames = new HashSet<string>(
|
||||
CompilationPipeline.GetAssemblies(AssembliesType.Player).Select(a => a.name),
|
||||
StringComparer.Ordinal);
|
||||
|
||||
var list = types.ToList();
|
||||
var fromPlayer = list.Where(t => playerAsmNames.Contains(t.Assembly.GetName().Name)).ToList();
|
||||
return fromPlayer.Count > 0 ? fromPlayer : list;
|
||||
#else
|
||||
return types;
|
||||
#endif
|
||||
}
|
||||
|
||||
private static string FormatAmbiguityError(string query, List<Type> candidates)
|
||||
{
|
||||
var names = string.Join(", ", candidates.Take(5).Select(t => t.FullName));
|
||||
if (candidates.Count > 5) names += $" ... ({candidates.Count - 5} more)";
|
||||
return $"Ambiguous type reference '{query}'. Found {candidates.Count} matches: [{names}]. Use a fully-qualified name.";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 2cdf06f869b124741af31f27b25742db
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
@ -945,17 +945,29 @@ namespace MCPForUnity.Editor.Services
|
|||
|
||||
if (Application.platform == RuntimePlatform.WindowsEditor)
|
||||
{
|
||||
// netstat -ano | findstr :<port>
|
||||
success = ExecPath.TryRun("cmd.exe", $"/c netstat -ano | findstr :{port}", Application.dataPath, out stdout, out stderr);
|
||||
if (success && !string.IsNullOrEmpty(stdout))
|
||||
// Run netstat -ano directly (without findstr) and filter in C#.
|
||||
// Using findstr in a pipe causes the entire command to return exit code 1 when no matches are found,
|
||||
// which ExecPath.TryRun interprets as failure. Running netstat alone gives us exit code 0 on success.
|
||||
success = ExecPath.TryRun("netstat.exe", "-ano", Application.dataPath, out stdout, out stderr);
|
||||
|
||||
// Process stdout regardless of success flag - netstat might still produce valid output
|
||||
if (!string.IsNullOrEmpty(stdout))
|
||||
{
|
||||
string portSuffix = $":{port}";
|
||||
var lines = stdout.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries);
|
||||
foreach (var line in lines)
|
||||
{
|
||||
if (line.Contains("LISTENING"))
|
||||
// Windows netstat format: Proto Local Address Foreign Address State PID
|
||||
// Example: TCP 0.0.0.0:8080 0.0.0.0:0 LISTENING 12345
|
||||
if (line.Contains("LISTENING") && line.Contains(portSuffix))
|
||||
{
|
||||
var parts = line.Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries);
|
||||
if (parts.Length > 0 && int.TryParse(parts[parts.Length - 1], out int pid))
|
||||
// Verify the local address column actually ends with :{port}
|
||||
// parts[0] = Proto (TCP), parts[1] = Local Address, parts[2] = Foreign Address, parts[3] = State, parts[4] = PID
|
||||
if (parts.Length >= 5)
|
||||
{
|
||||
string localAddr = parts[1];
|
||||
if (localAddr.EndsWith(portSuffix) && int.TryParse(parts[parts.Length - 1], out int pid))
|
||||
{
|
||||
results.Add(pid);
|
||||
}
|
||||
|
|
@ -963,6 +975,7 @@ namespace MCPForUnity.Editor.Services
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// lsof: only return LISTENers (avoids capturing random clients)
|
||||
|
|
@ -1002,16 +1015,44 @@ namespace MCPForUnity.Editor.Services
|
|||
{
|
||||
try
|
||||
{
|
||||
bool debugLogs = false;
|
||||
try { debugLogs = EditorPrefs.GetBool(EditorPrefKeys.DebugLogs, false); } catch { }
|
||||
|
||||
// Windows best-effort: tasklist /FI "PID eq X"
|
||||
// Windows best-effort: First check process name with tasklist, then try to get command line with wmic
|
||||
if (Application.platform == RuntimePlatform.WindowsEditor)
|
||||
{
|
||||
ExecPath.TryRun("cmd.exe", $"/c tasklist /FI \"PID eq {pid}\"", Application.dataPath, out var stdout, out var stderr, 5000);
|
||||
string combined = ((stdout ?? string.Empty) + "\n" + (stderr ?? string.Empty)).ToLowerInvariant();
|
||||
// Common process names: python.exe, uv.exe, uvx.exe
|
||||
return combined.Contains("python") || combined.Contains("uvx") || combined.Contains("uv.exe") || combined.Contains("uvx.exe");
|
||||
// Step 1: Check if process name matches known server executables
|
||||
ExecPath.TryRun("cmd.exe", $"/c tasklist /FI \"PID eq {pid}\"", Application.dataPath, out var tasklistOut, out var tasklistErr, 5000);
|
||||
string tasklistCombined = ((tasklistOut ?? string.Empty) + "\n" + (tasklistErr ?? string.Empty)).ToLowerInvariant();
|
||||
|
||||
// Check for common process names
|
||||
bool isPythonOrUv = tasklistCombined.Contains("python") || tasklistCombined.Contains("uvx") || tasklistCombined.Contains("uv.exe");
|
||||
if (!isPythonOrUv)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Step 2: Try to get command line with wmic for better validation
|
||||
ExecPath.TryRun("cmd.exe", $"/c wmic process where \"ProcessId={pid}\" get CommandLine /value", Application.dataPath, out var wmicOut, out var wmicErr, 5000);
|
||||
string wmicCombined = ((wmicOut ?? string.Empty) + "\n" + (wmicErr ?? string.Empty)).ToLowerInvariant();
|
||||
string wmicCompact = NormalizeForMatch(wmicOut ?? string.Empty);
|
||||
|
||||
// If we can see the command line, validate it's our server
|
||||
if (!string.IsNullOrEmpty(wmicCombined) && wmicCombined.Contains("commandline="))
|
||||
{
|
||||
bool mentionsMcp = wmicCompact.Contains("mcp-for-unity")
|
||||
|| wmicCompact.Contains("mcp_for_unity")
|
||||
|| wmicCompact.Contains("mcpforunity")
|
||||
|| wmicCompact.Contains("mcpforunityserver");
|
||||
bool mentionsTransport = wmicCompact.Contains("--transporthttp") || (wmicCompact.Contains("--transport") && wmicCompact.Contains("http"));
|
||||
bool mentionsUvicorn = wmicCombined.Contains("uvicorn");
|
||||
|
||||
if (mentionsMcp || mentionsTransport || mentionsUvicorn)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to just checking for python/uv processes if wmic didn't give us details
|
||||
// This is less precise but necessary for cases where wmic access is restricted
|
||||
return isPythonOrUv;
|
||||
}
|
||||
|
||||
// macOS/Linux: ps -p pid -ww -o comm= -o args=
|
||||
|
|
@ -1027,7 +1068,6 @@ namespace MCPForUnity.Editor.Services
|
|||
string sCompact = NormalizeForMatch(raw);
|
||||
if (!string.IsNullOrEmpty(s))
|
||||
{
|
||||
|
||||
bool mentionsMcp = sCompact.Contains("mcp-for-unity")
|
||||
|| sCompact.Contains("mcp_for_unity")
|
||||
|| sCompact.Contains("mcpforunity");
|
||||
|
|
@ -1058,15 +1098,6 @@ namespace MCPForUnity.Editor.Services
|
|||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (debugLogs)
|
||||
{
|
||||
LogStopDiagnosticsOnce(pid, $"ps='{TrimForLog(s)}' uvx={mentionsUvx} uv={mentionsUv} py={mentionsPython} uvicorn={mentionsUvicorn} mcp={mentionsMcp} transportHttp={mentionsTransport}");
|
||||
}
|
||||
}
|
||||
else if (debugLogs)
|
||||
{
|
||||
LogStopDiagnosticsOnce(pid, "ps output was empty (could not classify process).");
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
|
|
|
|||
|
|
@ -37,41 +37,30 @@ namespace MCPForUnity.Editor.Tools
|
|||
return new ErrorResponse("'searchTerm' or 'target' parameter is required.");
|
||||
}
|
||||
|
||||
// Pagination parameters
|
||||
int pageSize = ParamCoercion.CoerceInt(@params["pageSize"] ?? @params["page_size"], 50);
|
||||
int cursor = ParamCoercion.CoerceInt(@params["cursor"], 0);
|
||||
// Pagination parameters using standard PaginationRequest
|
||||
var pagination = PaginationRequest.FromParams(@params, defaultPageSize: 50);
|
||||
pagination.PageSize = Mathf.Clamp(pagination.PageSize, 1, 500);
|
||||
|
||||
// Search options
|
||||
bool includeInactive = ParamCoercion.CoerceBool(@params["includeInactive"] ?? @params["searchInactive"] ?? @params["include_inactive"], false);
|
||||
|
||||
// Validate pageSize bounds
|
||||
pageSize = Mathf.Clamp(pageSize, 1, 500);
|
||||
|
||||
try
|
||||
{
|
||||
// Get all matching instance IDs
|
||||
var allIds = GameObjectLookup.SearchGameObjects(searchMethod, searchTerm, includeInactive, 0);
|
||||
int totalCount = allIds.Count;
|
||||
|
||||
// Apply pagination
|
||||
var pagedIds = allIds.Skip(cursor).Take(pageSize).ToList();
|
||||
// Use standard pagination response
|
||||
var paginatedResult = PaginationResponse<int>.Create(allIds, pagination);
|
||||
|
||||
// Calculate next cursor
|
||||
int? nextCursor = cursor + pagedIds.Count < totalCount ? cursor + pagedIds.Count : (int?)null;
|
||||
|
||||
return new
|
||||
return new SuccessResponse("Found GameObjects", new
|
||||
{
|
||||
success = true,
|
||||
data = new
|
||||
{
|
||||
instanceIDs = pagedIds,
|
||||
pageSize = pageSize,
|
||||
cursor = cursor,
|
||||
nextCursor = nextCursor,
|
||||
totalCount = totalCount,
|
||||
hasMore = nextCursor.HasValue
|
||||
}
|
||||
};
|
||||
instanceIDs = paginatedResult.Items,
|
||||
pageSize = paginatedResult.PageSize,
|
||||
cursor = paginatedResult.Cursor,
|
||||
nextCursor = paginatedResult.NextCursor,
|
||||
totalCount = paginatedResult.TotalCount,
|
||||
hasMore = paginatedResult.HasMore
|
||||
});
|
||||
}
|
||||
catch (System.Exception ex)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -215,7 +215,7 @@ namespace MCPForUnity.Editor.Tools
|
|||
|
||||
if (propertiesForApply.HasValues)
|
||||
{
|
||||
MaterialOps.ApplyProperties(mat, propertiesForApply, ManageGameObject.InputSerializer);
|
||||
MaterialOps.ApplyProperties(mat, propertiesForApply, UnityJsonSerializer.Instance);
|
||||
}
|
||||
}
|
||||
AssetDatabase.CreateAsset(mat, fullPath);
|
||||
|
|
@ -418,7 +418,7 @@ namespace MCPForUnity.Editor.Tools
|
|||
{
|
||||
// Apply properties directly to the material. If this modifies, it sets modified=true.
|
||||
// Use |= in case the asset was already marked modified by previous logic (though unlikely here)
|
||||
modified |= MaterialOps.ApplyProperties(material, properties, ManageGameObject.InputSerializer);
|
||||
modified |= MaterialOps.ApplyProperties(material, properties, UnityJsonSerializer.Instance);
|
||||
}
|
||||
// Example: Modifying a ScriptableObject (Use manage_scriptable_object instead!)
|
||||
else if (asset is ScriptableObject so)
|
||||
|
|
@ -989,7 +989,7 @@ namespace MCPForUnity.Editor.Tools
|
|||
System.Reflection.PropertyInfo propInfo = type.GetProperty(memberName, flags);
|
||||
if (propInfo != null && propInfo.CanWrite)
|
||||
{
|
||||
object convertedValue = ConvertJTokenToType(value, propInfo.PropertyType);
|
||||
object convertedValue = Helpers.PropertyConversion.TryConvertToType(value, propInfo.PropertyType);
|
||||
if (
|
||||
convertedValue != null
|
||||
&& !object.Equals(propInfo.GetValue(target), convertedValue)
|
||||
|
|
@ -1004,7 +1004,7 @@ namespace MCPForUnity.Editor.Tools
|
|||
System.Reflection.FieldInfo fieldInfo = type.GetField(memberName, flags);
|
||||
if (fieldInfo != null)
|
||||
{
|
||||
object convertedValue = ConvertJTokenToType(value, fieldInfo.FieldType);
|
||||
object convertedValue = Helpers.PropertyConversion.TryConvertToType(value, fieldInfo.FieldType);
|
||||
if (
|
||||
convertedValue != null
|
||||
&& !object.Equals(fieldInfo.GetValue(target), convertedValue)
|
||||
|
|
@ -1025,89 +1025,6 @@ namespace MCPForUnity.Editor.Tools
|
|||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Simple JToken to Type conversion for common Unity types and primitives.
|
||||
/// </summary>
|
||||
private static object ConvertJTokenToType(JToken token, Type targetType)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (token == null || token.Type == JTokenType.Null)
|
||||
return null;
|
||||
|
||||
if (targetType == typeof(string))
|
||||
return token.ToObject<string>();
|
||||
if (targetType == typeof(int))
|
||||
return token.ToObject<int>();
|
||||
if (targetType == typeof(float))
|
||||
return token.ToObject<float>();
|
||||
if (targetType == typeof(bool))
|
||||
return token.ToObject<bool>();
|
||||
if (targetType == typeof(Vector2) && token is JArray arrV2 && arrV2.Count == 2)
|
||||
return new Vector2(arrV2[0].ToObject<float>(), arrV2[1].ToObject<float>());
|
||||
if (targetType == typeof(Vector3) && token is JArray arrV3 && arrV3.Count == 3)
|
||||
return new Vector3(
|
||||
arrV3[0].ToObject<float>(),
|
||||
arrV3[1].ToObject<float>(),
|
||||
arrV3[2].ToObject<float>()
|
||||
);
|
||||
if (targetType == typeof(Vector4) && token is JArray arrV4 && arrV4.Count == 4)
|
||||
return new Vector4(
|
||||
arrV4[0].ToObject<float>(),
|
||||
arrV4[1].ToObject<float>(),
|
||||
arrV4[2].ToObject<float>(),
|
||||
arrV4[3].ToObject<float>()
|
||||
);
|
||||
if (targetType == typeof(Quaternion) && token is JArray arrQ && arrQ.Count == 4)
|
||||
return new Quaternion(
|
||||
arrQ[0].ToObject<float>(),
|
||||
arrQ[1].ToObject<float>(),
|
||||
arrQ[2].ToObject<float>(),
|
||||
arrQ[3].ToObject<float>()
|
||||
);
|
||||
if (targetType == typeof(Color) && token is JArray arrC && arrC.Count >= 3) // Allow RGB or RGBA
|
||||
return new Color(
|
||||
arrC[0].ToObject<float>(),
|
||||
arrC[1].ToObject<float>(),
|
||||
arrC[2].ToObject<float>(),
|
||||
arrC.Count > 3 ? arrC[3].ToObject<float>() : 1.0f
|
||||
);
|
||||
if (targetType.IsEnum)
|
||||
return Enum.Parse(targetType, token.ToString(), true); // Case-insensitive enum parsing
|
||||
|
||||
// Handle loading Unity Objects (Materials, Textures, etc.) by path
|
||||
if (
|
||||
typeof(UnityEngine.Object).IsAssignableFrom(targetType)
|
||||
&& token.Type == JTokenType.String
|
||||
)
|
||||
{
|
||||
string assetPath = AssetPathUtility.SanitizeAssetPath(token.ToString());
|
||||
UnityEngine.Object loadedAsset = AssetDatabase.LoadAssetAtPath(
|
||||
assetPath,
|
||||
targetType
|
||||
);
|
||||
if (loadedAsset == null)
|
||||
{
|
||||
Debug.LogWarning(
|
||||
$"[ConvertJTokenToType] Could not load asset of type {targetType.Name} from path: {assetPath}"
|
||||
);
|
||||
}
|
||||
return loadedAsset;
|
||||
}
|
||||
|
||||
// Fallback: Try direct conversion (might work for other simple value types)
|
||||
return token.ToObject(targetType);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Debug.LogWarning(
|
||||
$"[ConvertJTokenToType] Could not convert JToken '{token}' (type {token.Type}) to type '{targetType.Name}': {ex.Message}"
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// --- Data Serialization ---
|
||||
|
||||
/// <summary>
|
||||
|
|
|
|||
|
|
@ -73,35 +73,32 @@ namespace MCPForUnity.Editor.Tools
|
|||
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))
|
||||
string componentTypeName = ParamCoercion.CoerceString(@params["componentType"] ?? @params["component_type"], null);
|
||||
if (string.IsNullOrEmpty(componentTypeName))
|
||||
{
|
||||
return new ErrorResponse("'componentType' parameter is required for 'add' action.");
|
||||
}
|
||||
|
||||
// Resolve component type
|
||||
Type type = FindComponentType(componentType);
|
||||
// Resolve component type using unified type resolver
|
||||
Type type = UnityTypeResolver.ResolveComponent(componentTypeName);
|
||||
if (type == null)
|
||||
{
|
||||
return new ErrorResponse($"Component type '{componentType}' not found. Use a fully-qualified name if needed.");
|
||||
return new ErrorResponse($"Component type '{componentTypeName}' 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);
|
||||
|
||||
// Use ComponentOps for the actual operation
|
||||
Component newComponent = ComponentOps.AddComponent(targetGo, type, out string error);
|
||||
if (newComponent == null)
|
||||
{
|
||||
return new ErrorResponse($"Failed to add component '{componentType}' to '{targetGo.name}'.");
|
||||
return new ErrorResponse(error ?? $"Failed to add component '{componentTypeName}'.");
|
||||
}
|
||||
|
||||
// Set properties if provided
|
||||
JObject properties = @params["properties"] as JObject ?? @params["componentProperties"] as JObject;
|
||||
if (properties != null && properties.HasValues)
|
||||
{
|
||||
// Record for undo before modifying properties
|
||||
Undo.RecordObject(newComponent, "Modify Component Properties");
|
||||
SetPropertiesOnComponent(newComponent, properties);
|
||||
}
|
||||
|
||||
|
|
@ -110,7 +107,7 @@ namespace MCPForUnity.Editor.Tools
|
|||
return new
|
||||
{
|
||||
success = true,
|
||||
message = $"Component '{componentType}' added to '{targetGo.name}'.",
|
||||
message = $"Component '{componentTypeName}' added to '{targetGo.name}'.",
|
||||
data = new
|
||||
{
|
||||
instanceID = targetGo.GetInstanceID(),
|
||||
|
|
@ -119,11 +116,6 @@ namespace MCPForUnity.Editor.Tools
|
|||
}
|
||||
};
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
return new ErrorResponse($"Error adding component '{componentType}': {e.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static object RemoveComponent(JObject @params, JToken targetToken, string searchMethod)
|
||||
{
|
||||
|
|
@ -133,51 +125,38 @@ namespace MCPForUnity.Editor.Tools
|
|||
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))
|
||||
string componentTypeName = ParamCoercion.CoerceString(@params["componentType"] ?? @params["component_type"], null);
|
||||
if (string.IsNullOrEmpty(componentTypeName))
|
||||
{
|
||||
return new ErrorResponse("'componentType' parameter is required for 'remove' action.");
|
||||
}
|
||||
|
||||
// Resolve component type
|
||||
Type type = FindComponentType(componentType);
|
||||
// Resolve component type using unified type resolver
|
||||
Type type = UnityTypeResolver.ResolveComponent(componentTypeName);
|
||||
if (type == null)
|
||||
{
|
||||
return new ErrorResponse($"Component type '{componentType}' not found.");
|
||||
return new ErrorResponse($"Component type '{componentTypeName}' not found.");
|
||||
}
|
||||
|
||||
// Prevent removal of Transform (check early before GetComponent)
|
||||
if (type == typeof(Transform))
|
||||
// Use ComponentOps for the actual operation
|
||||
bool removed = ComponentOps.RemoveComponent(targetGo, type, out string error);
|
||||
if (!removed)
|
||||
{
|
||||
return new ErrorResponse("Cannot remove the Transform component.");
|
||||
return new ErrorResponse(error ?? $"Failed to remove component '{componentTypeName}'.");
|
||||
}
|
||||
|
||||
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}'.",
|
||||
message = $"Component '{componentTypeName}' 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)
|
||||
{
|
||||
|
|
@ -193,8 +172,8 @@ namespace MCPForUnity.Editor.Tools
|
|||
return new ErrorResponse("'componentType' parameter is required for 'set_property' action.");
|
||||
}
|
||||
|
||||
// Resolve component type
|
||||
Type type = FindComponentType(componentType);
|
||||
// Resolve component type using unified type resolver
|
||||
Type type = UnityTypeResolver.ResolveComponent(componentType);
|
||||
if (type == null)
|
||||
{
|
||||
return new ErrorResponse($"Component type '{componentType}' not found.");
|
||||
|
|
@ -309,16 +288,6 @@ namespace MCPForUnity.Editor.Tools
|
|||
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)
|
||||
|
|
@ -340,78 +309,20 @@ namespace MCPForUnity.Editor.Tools
|
|||
|
||||
/// <summary>
|
||||
/// Attempts to set a property or field on a component.
|
||||
/// Note: Property/field lookup is case-insensitive for better usability with external callers.
|
||||
/// Delegates to ComponentOps.SetProperty for unified implementation.
|
||||
/// </summary>
|
||||
private static string TrySetProperty(Component component, string propertyName, JToken value)
|
||||
{
|
||||
if (component == null || string.IsNullOrEmpty(propertyName))
|
||||
return $"Invalid component or property name";
|
||||
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)
|
||||
if (ComponentOps.SetProperty(component, propertyName, value, out string error))
|
||||
{
|
||||
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);
|
||||
Debug.LogWarning($"[ManageComponents] {error}");
|
||||
return error;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
|
|
|||
|
|
@ -22,20 +22,8 @@ namespace MCPForUnity.Editor.Tools
|
|||
[McpForUnityTool("manage_gameobject", AutoRegister = false)]
|
||||
public static class ManageGameObject
|
||||
{
|
||||
// Shared JsonSerializer to avoid per-call allocation overhead
|
||||
internal static readonly JsonSerializer InputSerializer = JsonSerializer.Create(new JsonSerializerSettings
|
||||
{
|
||||
Converters = new List<JsonConverter>
|
||||
{
|
||||
new Vector3Converter(),
|
||||
new Vector2Converter(),
|
||||
new QuaternionConverter(),
|
||||
new ColorConverter(),
|
||||
new RectConverter(),
|
||||
new BoundsConverter(),
|
||||
new UnityEngineObjectConverter()
|
||||
}
|
||||
});
|
||||
// Use shared serializer from helper class (backwards-compatible alias)
|
||||
internal static JsonSerializer InputSerializer => UnityJsonSerializer.Instance;
|
||||
|
||||
// --- Main Handler ---
|
||||
|
||||
|
|
@ -88,75 +76,23 @@ namespace MCPForUnity.Editor.Tools
|
|||
}
|
||||
}
|
||||
|
||||
// --- Prefab Redirection Check ---
|
||||
// --- Prefab Asset Check ---
|
||||
// Prefab assets require different tools. Only 'create' (instantiation) is valid here.
|
||||
string targetPath =
|
||||
targetToken?.Type == JTokenType.String ? targetToken.ToString() : null;
|
||||
if (
|
||||
!string.IsNullOrEmpty(targetPath)
|
||||
&& targetPath.EndsWith(".prefab", StringComparison.OrdinalIgnoreCase)
|
||||
&& action != "create" // Allow prefab instantiation
|
||||
)
|
||||
{
|
||||
// Allow 'create' (instantiate), 'find' (?), 'get_components' (?)
|
||||
if (action == "modify" || action == "set_component_property")
|
||||
{
|
||||
Debug.Log(
|
||||
$"[ManageGameObject->ManageAsset] Redirecting action '{action}' for prefab '{targetPath}' to ManageAsset."
|
||||
);
|
||||
// Prepare params for ManageAsset.ModifyAsset
|
||||
JObject assetParams = new JObject();
|
||||
assetParams["action"] = "modify"; // ManageAsset uses "modify"
|
||||
assetParams["path"] = targetPath;
|
||||
|
||||
// Extract properties.
|
||||
// For 'set_component_property', combine componentName and componentProperties.
|
||||
// For 'modify', directly use componentProperties.
|
||||
JObject properties = null;
|
||||
if (action == "set_component_property")
|
||||
{
|
||||
string compName = @params["componentName"]?.ToString();
|
||||
JObject compProps = @params["componentProperties"]?[compName] as JObject; // Handle potential nesting
|
||||
if (string.IsNullOrEmpty(compName))
|
||||
return new ErrorResponse(
|
||||
"Missing 'componentName' for 'set_component_property' on prefab."
|
||||
);
|
||||
if (compProps == null)
|
||||
return new ErrorResponse(
|
||||
$"Missing or invalid 'componentProperties' for component '{compName}' for 'set_component_property' on prefab."
|
||||
);
|
||||
|
||||
properties = new JObject();
|
||||
properties[compName] = compProps;
|
||||
}
|
||||
else // action == "modify"
|
||||
{
|
||||
properties = @params["componentProperties"] as JObject;
|
||||
if (properties == null)
|
||||
return new ErrorResponse(
|
||||
"Missing 'componentProperties' for 'modify' action on prefab."
|
||||
$"Target '{targetPath}' is a prefab asset. " +
|
||||
$"Use 'manage_asset' with action='modify' for prefab asset modifications, " +
|
||||
$"or 'manage_prefabs' with action='open_stage' to edit the prefab in isolation mode."
|
||||
);
|
||||
}
|
||||
|
||||
assetParams["properties"] = properties;
|
||||
|
||||
// Call ManageAsset handler
|
||||
return ManageAsset.HandleCommand(assetParams);
|
||||
}
|
||||
else if (
|
||||
action == "delete"
|
||||
|| action == "add_component"
|
||||
|| action == "remove_component"
|
||||
|| action == "get_components"
|
||||
) // Added get_components here too
|
||||
{
|
||||
// Explicitly block other modifications on the prefab asset itself via manage_gameobject
|
||||
return new ErrorResponse(
|
||||
$"Action '{action}' on a prefab asset ('{targetPath}') should be performed using the 'manage_asset' command."
|
||||
);
|
||||
}
|
||||
// Allow 'create' (instantiation) and 'find' to proceed, although finding a prefab asset by path might be less common via manage_gameobject.
|
||||
// No specific handling needed here, the code below will run.
|
||||
}
|
||||
// --- End Prefab Redirection Check ---
|
||||
// --- End Prefab Asset Check ---
|
||||
|
||||
try
|
||||
{
|
||||
|
|
@ -398,42 +334,29 @@ namespace MCPForUnity.Editor.Tools
|
|||
// Set Tag (added for create action)
|
||||
if (!string.IsNullOrEmpty(tag))
|
||||
{
|
||||
// Similar logic as in ModifyGameObject for setting/creating tags
|
||||
string tagToSet = string.IsNullOrEmpty(tag) ? "Untagged" : tag;
|
||||
// Check if tag exists first (Unity doesn't throw exceptions for undefined tags, just logs a warning)
|
||||
if (tag != "Untagged" && !System.Linq.Enumerable.Contains(InternalEditorUtility.tags, tag))
|
||||
{
|
||||
Debug.Log($"[ManageGameObject.Create] Tag '{tag}' not found. Creating it.");
|
||||
try
|
||||
{
|
||||
newGo.tag = tagToSet;
|
||||
InternalEditorUtility.AddTag(tag);
|
||||
}
|
||||
catch (UnityException ex)
|
||||
catch (Exception ex)
|
||||
{
|
||||
if (ex.Message.Contains("is not defined"))
|
||||
{
|
||||
Debug.LogWarning(
|
||||
$"[ManageGameObject.Create] Tag '{tagToSet}' not found. Attempting to create it."
|
||||
);
|
||||
UnityEngine.Object.DestroyImmediate(newGo); // Clean up
|
||||
return new ErrorResponse($"Failed to create tag '{tag}': {ex.Message}.");
|
||||
}
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
InternalEditorUtility.AddTag(tagToSet);
|
||||
newGo.tag = tagToSet; // Retry
|
||||
Debug.Log(
|
||||
$"[ManageGameObject.Create] Tag '{tagToSet}' created and assigned successfully."
|
||||
);
|
||||
newGo.tag = tag;
|
||||
}
|
||||
catch (Exception innerEx)
|
||||
catch (Exception ex)
|
||||
{
|
||||
UnityEngine.Object.DestroyImmediate(newGo); // Clean up
|
||||
return new ErrorResponse(
|
||||
$"Failed to create or assign tag '{tagToSet}' during creation: {innerEx.Message}."
|
||||
);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
UnityEngine.Object.DestroyImmediate(newGo); // Clean up
|
||||
return new ErrorResponse(
|
||||
$"Failed to set tag to '{tagToSet}' during creation: {ex.Message}."
|
||||
);
|
||||
}
|
||||
return new ErrorResponse($"Failed to set tag to '{tag}' during creation: {ex.Message}.");
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -666,51 +589,31 @@ namespace MCPForUnity.Editor.Tools
|
|||
{
|
||||
// Ensure the tag is not empty, if empty, it means "Untagged" implicitly
|
||||
string tagToSet = string.IsNullOrEmpty(tag) ? "Untagged" : tag;
|
||||
try
|
||||
{
|
||||
targetGo.tag = tagToSet;
|
||||
modified = true;
|
||||
}
|
||||
catch (UnityException ex)
|
||||
{
|
||||
// Check if the error is specifically because the tag doesn't exist
|
||||
if (ex.Message.Contains("is not defined"))
|
||||
{
|
||||
Debug.LogWarning(
|
||||
$"[ManageGameObject] Tag '{tagToSet}' not found. Attempting to create it."
|
||||
);
|
||||
try
|
||||
{
|
||||
// Attempt to create the tag using internal utility
|
||||
InternalEditorUtility.AddTag(tagToSet);
|
||||
// Wait a frame maybe? Not strictly necessary but sometimes helps editor updates.
|
||||
// yield return null; // Cannot yield here, editor script limitation
|
||||
|
||||
// Retry setting the tag immediately after creation
|
||||
// Check if tag exists first (Unity doesn't throw exceptions for undefined tags, just logs a warning)
|
||||
if (tagToSet != "Untagged" && !System.Linq.Enumerable.Contains(InternalEditorUtility.tags, tagToSet))
|
||||
{
|
||||
Debug.Log($"[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;
|
||||
Debug.Log(
|
||||
$"[ManageGameObject] Tag '{tagToSet}' created and assigned successfully."
|
||||
);
|
||||
}
|
||||
catch (Exception innerEx)
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Handle failure during tag creation or the second assignment attempt
|
||||
Debug.LogError(
|
||||
$"[ManageGameObject] Failed to create or assign tag '{tagToSet}' after attempting creation: {innerEx.Message}"
|
||||
);
|
||||
return new ErrorResponse(
|
||||
$"Failed to create or assign tag '{tagToSet}': {innerEx.Message}. Check Tag Manager and permissions."
|
||||
);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// If the exception was for a different reason, return the original error
|
||||
return new ErrorResponse($"Failed to set tag to '{tagToSet}': {ex.Message}.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Change Layer (using consolidated 'layer' parameter)
|
||||
string layerName = @params["layer"]?.ToString();
|
||||
|
|
@ -1288,22 +1191,20 @@ namespace MCPForUnity.Editor.Tools
|
|||
Type componentType = FindType(searchTerm);
|
||||
if (componentType != null)
|
||||
{
|
||||
// Determine FindObjectsInactive based on the searchInactive flag
|
||||
FindObjectsInactive findInactive = searchInactive
|
||||
? FindObjectsInactive.Include
|
||||
: FindObjectsInactive.Exclude;
|
||||
// Replace FindObjectsOfType with FindObjectsByType, specifying the sorting mode and inactive state
|
||||
var searchPoolComp = rootSearchObject
|
||||
? rootSearchObject
|
||||
IEnumerable<GameObject> searchPoolComp;
|
||||
if (rootSearchObject)
|
||||
{
|
||||
searchPoolComp = rootSearchObject
|
||||
.GetComponentsInChildren(componentType, searchInactive)
|
||||
.Select(c => (c as Component).gameObject)
|
||||
: UnityEngine
|
||||
.Object.FindObjectsByType(
|
||||
componentType,
|
||||
findInactive,
|
||||
FindObjectsSortMode.None
|
||||
)
|
||||
.Select(c => (c as Component).gameObject);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Use FindObjectsOfType overload that respects includeInactive
|
||||
searchPoolComp = UnityEngine.Object.FindObjectsOfType(componentType, searchInactive)
|
||||
.Cast<Component>()
|
||||
.Select(c => c.gameObject);
|
||||
}
|
||||
results.AddRange(searchPoolComp.Where(go => go != null)); // Ensure GO is valid
|
||||
}
|
||||
else
|
||||
|
|
@ -1560,7 +1461,7 @@ namespace MCPForUnity.Editor.Tools
|
|||
if (!setResult)
|
||||
{
|
||||
var availableProperties = ComponentResolver.GetAllComponentProperties(targetComponent.GetType());
|
||||
var suggestions = ComponentResolver.GetAIPropertySuggestions(propName, availableProperties);
|
||||
var suggestions = ComponentResolver.GetFuzzyPropertySuggestions(propName, availableProperties);
|
||||
var msg = suggestions.Any()
|
||||
? $"Property '{propName}' not found. Did you mean: {string.Join(", ", suggestions)}? Available: [{string.Join(", ", availableProperties)}]"
|
||||
: $"Property '{propName}' not found. Available: [{string.Join(", ", availableProperties)}]";
|
||||
|
|
@ -1591,6 +1492,9 @@ namespace MCPForUnity.Editor.Tools
|
|||
BindingFlags flags =
|
||||
BindingFlags.Public | BindingFlags.Instance | BindingFlags.IgnoreCase;
|
||||
|
||||
// Normalize property name: "Use Gravity" → "useGravity", "is_kinematic" → "isKinematic"
|
||||
string normalizedName = Helpers.ParamCoercion.NormalizePropertyName(memberName);
|
||||
|
||||
// Use shared serializer to avoid per-call allocation
|
||||
var inputSerializer = InputSerializer;
|
||||
|
||||
|
|
@ -1604,7 +1508,9 @@ namespace MCPForUnity.Editor.Tools
|
|||
return SetNestedProperty(target, memberName, value, inputSerializer);
|
||||
}
|
||||
|
||||
PropertyInfo propInfo = type.GetProperty(memberName, flags);
|
||||
// Try both original and normalized names
|
||||
PropertyInfo propInfo = type.GetProperty(memberName, flags)
|
||||
?? type.GetProperty(normalizedName, flags);
|
||||
if (propInfo != null && propInfo.CanWrite)
|
||||
{
|
||||
// Use the inputSerializer for conversion
|
||||
|
|
@ -1621,7 +1527,9 @@ namespace MCPForUnity.Editor.Tools
|
|||
}
|
||||
else
|
||||
{
|
||||
FieldInfo fieldInfo = type.GetField(memberName, flags);
|
||||
// Try both original and normalized names for fields
|
||||
FieldInfo fieldInfo = type.GetField(memberName, flags)
|
||||
?? type.GetField(normalizedName, flags);
|
||||
if (fieldInfo != null) // Check if !IsLiteral?
|
||||
{
|
||||
// Use the inputSerializer for conversion
|
||||
|
|
@ -1638,8 +1546,9 @@ namespace MCPForUnity.Editor.Tools
|
|||
}
|
||||
else
|
||||
{
|
||||
// Try NonPublic [SerializeField] fields
|
||||
var npField = type.GetField(memberName, BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.IgnoreCase);
|
||||
// Try NonPublic [SerializeField] fields (with both original and normalized names)
|
||||
var npField = type.GetField(memberName, BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.IgnoreCase)
|
||||
?? type.GetField(normalizedName, BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.IgnoreCase);
|
||||
if (npField != null && npField.GetCustomAttribute<SerializeField>() != null)
|
||||
{
|
||||
object convertedValue = ConvertJTokenToType(value, npField.FieldType, inputSerializer);
|
||||
|
|
@ -1871,45 +1780,14 @@ namespace MCPForUnity.Editor.Tools
|
|||
/// <summary>
|
||||
/// Simple JToken to Type conversion for common Unity types, using JsonSerializer.
|
||||
/// </summary>
|
||||
// Pass the input serializer
|
||||
/// <remarks>
|
||||
/// Delegates to PropertyConversion.ConvertToType for unified type handling.
|
||||
/// The inputSerializer parameter is kept for backwards compatibility but is ignored
|
||||
/// as PropertyConversion uses UnityJsonSerializer.Instance internally.
|
||||
/// </remarks>
|
||||
private static object ConvertJTokenToType(JToken token, Type targetType, JsonSerializer inputSerializer)
|
||||
{
|
||||
if (token == null || token.Type == JTokenType.Null)
|
||||
{
|
||||
if (targetType.IsValueType && Nullable.GetUnderlyingType(targetType) == null)
|
||||
{
|
||||
Debug.LogWarning($"Cannot assign null to non-nullable value type {targetType.Name}. Returning default value.");
|
||||
return Activator.CreateInstance(targetType);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// Use the provided serializer instance which includes our custom converters
|
||||
return token.ToObject(targetType, inputSerializer);
|
||||
}
|
||||
catch (JsonSerializationException jsonEx)
|
||||
{
|
||||
Debug.LogError($"JSON Deserialization Error converting token to {targetType.FullName}: {jsonEx.Message}\nToken: {token.ToString(Formatting.None)}");
|
||||
// Optionally re-throw or return null/default
|
||||
// return targetType.IsValueType ? Activator.CreateInstance(targetType) : null;
|
||||
throw; // Re-throw to indicate failure higher up
|
||||
}
|
||||
catch (ArgumentException argEx)
|
||||
{
|
||||
Debug.LogError($"Argument Error converting token to {targetType.FullName}: {argEx.Message}\nToken: {token.ToString(Formatting.None)}");
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Debug.LogError($"Unexpected error converting token to {targetType.FullName}: {ex}\nToken: {token.ToString(Formatting.None)}");
|
||||
throw;
|
||||
}
|
||||
// If ToObject succeeded, it would have returned. If it threw, we wouldn't reach here.
|
||||
// This fallback logic is likely unreachable if ToObject covers all cases or throws on failure.
|
||||
// Debug.LogWarning($"Conversion failed for token to {targetType.FullName}. Token: {token.ToString(Formatting.None)}");
|
||||
// return targetType.IsValueType ? Activator.CreateInstance(targetType) : null;
|
||||
return PropertyConversion.ConvertToType(token, targetType);
|
||||
}
|
||||
|
||||
// --- ParseJTokenTo... helpers are likely redundant now with the serializer approach ---
|
||||
|
|
@ -2011,104 +1889,13 @@ namespace MCPForUnity.Editor.Tools
|
|||
/// Finds a specific UnityEngine.Object based on a find instruction JObject.
|
||||
/// Primarily used by UnityEngineObjectConverter during deserialization.
|
||||
/// </summary>
|
||||
// Made public static so UnityEngineObjectConverter can call it. Moved from ConvertJTokenToType.
|
||||
/// <remarks>
|
||||
/// This method now delegates to ObjectResolver.Resolve() for cleaner architecture.
|
||||
/// Kept for backwards compatibility with existing code.
|
||||
/// </remarks>
|
||||
public static UnityEngine.Object FindObjectByInstruction(JObject instruction, Type targetType)
|
||||
{
|
||||
string findTerm = instruction["find"]?.ToString();
|
||||
string method = instruction["method"]?.ToString()?.ToLower();
|
||||
string componentName = instruction["component"]?.ToString(); // Specific component to get
|
||||
|
||||
if (string.IsNullOrEmpty(findTerm))
|
||||
{
|
||||
Debug.LogWarning("Find instruction missing 'find' term.");
|
||||
return null;
|
||||
}
|
||||
|
||||
// Use a flexible default search method if none provided
|
||||
string searchMethodToUse = string.IsNullOrEmpty(method) ? "by_id_or_name_or_path" : method;
|
||||
|
||||
// If the target is an asset (Material, Texture, ScriptableObject etc.) try AssetDatabase first
|
||||
if (typeof(Material).IsAssignableFrom(targetType) ||
|
||||
typeof(Texture).IsAssignableFrom(targetType) ||
|
||||
typeof(ScriptableObject).IsAssignableFrom(targetType) ||
|
||||
targetType.FullName.StartsWith("UnityEngine.U2D") || // Sprites etc.
|
||||
typeof(AudioClip).IsAssignableFrom(targetType) ||
|
||||
typeof(AnimationClip).IsAssignableFrom(targetType) ||
|
||||
typeof(Font).IsAssignableFrom(targetType) ||
|
||||
typeof(Shader).IsAssignableFrom(targetType) ||
|
||||
typeof(ComputeShader).IsAssignableFrom(targetType) ||
|
||||
typeof(GameObject).IsAssignableFrom(targetType) && findTerm.StartsWith("Assets/")) // Prefab check
|
||||
{
|
||||
// Try loading directly by path/GUID first
|
||||
UnityEngine.Object asset = AssetDatabase.LoadAssetAtPath(findTerm, targetType);
|
||||
if (asset != null) return asset;
|
||||
asset = AssetDatabase.LoadAssetAtPath<UnityEngine.Object>(findTerm); // Try generic if type specific failed
|
||||
if (asset != null && targetType.IsAssignableFrom(asset.GetType())) return asset;
|
||||
|
||||
|
||||
// If direct path failed, try finding by name/type using FindAssets
|
||||
string searchFilter = $"t:{targetType.Name} {System.IO.Path.GetFileNameWithoutExtension(findTerm)}"; // Search by type and name
|
||||
string[] guids = AssetDatabase.FindAssets(searchFilter);
|
||||
|
||||
if (guids.Length == 1)
|
||||
{
|
||||
asset = AssetDatabase.LoadAssetAtPath(AssetDatabase.GUIDToAssetPath(guids[0]), targetType);
|
||||
if (asset != null) return asset;
|
||||
}
|
||||
else if (guids.Length > 1)
|
||||
{
|
||||
Debug.LogWarning($"[FindObjectByInstruction] Ambiguous asset find: Found {guids.Length} assets matching filter '{searchFilter}'. Provide a full path or unique name.");
|
||||
// Optionally return the first one? Or null? Returning null is safer.
|
||||
return null;
|
||||
}
|
||||
// If still not found, fall through to scene search (though unlikely for assets)
|
||||
}
|
||||
|
||||
|
||||
// --- Scene Object Search ---
|
||||
// Find the GameObject using the internal finder
|
||||
GameObject foundGo = FindObjectInternal(new JValue(findTerm), searchMethodToUse);
|
||||
|
||||
if (foundGo == null)
|
||||
{
|
||||
// Don't warn yet, could still be an asset not found above
|
||||
// Debug.LogWarning($"Could not find GameObject using instruction: {instruction}");
|
||||
return null;
|
||||
}
|
||||
|
||||
// Now, get the target object/component from the found GameObject
|
||||
if (targetType == typeof(GameObject))
|
||||
{
|
||||
return foundGo; // We were looking for a GameObject
|
||||
}
|
||||
else if (typeof(Component).IsAssignableFrom(targetType))
|
||||
{
|
||||
Type componentToGetType = targetType;
|
||||
if (!string.IsNullOrEmpty(componentName))
|
||||
{
|
||||
Type specificCompType = FindType(componentName);
|
||||
if (specificCompType != null && typeof(Component).IsAssignableFrom(specificCompType))
|
||||
{
|
||||
componentToGetType = specificCompType;
|
||||
}
|
||||
else
|
||||
{
|
||||
Debug.LogWarning($"Could not find component type '{componentName}' specified in find instruction. Falling back to target type '{targetType.Name}'.");
|
||||
}
|
||||
}
|
||||
|
||||
Component foundComp = foundGo.GetComponent(componentToGetType);
|
||||
if (foundComp == null)
|
||||
{
|
||||
Debug.LogWarning($"Found GameObject '{foundGo.name}' but could not find component of type '{componentToGetType.Name}'.");
|
||||
}
|
||||
return foundComp;
|
||||
}
|
||||
else
|
||||
{
|
||||
Debug.LogWarning($"Find instruction handling not implemented for target type: {targetType.Name}");
|
||||
return null;
|
||||
}
|
||||
return ObjectResolver.Resolve(instruction, targetType);
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -2134,125 +1921,18 @@ namespace MCPForUnity.Editor.Tools
|
|||
}
|
||||
|
||||
/// <summary>
|
||||
/// Robust component resolver that avoids Assembly.LoadFrom and supports assembly definitions.
|
||||
/// Prioritizes runtime (Player) assemblies over Editor assemblies.
|
||||
/// Component resolver that delegates to UnityTypeResolver.
|
||||
/// Kept for backwards compatibility.
|
||||
/// </summary>
|
||||
internal static class ComponentResolver
|
||||
{
|
||||
private static readonly Dictionary<string, Type> CacheByFqn = new(StringComparer.Ordinal);
|
||||
private static readonly Dictionary<string, Type> CacheByName = new(StringComparer.Ordinal);
|
||||
|
||||
/// <summary>
|
||||
/// Resolve a Component/MonoBehaviour type by short or fully-qualified name.
|
||||
/// Prefers runtime (Player) script assemblies; falls back to Editor assemblies.
|
||||
/// Never uses Assembly.LoadFrom.
|
||||
/// Delegates to UnityTypeResolver.TryResolve with Component constraint.
|
||||
/// </summary>
|
||||
public static bool TryResolve(string nameOrFullName, out Type type, out string error)
|
||||
{
|
||||
error = string.Empty;
|
||||
type = null!;
|
||||
|
||||
// Handle null/empty input
|
||||
if (string.IsNullOrWhiteSpace(nameOrFullName))
|
||||
{
|
||||
error = "Component name cannot be null or empty";
|
||||
return false;
|
||||
}
|
||||
|
||||
// 1) Exact cache hits
|
||||
if (CacheByFqn.TryGetValue(nameOrFullName, out type)) return true;
|
||||
if (!nameOrFullName.Contains(".") && CacheByName.TryGetValue(nameOrFullName, out type)) return true;
|
||||
type = Type.GetType(nameOrFullName, throwOnError: false);
|
||||
if (IsValidComponent(type)) { Cache(type); return true; }
|
||||
|
||||
// 2) Search loaded assemblies (prefer Player assemblies)
|
||||
var candidates = FindCandidates(nameOrFullName);
|
||||
if (candidates.Count == 1) { type = candidates[0]; Cache(type); return true; }
|
||||
if (candidates.Count > 1) { error = Ambiguity(nameOrFullName, candidates); type = null!; return false; }
|
||||
|
||||
#if UNITY_EDITOR
|
||||
// 3) Last resort: Editor-only TypeCache (fast index)
|
||||
var tc = TypeCache.GetTypesDerivedFrom<Component>()
|
||||
.Where(t => NamesMatch(t, nameOrFullName));
|
||||
candidates = PreferPlayer(tc).ToList();
|
||||
if (candidates.Count == 1) { type = candidates[0]; Cache(type); return true; }
|
||||
if (candidates.Count > 1) { error = Ambiguity(nameOrFullName, candidates); type = null!; return false; }
|
||||
#endif
|
||||
|
||||
error = $"Component type '{nameOrFullName}' not found in loaded runtime assemblies. " +
|
||||
"Use a fully-qualified name (Namespace.TypeName) and ensure the script compiled.";
|
||||
type = null!;
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool NamesMatch(Type t, string q) =>
|
||||
t.Name.Equals(q, StringComparison.Ordinal) ||
|
||||
(t.FullName?.Equals(q, StringComparison.Ordinal) ?? false);
|
||||
|
||||
private static bool IsValidComponent(Type t) =>
|
||||
t != null && typeof(Component).IsAssignableFrom(t);
|
||||
|
||||
private static void Cache(Type t)
|
||||
{
|
||||
if (t.FullName != null) CacheByFqn[t.FullName] = t;
|
||||
CacheByName[t.Name] = t;
|
||||
}
|
||||
|
||||
private static List<Type> FindCandidates(string query)
|
||||
{
|
||||
bool isShort = !query.Contains('.');
|
||||
var loaded = AppDomain.CurrentDomain.GetAssemblies();
|
||||
|
||||
#if UNITY_EDITOR
|
||||
// Names of Player (runtime) script assemblies (asmdefs + Assembly-CSharp)
|
||||
var playerAsmNames = new HashSet<string>(
|
||||
UnityEditor.Compilation.CompilationPipeline.GetAssemblies(UnityEditor.Compilation.AssembliesType.Player).Select(a => a.name),
|
||||
StringComparer.Ordinal);
|
||||
|
||||
IEnumerable<System.Reflection.Assembly> playerAsms = loaded.Where(a => playerAsmNames.Contains(a.GetName().Name));
|
||||
IEnumerable<System.Reflection.Assembly> editorAsms = loaded.Except(playerAsms);
|
||||
#else
|
||||
IEnumerable<System.Reflection.Assembly> playerAsms = loaded;
|
||||
IEnumerable<System.Reflection.Assembly> editorAsms = Array.Empty<System.Reflection.Assembly>();
|
||||
#endif
|
||||
static IEnumerable<Type> SafeGetTypes(System.Reflection.Assembly a)
|
||||
{
|
||||
try { return a.GetTypes(); }
|
||||
catch (ReflectionTypeLoadException rtle) { return rtle.Types.Where(t => t != null)!; }
|
||||
}
|
||||
|
||||
Func<Type, bool> match = isShort
|
||||
? (t => t.Name.Equals(query, StringComparison.Ordinal))
|
||||
: (t => t.FullName!.Equals(query, StringComparison.Ordinal));
|
||||
|
||||
var fromPlayer = playerAsms.SelectMany(SafeGetTypes)
|
||||
.Where(IsValidComponent)
|
||||
.Where(match);
|
||||
var fromEditor = editorAsms.SelectMany(SafeGetTypes)
|
||||
.Where(IsValidComponent)
|
||||
.Where(match);
|
||||
|
||||
var list = new List<Type>(fromPlayer);
|
||||
if (list.Count == 0) list.AddRange(fromEditor);
|
||||
return list;
|
||||
}
|
||||
|
||||
#if UNITY_EDITOR
|
||||
private static IEnumerable<Type> PreferPlayer(IEnumerable<Type> seq)
|
||||
{
|
||||
var player = new HashSet<string>(
|
||||
UnityEditor.Compilation.CompilationPipeline.GetAssemblies(UnityEditor.Compilation.AssembliesType.Player).Select(a => a.name),
|
||||
StringComparer.Ordinal);
|
||||
|
||||
return seq.OrderBy(t => player.Contains(t.Assembly.GetName().Name) ? 0 : 1);
|
||||
}
|
||||
#endif
|
||||
|
||||
private static string Ambiguity(string query, IEnumerable<Type> cands)
|
||||
{
|
||||
var lines = cands.Select(t => $"{t.FullName} (assembly {t.Assembly.GetName().Name})");
|
||||
return $"Multiple component types matched '{query}':\n - " + string.Join("\n - ", lines) +
|
||||
"\nProvide a fully qualified type name to disambiguate.";
|
||||
return UnityTypeResolver.TryResolve(nameOrFullName, out type, out error, typeof(Component));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -2279,45 +1959,28 @@ namespace MCPForUnity.Editor.Tools
|
|||
}
|
||||
|
||||
/// <summary>
|
||||
/// Uses AI to suggest the most likely property matches for a user's input.
|
||||
/// Suggests the most likely property matches for a user's input using fuzzy matching.
|
||||
/// Uses Levenshtein distance, substring matching, and common naming pattern heuristics.
|
||||
/// </summary>
|
||||
public static List<string> GetAIPropertySuggestions(string userInput, List<string> availableProperties)
|
||||
public static List<string> GetFuzzyPropertySuggestions(string userInput, List<string> availableProperties)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(userInput) || !availableProperties.Any())
|
||||
return new List<string>();
|
||||
|
||||
// Simple caching to avoid repeated AI calls for the same input
|
||||
// Simple caching to avoid repeated lookups for the same input
|
||||
var cacheKey = $"{userInput.ToLowerInvariant()}:{string.Join(",", availableProperties)}";
|
||||
if (PropertySuggestionCache.TryGetValue(cacheKey, out var cached))
|
||||
return cached;
|
||||
|
||||
try
|
||||
{
|
||||
var prompt = $"A Unity developer is trying to set a component property but used an incorrect name.\n\n" +
|
||||
$"User requested: \"{userInput}\"\n" +
|
||||
$"Available properties: [{string.Join(", ", availableProperties)}]\n\n" +
|
||||
$"Find 1-3 most likely matches considering:\n" +
|
||||
$"- Unity Inspector display names vs actual field names (e.g., \"Max Reach Distance\" → \"maxReachDistance\")\n" +
|
||||
$"- camelCase vs PascalCase vs spaces\n" +
|
||||
$"- Similar meaning/semantics\n" +
|
||||
$"- Common Unity naming patterns\n\n" +
|
||||
$"Return ONLY the matching property names, comma-separated, no quotes or explanation.\n" +
|
||||
$"If confidence is low (<70%), return empty string.\n\n" +
|
||||
$"Examples:\n" +
|
||||
$"- \"Max Reach Distance\" → \"maxReachDistance\"\n" +
|
||||
$"- \"Health Points\" → \"healthPoints, hp\"\n" +
|
||||
$"- \"Move Speed\" → \"moveSpeed, movementSpeed\"";
|
||||
|
||||
// For now, we'll use a simple rule-based approach that mimics AI behavior
|
||||
// This can be replaced with actual AI calls later
|
||||
var suggestions = GetRuleBasedSuggestions(userInput, availableProperties);
|
||||
|
||||
PropertySuggestionCache[cacheKey] = suggestions;
|
||||
return suggestions;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Debug.LogWarning($"[AI Property Matching] Error getting suggestions for '{userInput}': {ex.Message}");
|
||||
Debug.LogWarning($"[Property Matching] Error getting suggestions for '{userInput}': {ex.Message}");
|
||||
return new List<string>();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ namespace MCPForUnity.Editor.Tools
|
|||
string action = @params["action"]?.ToString();
|
||||
if (string.IsNullOrEmpty(action))
|
||||
{
|
||||
return new { status = "error", message = "Action is required" };
|
||||
return new ErrorResponse("Action is required");
|
||||
}
|
||||
|
||||
try
|
||||
|
|
@ -24,7 +24,7 @@ namespace MCPForUnity.Editor.Tools
|
|||
switch (action)
|
||||
{
|
||||
case "ping":
|
||||
return new { status = "success", tool = "manage_material" };
|
||||
return new SuccessResponse("pong", new { tool = "manage_material" });
|
||||
|
||||
case "create":
|
||||
return CreateMaterial(@params);
|
||||
|
|
@ -45,12 +45,12 @@ namespace MCPForUnity.Editor.Tools
|
|||
return GetMaterialInfo(@params);
|
||||
|
||||
default:
|
||||
return new { status = "error", message = $"Unknown action: {action}" };
|
||||
return new ErrorResponse($"Unknown action: {action}");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new { status = "error", message = ex.Message, stackTrace = ex.StackTrace };
|
||||
return new ErrorResponse(ex.Message, new { stackTrace = ex.StackTrace });
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -78,16 +78,16 @@ namespace MCPForUnity.Editor.Tools
|
|||
|
||||
if (string.IsNullOrEmpty(materialPath) || string.IsNullOrEmpty(property) || value == null)
|
||||
{
|
||||
return new { status = "error", message = "materialPath, property, and value are required" };
|
||||
return new ErrorResponse("materialPath, property, and value are required");
|
||||
}
|
||||
|
||||
// Find material
|
||||
var findInstruction = new JObject { ["find"] = materialPath };
|
||||
Material mat = ManageGameObject.FindObjectByInstruction(findInstruction, typeof(Material)) as Material;
|
||||
Material mat = ObjectResolver.Resolve(findInstruction, typeof(Material)) as Material;
|
||||
|
||||
if (mat == null)
|
||||
{
|
||||
return new { status = "error", message = $"Could not find material at path: {materialPath}" };
|
||||
return new ErrorResponse($"Could not find material at path: {materialPath}");
|
||||
}
|
||||
|
||||
Undo.RecordObject(mat, "Set Material Property");
|
||||
|
|
@ -101,27 +101,27 @@ namespace MCPForUnity.Editor.Tools
|
|||
// Check if it looks like an instruction
|
||||
if (value is JObject obj && (obj.ContainsKey("find") || obj.ContainsKey("method")))
|
||||
{
|
||||
Texture tex = ManageGameObject.FindObjectByInstruction(obj, typeof(Texture)) as Texture;
|
||||
Texture tex = ObjectResolver.Resolve(obj, typeof(Texture)) as Texture;
|
||||
if (tex != null && mat.HasProperty(property))
|
||||
{
|
||||
mat.SetTexture(property, tex);
|
||||
EditorUtility.SetDirty(mat);
|
||||
return new { status = "success", message = $"Set texture property {property} on {mat.name}" };
|
||||
return new SuccessResponse($"Set texture property {property} on {mat.name}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Fallback to standard logic via MaterialOps (handles Colors, Floats, Strings->Path)
|
||||
bool success = MaterialOps.TrySetShaderProperty(mat, property, value, ManageGameObject.InputSerializer);
|
||||
bool success = MaterialOps.TrySetShaderProperty(mat, property, value, UnityJsonSerializer.Instance);
|
||||
|
||||
if (success)
|
||||
{
|
||||
EditorUtility.SetDirty(mat);
|
||||
return new { status = "success", message = $"Set property {property} on {mat.name}" };
|
||||
return new SuccessResponse($"Set property {property} on {mat.name}");
|
||||
}
|
||||
else
|
||||
{
|
||||
return new { status = "error", message = $"Failed to set property {property}. Value format might be unsupported or texture not found." };
|
||||
return new ErrorResponse($"Failed to set property {property}. Value format might be unsupported or texture not found.");
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -133,25 +133,25 @@ namespace MCPForUnity.Editor.Tools
|
|||
|
||||
if (string.IsNullOrEmpty(materialPath) || colorToken == null)
|
||||
{
|
||||
return new { status = "error", message = "materialPath and color are required" };
|
||||
return new ErrorResponse("materialPath and color are required");
|
||||
}
|
||||
|
||||
var findInstruction = new JObject { ["find"] = materialPath };
|
||||
Material mat = ManageGameObject.FindObjectByInstruction(findInstruction, typeof(Material)) as Material;
|
||||
Material mat = ObjectResolver.Resolve(findInstruction, typeof(Material)) as Material;
|
||||
|
||||
if (mat == null)
|
||||
{
|
||||
return new { status = "error", message = $"Could not find material at path: {materialPath}" };
|
||||
return new ErrorResponse($"Could not find material at path: {materialPath}");
|
||||
}
|
||||
|
||||
Color color;
|
||||
try
|
||||
{
|
||||
color = MaterialOps.ParseColor(colorToken, ManageGameObject.InputSerializer);
|
||||
color = MaterialOps.ParseColor(colorToken, UnityJsonSerializer.Instance);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
return new { status = "error", message = $"Invalid color format: {e.Message}" };
|
||||
return new ErrorResponse($"Invalid color format: {e.Message}");
|
||||
}
|
||||
|
||||
Undo.RecordObject(mat, "Set Material Color");
|
||||
|
|
@ -185,11 +185,11 @@ namespace MCPForUnity.Editor.Tools
|
|||
if (foundProp)
|
||||
{
|
||||
EditorUtility.SetDirty(mat);
|
||||
return new { status = "success", message = $"Set color on {property}" };
|
||||
return new SuccessResponse($"Set color on {property}");
|
||||
}
|
||||
else
|
||||
{
|
||||
return new { status = "error", message = "Could not find suitable color property (_BaseColor or _Color) or specified property does not exist." };
|
||||
return new ErrorResponse("Could not find suitable color property (_BaseColor or _Color) or specified property does not exist.");
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -202,29 +202,29 @@ namespace MCPForUnity.Editor.Tools
|
|||
|
||||
if (string.IsNullOrEmpty(target) || string.IsNullOrEmpty(materialPath))
|
||||
{
|
||||
return new { status = "error", message = "target and materialPath are required" };
|
||||
return new ErrorResponse("target and materialPath are required");
|
||||
}
|
||||
|
||||
var goInstruction = new JObject { ["find"] = target };
|
||||
if (!string.IsNullOrEmpty(searchMethod)) goInstruction["method"] = searchMethod;
|
||||
|
||||
GameObject go = ManageGameObject.FindObjectByInstruction(goInstruction, typeof(GameObject)) as GameObject;
|
||||
GameObject go = ObjectResolver.Resolve(goInstruction, typeof(GameObject)) as GameObject;
|
||||
if (go == null)
|
||||
{
|
||||
return new { status = "error", message = $"Could not find target GameObject: {target}" };
|
||||
return new ErrorResponse($"Could not find target GameObject: {target}");
|
||||
}
|
||||
|
||||
Renderer renderer = go.GetComponent<Renderer>();
|
||||
if (renderer == null)
|
||||
{
|
||||
return new { status = "error", message = $"GameObject {go.name} has no Renderer component" };
|
||||
return new ErrorResponse($"GameObject {go.name} has no Renderer component");
|
||||
}
|
||||
|
||||
var matInstruction = new JObject { ["find"] = materialPath };
|
||||
Material mat = ManageGameObject.FindObjectByInstruction(matInstruction, typeof(Material)) as Material;
|
||||
Material mat = ObjectResolver.Resolve(matInstruction, typeof(Material)) as Material;
|
||||
if (mat == null)
|
||||
{
|
||||
return new { status = "error", message = $"Could not find material: {materialPath}" };
|
||||
return new ErrorResponse($"Could not find material: {materialPath}");
|
||||
}
|
||||
|
||||
Undo.RecordObject(renderer, "Assign Material");
|
||||
|
|
@ -232,14 +232,14 @@ namespace MCPForUnity.Editor.Tools
|
|||
Material[] sharedMats = renderer.sharedMaterials;
|
||||
if (slot < 0 || slot >= sharedMats.Length)
|
||||
{
|
||||
return new { status = "error", message = $"Slot {slot} out of bounds (count: {sharedMats.Length})" };
|
||||
return new ErrorResponse($"Slot {slot} out of bounds (count: {sharedMats.Length})");
|
||||
}
|
||||
|
||||
sharedMats[slot] = mat;
|
||||
renderer.sharedMaterials = sharedMats;
|
||||
|
||||
EditorUtility.SetDirty(renderer);
|
||||
return new { status = "success", message = $"Assigned material {mat.name} to {go.name} slot {slot}" };
|
||||
return new SuccessResponse($"Assigned material {mat.name} to {go.name} slot {slot}");
|
||||
}
|
||||
|
||||
private static object SetRendererColor(JObject @params)
|
||||
|
|
@ -252,39 +252,39 @@ namespace MCPForUnity.Editor.Tools
|
|||
|
||||
if (string.IsNullOrEmpty(target) || colorToken == null)
|
||||
{
|
||||
return new { status = "error", message = "target and color are required" };
|
||||
return new ErrorResponse("target and color are required");
|
||||
}
|
||||
|
||||
Color color;
|
||||
try
|
||||
{
|
||||
color = MaterialOps.ParseColor(colorToken, ManageGameObject.InputSerializer);
|
||||
color = MaterialOps.ParseColor(colorToken, UnityJsonSerializer.Instance);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
return new { status = "error", message = $"Invalid color format: {e.Message}" };
|
||||
return new ErrorResponse($"Invalid color format: {e.Message}");
|
||||
}
|
||||
|
||||
var goInstruction = new JObject { ["find"] = target };
|
||||
if (!string.IsNullOrEmpty(searchMethod)) goInstruction["method"] = searchMethod;
|
||||
|
||||
GameObject go = ManageGameObject.FindObjectByInstruction(goInstruction, typeof(GameObject)) as GameObject;
|
||||
GameObject go = ObjectResolver.Resolve(goInstruction, typeof(GameObject)) as GameObject;
|
||||
if (go == null)
|
||||
{
|
||||
return new { status = "error", message = $"Could not find target GameObject: {target}" };
|
||||
return new ErrorResponse($"Could not find target GameObject: {target}");
|
||||
}
|
||||
|
||||
Renderer renderer = go.GetComponent<Renderer>();
|
||||
if (renderer == null)
|
||||
{
|
||||
return new { status = "error", message = $"GameObject {go.name} has no Renderer component" };
|
||||
return new ErrorResponse($"GameObject {go.name} has no Renderer component");
|
||||
}
|
||||
|
||||
if (mode == "property_block")
|
||||
{
|
||||
if (slot < 0 || slot >= renderer.sharedMaterials.Length)
|
||||
{
|
||||
return new { status = "error", message = $"Slot {slot} out of bounds (count: {renderer.sharedMaterials.Length})" };
|
||||
return new ErrorResponse($"Slot {slot} out of bounds (count: {renderer.sharedMaterials.Length})");
|
||||
}
|
||||
|
||||
MaterialPropertyBlock block = new MaterialPropertyBlock();
|
||||
|
|
@ -304,7 +304,7 @@ namespace MCPForUnity.Editor.Tools
|
|||
|
||||
renderer.SetPropertyBlock(block, slot);
|
||||
EditorUtility.SetDirty(renderer);
|
||||
return new { status = "success", message = $"Set renderer color (PropertyBlock) on slot {slot}" };
|
||||
return new SuccessResponse($"Set renderer color (PropertyBlock) on slot {slot}");
|
||||
}
|
||||
else if (mode == "shared")
|
||||
{
|
||||
|
|
@ -313,15 +313,15 @@ namespace MCPForUnity.Editor.Tools
|
|||
Material mat = renderer.sharedMaterials[slot];
|
||||
if (mat == null)
|
||||
{
|
||||
return new { status = "error", message = $"No material in slot {slot}" };
|
||||
return new ErrorResponse($"No material in slot {slot}");
|
||||
}
|
||||
Undo.RecordObject(mat, "Set Material Color");
|
||||
if (mat.HasProperty("_BaseColor")) mat.SetColor("_BaseColor", color);
|
||||
else mat.SetColor("_Color", color);
|
||||
EditorUtility.SetDirty(mat);
|
||||
return new { status = "success", message = "Set shared material color" };
|
||||
return new SuccessResponse("Set shared material color");
|
||||
}
|
||||
return new { status = "error", message = "Invalid slot" };
|
||||
return new ErrorResponse("Invalid slot");
|
||||
}
|
||||
else if (mode == "instance")
|
||||
{
|
||||
|
|
@ -330,18 +330,18 @@ namespace MCPForUnity.Editor.Tools
|
|||
Material mat = renderer.materials[slot];
|
||||
if (mat == null)
|
||||
{
|
||||
return new { status = "error", message = $"No material in slot {slot}" };
|
||||
return new ErrorResponse($"No material in slot {slot}");
|
||||
}
|
||||
// Note: Undo cannot fully revert material instantiation
|
||||
Undo.RecordObject(mat, "Set Instance Material Color");
|
||||
if (mat.HasProperty("_BaseColor")) mat.SetColor("_BaseColor", color);
|
||||
else mat.SetColor("_Color", color);
|
||||
return new { status = "success", message = "Set instance material color", warning = "Material instance created; Undo cannot fully revert instantiation." };
|
||||
return new SuccessResponse("Set instance material color", new { warning = "Material instance created; Undo cannot fully revert instantiation." });
|
||||
}
|
||||
return new { status = "error", message = "Invalid slot" };
|
||||
return new ErrorResponse("Invalid slot");
|
||||
}
|
||||
|
||||
return new { status = "error", message = $"Unknown mode: {mode}" };
|
||||
return new ErrorResponse($"Unknown mode: {mode}");
|
||||
}
|
||||
|
||||
private static object GetMaterialInfo(JObject @params)
|
||||
|
|
@ -349,15 +349,15 @@ namespace MCPForUnity.Editor.Tools
|
|||
string materialPath = NormalizePath(@params["materialPath"]?.ToString());
|
||||
if (string.IsNullOrEmpty(materialPath))
|
||||
{
|
||||
return new { status = "error", message = "materialPath is required" };
|
||||
return new ErrorResponse("materialPath is required");
|
||||
}
|
||||
|
||||
var findInstruction = new JObject { ["find"] = materialPath };
|
||||
Material mat = ManageGameObject.FindObjectByInstruction(findInstruction, typeof(Material)) as Material;
|
||||
Material mat = ObjectResolver.Resolve(findInstruction, typeof(Material)) as Material;
|
||||
|
||||
if (mat == null)
|
||||
{
|
||||
return new { status = "error", message = $"Could not find material at path: {materialPath}" };
|
||||
return new ErrorResponse($"Could not find material at path: {materialPath}");
|
||||
}
|
||||
|
||||
Shader shader = mat.shader;
|
||||
|
|
@ -448,12 +448,11 @@ namespace MCPForUnity.Editor.Tools
|
|||
}
|
||||
#endif
|
||||
|
||||
return new {
|
||||
status = "success",
|
||||
return new SuccessResponse($"Retrieved material info for {mat.name}", new {
|
||||
material = mat.name,
|
||||
shader = shader.name,
|
||||
properties = properties
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
private static object CreateMaterial(JObject @params)
|
||||
|
|
@ -470,7 +469,7 @@ namespace MCPForUnity.Editor.Tools
|
|||
if (propsToken.Type == JTokenType.String)
|
||||
{
|
||||
try { properties = JObject.Parse(propsToken.ToString()); }
|
||||
catch (Exception ex) { return new { status = "error", message = $"Invalid JSON in properties: {ex.Message}" }; }
|
||||
catch (Exception ex) { return new ErrorResponse($"Invalid JSON in properties: {ex.Message}"); }
|
||||
}
|
||||
else if (propsToken is JObject obj)
|
||||
{
|
||||
|
|
@ -480,26 +479,26 @@ namespace MCPForUnity.Editor.Tools
|
|||
|
||||
if (string.IsNullOrEmpty(materialPath))
|
||||
{
|
||||
return new { status = "error", message = "materialPath is required" };
|
||||
return new ErrorResponse("materialPath is required");
|
||||
}
|
||||
|
||||
// Path normalization handled by helper above, explicit check removed
|
||||
// but we ensure it's valid for CreateAsset
|
||||
// Safety check: SanitizeAssetPath should guarantee Assets/ prefix
|
||||
// This check catches edge cases where normalization might fail
|
||||
if (!materialPath.StartsWith("Assets/"))
|
||||
{
|
||||
return new { status = "error", message = "Path must start with Assets/ (normalization failed)" };
|
||||
return new ErrorResponse($"Invalid path '{materialPath}'. Path must be within Assets/ folder.");
|
||||
}
|
||||
|
||||
Shader shader = RenderPipelineUtility.ResolveShader(shaderName);
|
||||
if (shader == null)
|
||||
{
|
||||
return new { status = "error", message = $"Could not find shader: {shaderName}" };
|
||||
return new ErrorResponse($"Could not find shader: {shaderName}");
|
||||
}
|
||||
|
||||
// Check for existing asset to avoid silent overwrite
|
||||
if (AssetDatabase.LoadAssetAtPath<Material>(materialPath) != null)
|
||||
{
|
||||
return new { status = "error", message = $"Material already exists at {materialPath}" };
|
||||
return new ErrorResponse($"Material already exists at {materialPath}");
|
||||
}
|
||||
|
||||
Material material = null;
|
||||
|
|
@ -534,11 +533,11 @@ namespace MCPForUnity.Editor.Tools
|
|||
Color color;
|
||||
try
|
||||
{
|
||||
color = MaterialOps.ParseColor(colorToken, ManageGameObject.InputSerializer);
|
||||
color = MaterialOps.ParseColor(colorToken, UnityJsonSerializer.Instance);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
return new { status = "error", message = $"Invalid color format: {e.Message}" };
|
||||
return new ErrorResponse($"Invalid color format: {e.Message}");
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(colorProperty))
|
||||
|
|
@ -549,11 +548,7 @@ namespace MCPForUnity.Editor.Tools
|
|||
}
|
||||
else
|
||||
{
|
||||
return new
|
||||
{
|
||||
status = "error",
|
||||
message = $"Specified color property '{colorProperty}' does not exist on this material."
|
||||
};
|
||||
return new ErrorResponse($"Specified color property '{colorProperty}' does not exist on this material.");
|
||||
}
|
||||
}
|
||||
else if (material.HasProperty("_BaseColor"))
|
||||
|
|
@ -566,11 +561,7 @@ namespace MCPForUnity.Editor.Tools
|
|||
}
|
||||
else
|
||||
{
|
||||
return new
|
||||
{
|
||||
status = "error",
|
||||
message = "Could not find suitable color property (_BaseColor or _Color) on this material's shader."
|
||||
};
|
||||
return new ErrorResponse("Could not find suitable color property (_BaseColor or _Color) on this material's shader.");
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -579,13 +570,13 @@ namespace MCPForUnity.Editor.Tools
|
|||
|
||||
if (properties != null)
|
||||
{
|
||||
MaterialOps.ApplyProperties(material, properties, ManageGameObject.InputSerializer);
|
||||
MaterialOps.ApplyProperties(material, properties, UnityJsonSerializer.Instance);
|
||||
}
|
||||
|
||||
EditorUtility.SetDirty(material);
|
||||
AssetDatabase.SaveAssets();
|
||||
|
||||
return new { status = "success", message = $"Created material at {materialPath} with shader {shaderName}" };
|
||||
return new SuccessResponse($"Created material at {materialPath} with shader {shaderName}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
|
|
|
|||
|
|
@ -396,7 +396,8 @@ namespace MCPForUnity.Editor.Tools
|
|||
Camera cam = Camera.main;
|
||||
if (cam == null)
|
||||
{
|
||||
var cams = UnityEngine.Object.FindObjectsByType<Camera>(FindObjectsSortMode.None);
|
||||
// Use FindObjectsOfType for Unity 2021 compatibility
|
||||
var cams = UnityEngine.Object.FindObjectsOfType<Camera>();
|
||||
cam = cams.FirstOrDefault();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -887,45 +887,12 @@ namespace MCPForUnity.Editor.Tools
|
|||
return normalized == "create" || normalized == "createso";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves a type by name. Delegates to UnityTypeResolver.ResolveAny().
|
||||
/// </summary>
|
||||
private static Type ResolveType(string typeName)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(typeName)) return null;
|
||||
|
||||
var type = Type.GetType(typeName, throwOnError: false);
|
||||
if (type != null) return type;
|
||||
|
||||
foreach (var asm in AppDomain.CurrentDomain.GetAssemblies().Where(a => a != null && !a.IsDynamic))
|
||||
{
|
||||
try
|
||||
{
|
||||
type = asm.GetType(typeName, throwOnError: false);
|
||||
if (type != null) return type;
|
||||
}
|
||||
catch
|
||||
{
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
// fallback: scan types by FullName match (covers cases where GetType lookup fails)
|
||||
foreach (var asm in AppDomain.CurrentDomain.GetAssemblies().Where(a => a != null && !a.IsDynamic))
|
||||
{
|
||||
Type[] types;
|
||||
try { types = asm.GetTypes(); }
|
||||
catch (ReflectionTypeLoadException e) { types = e.Types.Where(t => t != null).ToArray(); }
|
||||
catch { continue; }
|
||||
|
||||
foreach (var t in types)
|
||||
{
|
||||
if (t == null) continue;
|
||||
if (string.Equals(t.FullName, typeName, StringComparison.Ordinal))
|
||||
{
|
||||
return t;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
return Helpers.UnityTypeResolver.ResolveAny(typeName);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -547,17 +547,36 @@ namespace MCPForUnity.Editor.Windows.Components.Connection
|
|||
// Wait briefly for the HTTP server to become ready, then start the session.
|
||||
// This is called when THIS instance starts the server (not when detecting an external server).
|
||||
var bridgeService = MCPServiceLocator.Bridge;
|
||||
const int maxAttempts = 10;
|
||||
var delay = TimeSpan.FromSeconds(1);
|
||||
// Windows/dev mode may take much longer due to uv package resolution, fresh downloads, antivirus scans, etc.
|
||||
const int maxAttempts = 30;
|
||||
// Use shorter delays initially, then longer delays to allow server startup
|
||||
var shortDelay = TimeSpan.FromMilliseconds(500);
|
||||
var longDelay = TimeSpan.FromSeconds(3);
|
||||
|
||||
for (int attempt = 0; attempt < maxAttempts; attempt++)
|
||||
{
|
||||
var delay = attempt < 6 ? shortDelay : longDelay;
|
||||
|
||||
// Check if server is actually accepting connections
|
||||
if (!MCPServiceLocator.Server.IsLocalHttpServerRunning())
|
||||
bool serverDetected = MCPServiceLocator.Server.IsLocalHttpServerRunning();
|
||||
|
||||
if (serverDetected)
|
||||
{
|
||||
await Task.Delay(delay);
|
||||
continue;
|
||||
// Server detected - try to connect
|
||||
bool started = await bridgeService.StartAsync();
|
||||
if (started)
|
||||
{
|
||||
await VerifyBridgeConnectionAsync();
|
||||
UpdateConnectionStatus();
|
||||
return;
|
||||
}
|
||||
}
|
||||
else if (attempt >= 20)
|
||||
{
|
||||
// After many attempts without detection, try connecting anyway as a last resort.
|
||||
// This handles cases where process detection fails but the server is actually running.
|
||||
// Only try once every 3 attempts to avoid spamming connection errors (at attempts 20, 23, 26, 29).
|
||||
if ((attempt - 20) % 3 != 0) continue;
|
||||
|
||||
bool started = await bridgeService.StartAsync();
|
||||
if (started)
|
||||
|
|
@ -566,6 +585,7 @@ namespace MCPForUnity.Editor.Windows.Components.Connection
|
|||
UpdateConnectionStatus();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (attempt < maxAttempts - 1)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -160,6 +160,34 @@ namespace MCPForUnity.Runtime.Serialization
|
|||
}
|
||||
}
|
||||
|
||||
public class Vector4Converter : JsonConverter<Vector4>
|
||||
{
|
||||
public override void WriteJson(JsonWriter writer, Vector4 value, JsonSerializer serializer)
|
||||
{
|
||||
writer.WriteStartObject();
|
||||
writer.WritePropertyName("x");
|
||||
writer.WriteValue(value.x);
|
||||
writer.WritePropertyName("y");
|
||||
writer.WriteValue(value.y);
|
||||
writer.WritePropertyName("z");
|
||||
writer.WriteValue(value.z);
|
||||
writer.WritePropertyName("w");
|
||||
writer.WriteValue(value.w);
|
||||
writer.WriteEndObject();
|
||||
}
|
||||
|
||||
public override Vector4 ReadJson(JsonReader reader, Type objectType, Vector4 existingValue, bool hasExistingValue, JsonSerializer serializer)
|
||||
{
|
||||
JObject jo = JObject.Load(reader);
|
||||
return new Vector4(
|
||||
(float)jo["x"],
|
||||
(float)jo["y"],
|
||||
(float)jo["z"],
|
||||
(float)jo["w"]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Safe converter for Matrix4x4 that only accesses raw matrix elements (m00-m33).
|
||||
/// Avoids computed properties (lossyScale, rotation, inverse) that call ValidTRS()
|
||||
|
|
|
|||
|
|
@ -352,9 +352,9 @@ Replace `YOUR_USERNAME` and `AppSupport` path segments as needed for your platfo
|
|||
|
||||
### 💡 Performance Tip: Use `batch_execute`
|
||||
|
||||
When performing multiple operations, use the `batch_execute` tool instead of calling tools one-by-one. This dramatically reduces latency and token costs:
|
||||
When performing multiple operations, use the `batch_execute` tool instead of calling tools one-by-one. This dramatically reduces latency and token costs (supports up to 25 commands per batch):
|
||||
|
||||
```
|
||||
```text
|
||||
❌ Slow: Create 5 cubes → 5 separate manage_gameobject calls
|
||||
✅ Fast: Create 5 cubes → 1 batch_execute call with 5 commands
|
||||
|
||||
|
|
|
|||
|
|
@ -20,7 +20,8 @@ MAX_COMMANDS_PER_BATCH = 25
|
|||
"Executes multiple MCP commands in a single batch for dramatically better performance. "
|
||||
"STRONGLY RECOMMENDED when creating/modifying multiple objects, adding components to multiple targets, "
|
||||
"or performing any repetitive operations. Reduces latency and token costs by 10-100x compared to "
|
||||
"sequential tool calls. Example: creating 5 cubes → use 1 batch_execute with 5 create commands instead of 5 separate calls."
|
||||
"sequential tool calls. Supports up to 25 commands per batch. "
|
||||
"Example: creating 5 cubes → use 1 batch_execute with 5 create commands instead of 5 separate calls."
|
||||
),
|
||||
annotations=ToolAnnotations(
|
||||
title="Batch Execute",
|
||||
|
|
|
|||
|
|
@ -25,8 +25,6 @@ async def execute_menu_item(
|
|||
menu_path: Annotated[str,
|
||||
"Menu path for 'execute' or 'exists' (e.g., 'File/Save Project')"] | None = None,
|
||||
) -> MCPResponse:
|
||||
# Get active instance from session state
|
||||
# Removed session_state import
|
||||
unity_instance = get_unity_instance_from_context(ctx)
|
||||
params_dict: dict[str, Any] = {"menuPath": menu_path}
|
||||
params_dict = {k: v for k, v in params_dict.items() if v is not None}
|
||||
|
|
|
|||
|
|
@ -40,16 +40,17 @@ async def find_gameobjects(
|
|||
"""
|
||||
unity_instance = get_unity_instance_from_context(ctx)
|
||||
|
||||
gate = await preflight(ctx, wait_for_no_compile=True, refresh_if_dirty=True)
|
||||
if gate is not None:
|
||||
return gate.model_dump()
|
||||
|
||||
# Validate required parameters before preflight I/O
|
||||
if not search_term:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Missing required parameter 'search_term'. Specify what to search for."
|
||||
}
|
||||
|
||||
gate = await preflight(ctx, wait_for_no_compile=True, refresh_if_dirty=True)
|
||||
if gate is not None:
|
||||
return gate.model_dump()
|
||||
|
||||
# Coerce parameters
|
||||
include_inactive = coerce_bool(include_inactive, default=False)
|
||||
page_size = coerce_int(page_size, default=50)
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
"""
|
||||
Defines the manage_asset tool for interacting with Unity assets.
|
||||
"""
|
||||
import ast
|
||||
import asyncio
|
||||
import json
|
||||
from typing import Annotated, Any, Literal
|
||||
|
|
@ -11,47 +10,12 @@ from mcp.types import ToolAnnotations
|
|||
|
||||
from services.registry import mcp_for_unity_tool
|
||||
from services.tools import get_unity_instance_from_context
|
||||
from services.tools.utils import parse_json_payload, coerce_int
|
||||
from services.tools.utils import parse_json_payload, coerce_int, normalize_properties
|
||||
from transport.unity_transport import send_with_unity_instance
|
||||
from transport.legacy.unity_connection import async_send_command_with_retry
|
||||
from services.tools.preflight import preflight
|
||||
|
||||
|
||||
def _normalize_properties(value: Any) -> tuple[dict[str, Any] | None, str | None]:
|
||||
"""
|
||||
Robustly normalize properties parameter to a dict.
|
||||
Returns (parsed_dict, error_message). If error_message is set, parsed_dict is None.
|
||||
"""
|
||||
if value is None:
|
||||
return {}, None
|
||||
|
||||
# Already a dict - return as-is
|
||||
if isinstance(value, dict):
|
||||
return value, None
|
||||
|
||||
# Try parsing as string
|
||||
if isinstance(value, str):
|
||||
# Check for obviously invalid values from serialization bugs
|
||||
if value in ("[object Object]", "undefined", "null", ""):
|
||||
return None, f"properties received invalid value: '{value}'. Expected a JSON object like {{\"key\": value}}"
|
||||
|
||||
# Try JSON parsing first
|
||||
parsed = parse_json_payload(value)
|
||||
if isinstance(parsed, dict):
|
||||
return parsed, None
|
||||
|
||||
# Fallback to ast.literal_eval for Python dict literals
|
||||
try:
|
||||
parsed = ast.literal_eval(value)
|
||||
if isinstance(parsed, dict):
|
||||
return parsed, None
|
||||
return None, f"properties must evaluate to a dict, got {type(parsed).__name__}"
|
||||
except (ValueError, SyntaxError) as e:
|
||||
return None, f"Failed to parse properties: {e}"
|
||||
|
||||
return None, f"properties must be a dict or JSON string, got {type(value).__name__}"
|
||||
|
||||
|
||||
@mcp_for_unity_tool(
|
||||
description=(
|
||||
"Performs asset operations (import, create, modify, delete, etc.) in Unity.\n\n"
|
||||
|
|
@ -96,7 +60,7 @@ async def manage_asset(
|
|||
return gate.model_dump()
|
||||
|
||||
# --- Normalize properties using robust module-level helper ---
|
||||
properties, parse_error = _normalize_properties(properties)
|
||||
properties, parse_error = normalize_properties(properties)
|
||||
if parse_error:
|
||||
await ctx.error(f"manage_asset: {parse_error}")
|
||||
return {"success": False, "message": parse_error}
|
||||
|
|
|
|||
|
|
@ -9,37 +9,10 @@ from services.registry import mcp_for_unity_tool
|
|||
from services.tools import get_unity_instance_from_context
|
||||
from transport.unity_transport import send_with_unity_instance
|
||||
from transport.legacy.unity_connection import async_send_command_with_retry
|
||||
from services.tools.utils import parse_json_payload
|
||||
from services.tools.utils import parse_json_payload, normalize_properties
|
||||
from services.tools.preflight import preflight
|
||||
|
||||
|
||||
def _normalize_properties(value: Any) -> tuple[dict[str, Any] | None, str | None]:
|
||||
"""
|
||||
Robustly normalize properties parameter to a dict.
|
||||
Returns (parsed_dict, error_message). If error_message is set, parsed_dict is None.
|
||||
"""
|
||||
if value is None:
|
||||
return None, None
|
||||
|
||||
# Already a dict - return as-is
|
||||
if isinstance(value, dict):
|
||||
return value, None
|
||||
|
||||
# Try parsing as string
|
||||
if isinstance(value, str):
|
||||
# Check for obviously invalid values from serialization bugs
|
||||
if value in ("[object Object]", "undefined", "null", ""):
|
||||
return None, f"properties received invalid value: '{value}'. Expected a JSON object like {{\"propertyName\": value}}"
|
||||
|
||||
parsed = parse_json_payload(value)
|
||||
if isinstance(parsed, dict):
|
||||
return parsed, None
|
||||
|
||||
return None, f"properties must be a JSON object (dict), got string that parsed to {type(parsed).__name__}"
|
||||
|
||||
return None, f"properties must be a dict or JSON string, got {type(value).__name__}"
|
||||
|
||||
|
||||
@mcp_for_unity_tool(
|
||||
description="Manages components on GameObjects (add, remove, set_property). For reading component data, use the unity://scene/gameobject/{id}/components resource."
|
||||
)
|
||||
|
|
@ -109,7 +82,7 @@ async def manage_components(
|
|||
}
|
||||
|
||||
# --- Normalize properties with detailed error handling ---
|
||||
properties, props_error = _normalize_properties(properties)
|
||||
properties, props_error = normalize_properties(properties)
|
||||
if props_error:
|
||||
return {"success": False, "message": props_error}
|
||||
|
||||
|
|
|
|||
|
|
@ -46,13 +46,9 @@ async def manage_editor(
|
|||
params = {
|
||||
"action": action,
|
||||
"waitForCompletion": wait_for_completion,
|
||||
"toolName": tool_name, # Corrected parameter name to match C#
|
||||
"tagName": tag_name, # Pass tag name
|
||||
"layerName": layer_name, # Pass layer name
|
||||
# Add other parameters based on the action being performed
|
||||
# "width": width,
|
||||
# "height": height,
|
||||
# etc.
|
||||
"toolName": tool_name,
|
||||
"tagName": tag_name,
|
||||
"layerName": layer_name,
|
||||
}
|
||||
params = {k: v for k, v in params.items() if v is not None}
|
||||
|
||||
|
|
|
|||
|
|
@ -83,7 +83,7 @@ def _normalize_component_properties(value: Any) -> tuple[dict[str, dict[str, Any
|
|||
|
||||
|
||||
@mcp_for_unity_tool(
|
||||
description="Performs CRUD operations on GameObjects and components. Read-only actions: find, get_components, get_component. Modifying actions: create, modify, delete, add_component, remove_component, set_component_property, duplicate, move_relative.",
|
||||
description="Performs CRUD operations on GameObjects. Actions: create, modify, delete, duplicate, move_relative. For finding GameObjects use find_gameobjects tool. For component operations use manage_components tool.",
|
||||
annotations=ToolAnnotations(
|
||||
title="Manage GameObject",
|
||||
destructiveHint=True,
|
||||
|
|
@ -91,7 +91,7 @@ def _normalize_component_properties(value: Any) -> tuple[dict[str, dict[str, Any
|
|||
)
|
||||
async def manage_gameobject(
|
||||
ctx: Context,
|
||||
action: Annotated[Literal["create", "modify", "delete", "find", "add_component", "remove_component", "set_component_property", "get_components", "get_component", "duplicate", "move_relative"], "Perform CRUD operations on GameObjects and components."] | None = None,
|
||||
action: Annotated[Literal["create", "modify", "delete", "duplicate", "move_relative"], "Action to perform on GameObject."] | None = None,
|
||||
target: Annotated[str,
|
||||
"GameObject identifier by name or path for modify/delete/component actions"] | None = None,
|
||||
search_method: Annotated[Literal["by_id", "by_name", "by_path", "by_tag", "by_layer", "by_component"],
|
||||
|
|
@ -175,7 +175,7 @@ async def manage_gameobject(
|
|||
if action is None:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Missing required parameter 'action'. Valid actions: create, modify, delete, find, add_component, remove_component, set_component_property, get_components, get_component, duplicate, move_relative"
|
||||
"message": "Missing required parameter 'action'. Valid actions: create, modify, delete, duplicate, move_relative. For finding GameObjects use find_gameobjects tool. For component operations use manage_components tool."
|
||||
}
|
||||
|
||||
# --- Normalize vector parameters using robust helper ---
|
||||
|
|
@ -205,23 +205,7 @@ async def manage_gameobject(
|
|||
return {"success": False, "message": comp_props_error}
|
||||
|
||||
try:
|
||||
# Map tag to search_term when search_method is by_tag for backward compatibility
|
||||
if action == "find" and search_method == "by_tag" and tag is not None and search_term is None:
|
||||
search_term = tag
|
||||
|
||||
# Validate parameter usage to prevent silent failures
|
||||
if action == "find":
|
||||
if name is not None:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "For 'find' action, use 'search_term' parameter, not 'name'. Remove 'name' parameter. Example: search_term='Player', search_method='by_name'"
|
||||
}
|
||||
if search_term is None:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "For 'find' action, 'search_term' parameter is required. Use search_term (not 'name') to specify what to find."
|
||||
}
|
||||
|
||||
if action in ["create", "modify"]:
|
||||
if search_term is not None:
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ from mcp.types import ToolAnnotations
|
|||
|
||||
from services.registry import mcp_for_unity_tool
|
||||
from services.tools import get_unity_instance_from_context
|
||||
from services.tools.utils import parse_json_payload, coerce_int
|
||||
from services.tools.utils import parse_json_payload, coerce_int, normalize_properties
|
||||
from transport.unity_transport import send_with_unity_instance
|
||||
from transport.legacy.unity_connection import async_send_command_with_retry
|
||||
|
||||
|
|
@ -47,28 +47,6 @@ def _normalize_color(value: Any) -> tuple[list[float] | None, str | None]:
|
|||
return None, f"color must be a list or JSON string, got {type(value).__name__}"
|
||||
|
||||
|
||||
def _normalize_properties(value: Any) -> tuple[dict[str, Any] | None, str | None]:
|
||||
"""
|
||||
Normalize properties parameter to a dict.
|
||||
"""
|
||||
if value is None:
|
||||
return None, None
|
||||
|
||||
if isinstance(value, dict):
|
||||
return value, None
|
||||
|
||||
if isinstance(value, str):
|
||||
if value in ("[object Object]", "undefined", "null", ""):
|
||||
return None, f"properties received invalid value: '{value}'. Expected a JSON object"
|
||||
|
||||
parsed = parse_json_payload(value)
|
||||
if isinstance(parsed, dict):
|
||||
return parsed, None
|
||||
return None, f"properties must parse to a dict, got {type(parsed).__name__}"
|
||||
|
||||
return None, f"properties must be a dict or JSON string, got {type(value).__name__}"
|
||||
|
||||
|
||||
@mcp_for_unity_tool(
|
||||
description="Manages Unity materials (set properties, colors, shaders, etc). Read-only actions: ping, get_material_info. Modifying actions: create, set_material_shader_property, set_material_color, assign_material_to_renderer, set_renderer_color.",
|
||||
annotations=ToolAnnotations(
|
||||
|
|
@ -117,7 +95,7 @@ async def manage_material(
|
|||
return {"success": False, "message": color_error}
|
||||
|
||||
# --- Normalize properties with validation ---
|
||||
properties, props_error = _normalize_properties(properties)
|
||||
properties, props_error = normalize_properties(properties)
|
||||
if props_error:
|
||||
return {"success": False, "message": props_error}
|
||||
|
||||
|
|
|
|||
|
|
@ -513,7 +513,7 @@ async def manage_script(
|
|||
name: Annotated[str, "Script name (no .cs extension)", "Name of the script to create"],
|
||||
path: Annotated[str, "Asset path (default: 'Assets/')", "Path under Assets/ to create the script at, e.g., 'Assets/Scripts/My.cs'"],
|
||||
contents: Annotated[str, "Contents of the script to create",
|
||||
"C# code for 'create'/'update'"] | None = None,
|
||||
"C# code for 'create' action"] | None = None,
|
||||
script_type: Annotated[str, "Script type (e.g., 'C#')",
|
||||
"Type hint (e.g., 'MonoBehaviour')"] | None = None,
|
||||
namespace: Annotated[str, "Namespace for the script"] | None = None,
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ from transport.legacy.unity_connection import async_send_command_with_retry
|
|||
description="Manages shader scripts in Unity (create, read, update, delete). Read-only action: read. Modifying actions: create, update, delete.",
|
||||
annotations=ToolAnnotations(
|
||||
title="Manage Shader",
|
||||
destructiveHint=True,
|
||||
destructiveHint=True, # Note: 'read' action is non-destructive; 'create', 'update', 'delete' are destructive
|
||||
),
|
||||
)
|
||||
async def manage_shader(
|
||||
|
|
|
|||
|
|
@ -75,3 +75,38 @@ def coerce_int(value: Any, default: int | None = None) -> int | None:
|
|||
return int(float(s))
|
||||
except Exception:
|
||||
return default
|
||||
|
||||
|
||||
def normalize_properties(value: Any) -> tuple[dict[str, Any] | None, str | None]:
|
||||
"""
|
||||
Robustly normalize a properties parameter to a dict.
|
||||
|
||||
Handles various input formats from MCP clients/LLMs:
|
||||
- None -> (None, None)
|
||||
- dict -> (dict, None)
|
||||
- JSON string -> (parsed_dict, None) or (None, error_message)
|
||||
- Invalid values -> (None, error_message)
|
||||
|
||||
Returns:
|
||||
Tuple of (parsed_dict, error_message). If error_message is set, parsed_dict is None.
|
||||
"""
|
||||
if value is None:
|
||||
return None, None
|
||||
|
||||
# Already a dict - return as-is
|
||||
if isinstance(value, dict):
|
||||
return value, None
|
||||
|
||||
# Try parsing as string
|
||||
if isinstance(value, str):
|
||||
# Check for obviously invalid values from serialization bugs
|
||||
if value in ("[object Object]", "undefined", "null", ""):
|
||||
return None, f"properties received invalid value: '{value}'. Expected a JSON object like {{\"key\": value}}"
|
||||
|
||||
parsed = parse_json_payload(value)
|
||||
if isinstance(parsed, dict):
|
||||
return parsed, None
|
||||
|
||||
return None, f"properties must be a JSON object (dict), got string that parsed to {type(parsed).__name__}"
|
||||
|
||||
return None, f"properties must be a dict or JSON string, got {type(value).__name__}"
|
||||
|
|
|
|||
|
|
@ -57,7 +57,7 @@ class TestManageAssetJsonParsing:
|
|||
|
||||
# Verify behavior: parsing fails with a clear error
|
||||
assert result.get("success") is False
|
||||
assert "Failed to parse properties" in result.get("message", "")
|
||||
assert "properties must be" in result.get("message", "")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_properties_dict_unchanged(self, monkeypatch):
|
||||
|
|
|
|||
|
|
@ -5,7 +5,8 @@ import services.tools.manage_gameobject as manage_go_mod
|
|||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_manage_gameobject_boolean_and_tag_mapping(monkeypatch):
|
||||
async def test_manage_gameobject_boolean_coercion(monkeypatch):
|
||||
"""Test that string boolean values are properly coerced for valid actions."""
|
||||
captured = {}
|
||||
|
||||
async def fake_send(cmd, params, **kwargs):
|
||||
|
|
@ -18,26 +19,24 @@ async def test_manage_gameobject_boolean_and_tag_mapping(monkeypatch):
|
|||
fake_send,
|
||||
)
|
||||
|
||||
# find by tag: allow tag to map to searchTerm
|
||||
# Test boolean coercion with "modify" action (valid action)
|
||||
resp = await manage_go_mod.manage_gameobject(
|
||||
ctx=DummyContext(),
|
||||
action="find",
|
||||
search_method="by_tag",
|
||||
tag="Player",
|
||||
find_all="true",
|
||||
search_inactive="0",
|
||||
action="modify",
|
||||
target="Player",
|
||||
set_active="true", # String should be coerced to bool
|
||||
)
|
||||
# Loosen equality: wrapper may include a diagnostic message
|
||||
|
||||
assert resp.get("success") is True
|
||||
assert "data" in resp
|
||||
# ensure tag mapped to searchTerm and booleans passed through; C# side coerces true/false already
|
||||
assert captured["params"]["searchTerm"] == "Player"
|
||||
assert captured["params"]["findAll"] is True
|
||||
assert captured["params"]["searchInactive"] is False
|
||||
assert captured["params"]["action"] == "modify"
|
||||
assert captured["params"]["target"] == "Player"
|
||||
# setActive string "true" is coerced to bool True
|
||||
assert captured["params"]["setActive"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_manage_gameobject_get_components_paging_params_pass_through(monkeypatch):
|
||||
async def test_manage_gameobject_create_with_tag(monkeypatch):
|
||||
"""Test that create action properly passes tag parameter."""
|
||||
captured = {}
|
||||
|
||||
async def fake_send(cmd, params, **kwargs):
|
||||
|
|
@ -52,21 +51,15 @@ async def test_manage_gameobject_get_components_paging_params_pass_through(monke
|
|||
|
||||
resp = await manage_go_mod.manage_gameobject(
|
||||
ctx=DummyContext(),
|
||||
action="get_components",
|
||||
target="Player",
|
||||
search_method="by_name",
|
||||
page_size="25",
|
||||
cursor="50",
|
||||
max_components="100",
|
||||
include_properties="true",
|
||||
action="create",
|
||||
name="TestObject",
|
||||
tag="Player",
|
||||
position=[1.0, 2.0, 3.0],
|
||||
)
|
||||
|
||||
assert resp.get("success") is True
|
||||
p = captured["params"]
|
||||
assert p["action"] == "get_components"
|
||||
assert p["target"] == "Player"
|
||||
assert p["searchMethod"] == "by_name"
|
||||
assert p["pageSize"] == 25
|
||||
assert p["cursor"] == 50
|
||||
assert p["maxComponents"] == 100
|
||||
assert p["includeProperties"] is True
|
||||
assert p["action"] == "create"
|
||||
assert p["name"] == "TestObject"
|
||||
assert p["tag"] == "Player"
|
||||
assert p["position"] == [1.0, 2.0, 3.0]
|
||||
|
|
|
|||
|
|
@ -21,9 +21,9 @@ public class LongUnityScriptClaudeTest : MonoBehaviour
|
|||
private int padAccumulator = 0;
|
||||
private Vector3 padVector = Vector3.zero;
|
||||
|
||||
// Animation blend hashes
|
||||
private static readonly int BlendXHash = Animator.StringToHash("BlendX");
|
||||
private static readonly int BlendYHash = Animator.StringToHash("BlendY");
|
||||
// Animation blend hashes (match animator parameter names)
|
||||
private static readonly int BlendXHash = Animator.StringToHash("reachX");
|
||||
private static readonly int BlendYHash = Animator.StringToHash("reachY");
|
||||
|
||||
|
||||
[Header("Tuning")]
|
||||
|
|
|
|||
|
|
@ -0,0 +1,8 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 93766a50487224f02b29aae42971e08b
|
||||
NativeFormatImporter:
|
||||
externalObjects: {}
|
||||
mainObjectFileID: 0
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 20332651bb6f64cadb92cf3c6d68bed5
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
@ -0,0 +1,209 @@
|
|||
using System.Collections.Generic;
|
||||
using NUnit.Framework;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using MCPForUnity.Editor.Helpers;
|
||||
|
||||
namespace MCPForUnityTests.Editor.Helpers
|
||||
{
|
||||
/// <summary>
|
||||
/// Tests for the standard Pagination classes.
|
||||
/// </summary>
|
||||
public class PaginationTests
|
||||
{
|
||||
#region PaginationRequest Tests
|
||||
|
||||
[Test]
|
||||
public void PaginationRequest_FromParams_ParsesPageSizeSnakeCase()
|
||||
{
|
||||
var p = new JObject { ["page_size"] = 25 };
|
||||
var req = PaginationRequest.FromParams(p);
|
||||
Assert.AreEqual(25, req.PageSize);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void PaginationRequest_FromParams_ParsesPageSizeCamelCase()
|
||||
{
|
||||
var p = new JObject { ["pageSize"] = 30 };
|
||||
var req = PaginationRequest.FromParams(p);
|
||||
Assert.AreEqual(30, req.PageSize);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void PaginationRequest_FromParams_ParsesCursor()
|
||||
{
|
||||
var p = new JObject { ["cursor"] = 50 };
|
||||
var req = PaginationRequest.FromParams(p);
|
||||
Assert.AreEqual(50, req.Cursor);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void PaginationRequest_FromParams_ConvertsPageNumberToCursor()
|
||||
{
|
||||
// page_number is 1-based, should convert to 0-based cursor
|
||||
var p = new JObject { ["page_number"] = 3, ["page_size"] = 10 };
|
||||
var req = PaginationRequest.FromParams(p);
|
||||
// Page 3 with page size 10 means items 20-29, so cursor should be 20
|
||||
Assert.AreEqual(20, req.Cursor);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void PaginationRequest_FromParams_CursorTakesPrecedenceOverPageNumber()
|
||||
{
|
||||
// If both cursor and page_number are specified, cursor should win
|
||||
var p = new JObject { ["cursor"] = 100, ["page_number"] = 1 };
|
||||
var req = PaginationRequest.FromParams(p);
|
||||
Assert.AreEqual(100, req.Cursor);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void PaginationRequest_FromParams_UsesDefaultsForNullParams()
|
||||
{
|
||||
var req = PaginationRequest.FromParams(null);
|
||||
Assert.AreEqual(50, req.PageSize);
|
||||
Assert.AreEqual(0, req.Cursor);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void PaginationRequest_FromParams_UsesDefaultsForEmptyParams()
|
||||
{
|
||||
var req = PaginationRequest.FromParams(new JObject());
|
||||
Assert.AreEqual(50, req.PageSize);
|
||||
Assert.AreEqual(0, req.Cursor);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void PaginationRequest_FromParams_AcceptsCustomDefaultPageSize()
|
||||
{
|
||||
var req = PaginationRequest.FromParams(new JObject(), defaultPageSize: 100);
|
||||
Assert.AreEqual(100, req.PageSize);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void PaginationRequest_FromParams_HandleStringValues()
|
||||
{
|
||||
// Some clients might send string values
|
||||
var p = new JObject { ["page_size"] = "15", ["cursor"] = "5" };
|
||||
var req = PaginationRequest.FromParams(p);
|
||||
Assert.AreEqual(15, req.PageSize);
|
||||
Assert.AreEqual(5, req.Cursor);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region PaginationResponse Tests
|
||||
|
||||
[Test]
|
||||
public void PaginationResponse_Create_ReturnsCorrectPageOfItems()
|
||||
{
|
||||
var allItems = new List<int> { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 };
|
||||
var request = new PaginationRequest { PageSize = 3, Cursor = 0 };
|
||||
|
||||
var response = PaginationResponse<int>.Create(allItems, request);
|
||||
|
||||
Assert.AreEqual(3, response.Items.Count);
|
||||
Assert.AreEqual(new List<int> { 1, 2, 3 }, response.Items);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void PaginationResponse_Create_ReturnsCorrectMiddlePage()
|
||||
{
|
||||
var allItems = new List<int> { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 };
|
||||
var request = new PaginationRequest { PageSize = 3, Cursor = 3 };
|
||||
|
||||
var response = PaginationResponse<int>.Create(allItems, request);
|
||||
|
||||
Assert.AreEqual(3, response.Items.Count);
|
||||
Assert.AreEqual(new List<int> { 4, 5, 6 }, response.Items);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void PaginationResponse_Create_HandlesLastPage()
|
||||
{
|
||||
var allItems = new List<int> { 1, 2, 3, 4, 5 };
|
||||
var request = new PaginationRequest { PageSize = 3, Cursor = 3 };
|
||||
|
||||
var response = PaginationResponse<int>.Create(allItems, request);
|
||||
|
||||
Assert.AreEqual(2, response.Items.Count);
|
||||
Assert.AreEqual(new List<int> { 4, 5 }, response.Items);
|
||||
Assert.IsNull(response.NextCursor);
|
||||
Assert.IsFalse(response.HasMore);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void PaginationResponse_HasMore_TrueWhenNextCursorSet()
|
||||
{
|
||||
var allItems = new List<int> { 1, 2, 3, 4, 5, 6 };
|
||||
var request = new PaginationRequest { PageSize = 3, Cursor = 0 };
|
||||
|
||||
var response = PaginationResponse<int>.Create(allItems, request);
|
||||
|
||||
Assert.IsTrue(response.HasMore);
|
||||
Assert.AreEqual(3, response.NextCursor);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void PaginationResponse_HasMore_FalseWhenNoMoreItems()
|
||||
{
|
||||
var allItems = new List<int> { 1, 2, 3 };
|
||||
var request = new PaginationRequest { PageSize = 10, Cursor = 0 };
|
||||
|
||||
var response = PaginationResponse<int>.Create(allItems, request);
|
||||
|
||||
Assert.IsFalse(response.HasMore);
|
||||
Assert.IsNull(response.NextCursor);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void PaginationResponse_Create_SetsCorrectTotalCount()
|
||||
{
|
||||
var allItems = new List<string> { "a", "b", "c", "d", "e" };
|
||||
var request = new PaginationRequest { PageSize = 2, Cursor = 0 };
|
||||
|
||||
var response = PaginationResponse<string>.Create(allItems, request);
|
||||
|
||||
Assert.AreEqual(5, response.TotalCount);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void PaginationResponse_Create_HandlesEmptyList()
|
||||
{
|
||||
var allItems = new List<int>();
|
||||
var request = new PaginationRequest { PageSize = 10, Cursor = 0 };
|
||||
|
||||
var response = PaginationResponse<int>.Create(allItems, request);
|
||||
|
||||
Assert.AreEqual(0, response.Items.Count);
|
||||
Assert.AreEqual(0, response.TotalCount);
|
||||
Assert.IsNull(response.NextCursor);
|
||||
Assert.IsFalse(response.HasMore);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void PaginationResponse_Create_ClampsCursorToValidRange()
|
||||
{
|
||||
var allItems = new List<int> { 1, 2, 3 };
|
||||
var request = new PaginationRequest { PageSize = 10, Cursor = 100 };
|
||||
|
||||
var response = PaginationResponse<int>.Create(allItems, request);
|
||||
|
||||
Assert.AreEqual(0, response.Items.Count);
|
||||
Assert.AreEqual(3, response.Cursor); // Clamped to totalCount
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void PaginationResponse_Create_HandlesNegativeCursor()
|
||||
{
|
||||
var allItems = new List<int> { 1, 2, 3 };
|
||||
var request = new PaginationRequest { PageSize = 10, Cursor = -5 };
|
||||
|
||||
var response = PaginationResponse<int>.Create(allItems, request);
|
||||
|
||||
Assert.AreEqual(0, response.Cursor); // Clamped to 0
|
||||
Assert.AreEqual(3, response.Items.Count);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
fileFormatVersion: 2
|
||||
guid: a6d0177f4432b41c6bf7e0013cd5a2f2
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
@ -47,42 +47,42 @@ namespace MCPForUnityTests.Editor.Tools
|
|||
}
|
||||
|
||||
[Test]
|
||||
public void GetAIPropertySuggestions_ReturnsEmpty_ForNullInput()
|
||||
public void GetFuzzyPropertySuggestions_ReturnsEmpty_ForNullInput()
|
||||
{
|
||||
var suggestions = ComponentResolver.GetAIPropertySuggestions(null, sampleProperties);
|
||||
var suggestions = ComponentResolver.GetFuzzyPropertySuggestions(null, sampleProperties);
|
||||
|
||||
Assert.IsEmpty(suggestions, "Null input should return no suggestions");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void GetAIPropertySuggestions_ReturnsEmpty_ForEmptyInput()
|
||||
public void GetFuzzyPropertySuggestions_ReturnsEmpty_ForEmptyInput()
|
||||
{
|
||||
var suggestions = ComponentResolver.GetAIPropertySuggestions("", sampleProperties);
|
||||
var suggestions = ComponentResolver.GetFuzzyPropertySuggestions("", sampleProperties);
|
||||
|
||||
Assert.IsEmpty(suggestions, "Empty input should return no suggestions");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void GetAIPropertySuggestions_ReturnsEmpty_ForEmptyPropertyList()
|
||||
public void GetFuzzyPropertySuggestions_ReturnsEmpty_ForEmptyPropertyList()
|
||||
{
|
||||
var suggestions = ComponentResolver.GetAIPropertySuggestions("test", new List<string>());
|
||||
var suggestions = ComponentResolver.GetFuzzyPropertySuggestions("test", new List<string>());
|
||||
|
||||
Assert.IsEmpty(suggestions, "Empty property list should return no suggestions");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void GetAIPropertySuggestions_FindsExactMatch_AfterCleaning()
|
||||
public void GetFuzzyPropertySuggestions_FindsExactMatch_AfterCleaning()
|
||||
{
|
||||
var suggestions = ComponentResolver.GetAIPropertySuggestions("Max Reach Distance", sampleProperties);
|
||||
var suggestions = ComponentResolver.GetFuzzyPropertySuggestions("Max Reach Distance", sampleProperties);
|
||||
|
||||
Assert.Contains("maxReachDistance", suggestions, "Should find exact match after cleaning spaces");
|
||||
Assert.GreaterOrEqual(suggestions.Count, 1, "Should return at least one match for exact match");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void GetAIPropertySuggestions_FindsMultipleWordMatches()
|
||||
public void GetFuzzyPropertySuggestions_FindsMultipleWordMatches()
|
||||
{
|
||||
var suggestions = ComponentResolver.GetAIPropertySuggestions("max distance", sampleProperties);
|
||||
var suggestions = ComponentResolver.GetFuzzyPropertySuggestions("max distance", sampleProperties);
|
||||
|
||||
Assert.Contains("maxReachDistance", suggestions, "Should match maxReachDistance");
|
||||
Assert.Contains("maxHorizontalDistance", suggestions, "Should match maxHorizontalDistance");
|
||||
|
|
@ -90,54 +90,54 @@ namespace MCPForUnityTests.Editor.Tools
|
|||
}
|
||||
|
||||
[Test]
|
||||
public void GetAIPropertySuggestions_FindsSimilarStrings_WithTypos()
|
||||
public void GetFuzzyPropertySuggestions_FindsSimilarStrings_WithTypos()
|
||||
{
|
||||
var suggestions = ComponentResolver.GetAIPropertySuggestions("movespeed", sampleProperties); // missing capital S
|
||||
var suggestions = ComponentResolver.GetFuzzyPropertySuggestions("movespeed", sampleProperties); // missing capital S
|
||||
|
||||
Assert.Contains("moveSpeed", suggestions, "Should find moveSpeed despite missing capital");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void GetAIPropertySuggestions_FindsSemanticMatches_ForCommonTerms()
|
||||
public void GetFuzzyPropertySuggestions_FindsSemanticMatches_ForCommonTerms()
|
||||
{
|
||||
var suggestions = ComponentResolver.GetAIPropertySuggestions("weight", sampleProperties);
|
||||
var suggestions = ComponentResolver.GetFuzzyPropertySuggestions("weight", sampleProperties);
|
||||
|
||||
// Note: Current algorithm might not find "mass" but should handle it gracefully
|
||||
Assert.IsNotNull(suggestions, "Should return valid suggestions list");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void GetAIPropertySuggestions_LimitsResults_ToReasonableNumber()
|
||||
public void GetFuzzyPropertySuggestions_LimitsResults_ToReasonableNumber()
|
||||
{
|
||||
// Test with input that might match many properties
|
||||
var suggestions = ComponentResolver.GetAIPropertySuggestions("m", sampleProperties);
|
||||
var suggestions = ComponentResolver.GetFuzzyPropertySuggestions("m", sampleProperties);
|
||||
|
||||
Assert.LessOrEqual(suggestions.Count, 3, "Should limit suggestions to 3 or fewer");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void GetAIPropertySuggestions_CachesResults()
|
||||
public void GetFuzzyPropertySuggestions_CachesResults()
|
||||
{
|
||||
var input = "Max Reach Distance";
|
||||
|
||||
// First call
|
||||
var suggestions1 = ComponentResolver.GetAIPropertySuggestions(input, sampleProperties);
|
||||
var suggestions1 = ComponentResolver.GetFuzzyPropertySuggestions(input, sampleProperties);
|
||||
|
||||
// Second call should use cache (tested indirectly by ensuring consistency)
|
||||
var suggestions2 = ComponentResolver.GetAIPropertySuggestions(input, sampleProperties);
|
||||
var suggestions2 = ComponentResolver.GetFuzzyPropertySuggestions(input, sampleProperties);
|
||||
|
||||
Assert.AreEqual(suggestions1.Count, suggestions2.Count, "Cached results should be consistent");
|
||||
CollectionAssert.AreEqual(suggestions1, suggestions2, "Cached results should be identical");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void GetAIPropertySuggestions_HandlesUnityNamingConventions()
|
||||
public void GetFuzzyPropertySuggestions_HandlesUnityNamingConventions()
|
||||
{
|
||||
var unityStyleProperties = new List<string> { "isKinematic", "useGravity", "maxLinearVelocity" };
|
||||
|
||||
var suggestions1 = ComponentResolver.GetAIPropertySuggestions("is kinematic", unityStyleProperties);
|
||||
var suggestions2 = ComponentResolver.GetAIPropertySuggestions("use gravity", unityStyleProperties);
|
||||
var suggestions3 = ComponentResolver.GetAIPropertySuggestions("max linear velocity", unityStyleProperties);
|
||||
var suggestions1 = ComponentResolver.GetFuzzyPropertySuggestions("is kinematic", unityStyleProperties);
|
||||
var suggestions2 = ComponentResolver.GetFuzzyPropertySuggestions("use gravity", unityStyleProperties);
|
||||
var suggestions3 = ComponentResolver.GetFuzzyPropertySuggestions("max linear velocity", unityStyleProperties);
|
||||
|
||||
Assert.Contains("isKinematic", suggestions1, "Should handle 'is' prefix convention");
|
||||
Assert.Contains("useGravity", suggestions2, "Should handle 'use' prefix convention");
|
||||
|
|
@ -145,10 +145,10 @@ namespace MCPForUnityTests.Editor.Tools
|
|||
}
|
||||
|
||||
[Test]
|
||||
public void GetAIPropertySuggestions_PrioritizesExactMatches()
|
||||
public void GetFuzzyPropertySuggestions_PrioritizesExactMatches()
|
||||
{
|
||||
var properties = new List<string> { "speed", "moveSpeed", "maxSpeed", "speedMultiplier" };
|
||||
var suggestions = ComponentResolver.GetAIPropertySuggestions("speed", properties);
|
||||
var suggestions = ComponentResolver.GetFuzzyPropertySuggestions("speed", properties);
|
||||
|
||||
Assert.IsNotEmpty(suggestions, "Should find suggestions");
|
||||
Assert.Contains("speed", suggestions, "Exact match should be included in results");
|
||||
|
|
@ -156,10 +156,10 @@ namespace MCPForUnityTests.Editor.Tools
|
|||
}
|
||||
|
||||
[Test]
|
||||
public void GetAIPropertySuggestions_HandlesCaseInsensitive()
|
||||
public void GetFuzzyPropertySuggestions_HandlesCaseInsensitive()
|
||||
{
|
||||
var suggestions1 = ComponentResolver.GetAIPropertySuggestions("MAXREACHDISTANCE", sampleProperties);
|
||||
var suggestions2 = ComponentResolver.GetAIPropertySuggestions("maxreachdistance", sampleProperties);
|
||||
var suggestions1 = ComponentResolver.GetFuzzyPropertySuggestions("MAXREACHDISTANCE", sampleProperties);
|
||||
var suggestions2 = ComponentResolver.GetFuzzyPropertySuggestions("maxreachdistance", sampleProperties);
|
||||
|
||||
Assert.Contains("maxReachDistance", suggestions1, "Should handle uppercase input");
|
||||
Assert.Contains("maxReachDistance", suggestions2, "Should handle lowercase input");
|
||||
|
|
|
|||
|
|
@ -54,7 +54,16 @@ namespace MCPForUnityTests.Editor.Tools
|
|||
private static JObject ToJObject(object result)
|
||||
{
|
||||
if (result == null) return new JObject();
|
||||
return result as JObject ?? JObject.FromObject(result);
|
||||
if (result is JObject jobj) return jobj;
|
||||
try
|
||||
{
|
||||
return JObject.FromObject(result);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Debug.LogWarning($"[ToJObject] Failed to convert result: {ex.Message}");
|
||||
return new JObject { ["error"] = ex.Message };
|
||||
}
|
||||
}
|
||||
|
||||
#region Bulk GameObject Creation
|
||||
|
|
@ -85,7 +94,8 @@ namespace MCPForUnityTests.Editor.Tools
|
|||
|
||||
sw.Stop();
|
||||
Debug.Log($"[BulkCreate] Created {SMALL_BATCH} objects in {sw.ElapsedMilliseconds}ms");
|
||||
Assert.Less(sw.ElapsedMilliseconds, 5000, "Bulk create took too long");
|
||||
// Use generous threshold for CI variability
|
||||
Assert.Less(sw.ElapsedMilliseconds, 10000, "Bulk create took too long (CI threshold)");
|
||||
}
|
||||
|
||||
[Test]
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
using System.Collections.Generic;
|
||||
using NUnit.Framework;
|
||||
using UnityEngine;
|
||||
using UnityEditorInternal;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using MCPForUnity.Editor.Tools;
|
||||
|
||||
|
|
@ -401,25 +402,33 @@ namespace MCPForUnityTests.Editor.Tools
|
|||
}
|
||||
|
||||
[Test]
|
||||
public void Create_WithInvalidTag_HandlesGracefully()
|
||||
public void Create_WithNewTag_AutoCreatesTag()
|
||||
{
|
||||
// Expect the error log from Unity about invalid tag
|
||||
UnityEngine.TestTools.LogAssert.Expect(LogType.Error,
|
||||
new System.Text.RegularExpressions.Regex("Tag:.*NonExistentTag12345.*not defined"));
|
||||
const string testTag = "AutoCreatedTag12345";
|
||||
|
||||
// Tags that don't exist are now auto-created
|
||||
var p = new JObject
|
||||
{
|
||||
["action"] = "create",
|
||||
["name"] = "TestInvalidTag",
|
||||
["tag"] = "NonExistentTag12345"
|
||||
["name"] = "TestAutoTag",
|
||||
["tag"] = testTag
|
||||
};
|
||||
|
||||
var result = ManageGameObject.HandleCommand(p);
|
||||
// Current behavior: logs error but may create object anyway
|
||||
Assert.IsNotNull(result, "Should return a result");
|
||||
var resultObj = result as JObject ?? JObject.FromObject(result);
|
||||
|
||||
// Clean up if object was created
|
||||
FindAndTrack("TestInvalidTag");
|
||||
Assert.IsTrue(resultObj.Value<bool>("success"), resultObj.ToString());
|
||||
|
||||
var created = FindAndTrack("TestAutoTag");
|
||||
Assert.IsNotNull(created, "Object should be created");
|
||||
Assert.AreEqual(testTag, created.tag, "Tag should be auto-created and assigned");
|
||||
|
||||
// Verify tag was actually added to the tag manager
|
||||
Assert.That(UnityEditorInternal.InternalEditorUtility.tags, Does.Contain(testTag),
|
||||
"Tag should exist in Unity's tag manager");
|
||||
|
||||
// Clean up the created tag
|
||||
try { UnityEditorInternal.InternalEditorUtility.RemoveTag(testTag); } catch { }
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
using System.Collections.Generic;
|
||||
using NUnit.Framework;
|
||||
using UnityEngine;
|
||||
using UnityEditorInternal;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using MCPForUnity.Editor.Tools;
|
||||
|
||||
|
|
@ -392,22 +393,30 @@ namespace MCPForUnityTests.Editor.Tools
|
|||
}
|
||||
|
||||
[Test]
|
||||
public void Modify_InvalidTag_HandlesGracefully()
|
||||
public void Modify_NewTag_AutoCreatesTag()
|
||||
{
|
||||
// Expect the error log from Unity about invalid tag
|
||||
UnityEngine.TestTools.LogAssert.Expect(LogType.Error,
|
||||
new System.Text.RegularExpressions.Regex("Tag:.*NonExistentTag12345.*not defined"));
|
||||
const string testTag = "AutoModifyTag12345";
|
||||
|
||||
// Tags that don't exist are now auto-created
|
||||
var p = new JObject
|
||||
{
|
||||
["action"] = "modify",
|
||||
["target"] = "ModifyTestObject",
|
||||
["tag"] = "NonExistentTag12345"
|
||||
["tag"] = testTag
|
||||
};
|
||||
|
||||
var result = ManageGameObject.HandleCommand(p);
|
||||
// Current behavior: logs error but continues
|
||||
Assert.IsNotNull(result, "Should return a result");
|
||||
var resultObj = result as JObject ?? JObject.FromObject(result);
|
||||
|
||||
Assert.IsTrue(resultObj.Value<bool>("success"), resultObj.ToString());
|
||||
Assert.AreEqual(testTag, testObjects[0].tag, "Tag should be auto-created and assigned");
|
||||
|
||||
// Verify tag was actually added to the tag manager
|
||||
Assert.That(UnityEditorInternal.InternalEditorUtility.tags, Does.Contain(testTag),
|
||||
"Tag should exist in Unity's tag manager");
|
||||
|
||||
// Clean up the created tag
|
||||
try { UnityEditorInternal.InternalEditorUtility.RemoveTag(testTag); } catch { }
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
|
|
|||
|
|
@ -122,7 +122,7 @@ namespace MCPForUnityTests.Editor.Tools
|
|||
Assert.Contains("useGravity", properties, "Rigidbody should have useGravity property");
|
||||
|
||||
// Test AI suggestions
|
||||
var suggestions = ComponentResolver.GetAIPropertySuggestions("Use Gravity", properties);
|
||||
var suggestions = ComponentResolver.GetFuzzyPropertySuggestions("Use Gravity", properties);
|
||||
Assert.Contains("useGravity", suggestions, "Should suggest useGravity for 'Use Gravity'");
|
||||
}
|
||||
|
||||
|
|
@ -153,7 +153,7 @@ namespace MCPForUnityTests.Editor.Tools
|
|||
|
||||
foreach (var (input, expected) in testCases)
|
||||
{
|
||||
var suggestions = ComponentResolver.GetAIPropertySuggestions(input, testProperties);
|
||||
var suggestions = ComponentResolver.GetFuzzyPropertySuggestions(input, testProperties);
|
||||
Assert.Contains(expected, suggestions, $"Should suggest {expected} for input '{input}'");
|
||||
}
|
||||
}
|
||||
|
|
@ -163,13 +163,13 @@ namespace MCPForUnityTests.Editor.Tools
|
|||
{
|
||||
// This test verifies that error messages are helpful and contain suggestions
|
||||
var testProperties = new List<string> { "mass", "velocity", "drag", "useGravity" };
|
||||
var suggestions = ComponentResolver.GetAIPropertySuggestions("weight", testProperties);
|
||||
var suggestions = ComponentResolver.GetFuzzyPropertySuggestions("weight", testProperties);
|
||||
|
||||
// Even if no perfect match, should return valid list
|
||||
Assert.IsNotNull(suggestions, "Should return valid suggestions list");
|
||||
|
||||
// Test with completely invalid input
|
||||
var badSuggestions = ComponentResolver.GetAIPropertySuggestions("xyz123invalid", testProperties);
|
||||
var badSuggestions = ComponentResolver.GetFuzzyPropertySuggestions("xyz123invalid", testProperties);
|
||||
Assert.IsNotNull(badSuggestions, "Should handle invalid input gracefully");
|
||||
}
|
||||
|
||||
|
|
@ -181,12 +181,12 @@ namespace MCPForUnityTests.Editor.Tools
|
|||
|
||||
// First call - populate cache
|
||||
var startTime = System.DateTime.UtcNow;
|
||||
var suggestions1 = ComponentResolver.GetAIPropertySuggestions(input, properties);
|
||||
var suggestions1 = ComponentResolver.GetFuzzyPropertySuggestions(input, properties);
|
||||
var firstCallTime = (System.DateTime.UtcNow - startTime).TotalMilliseconds;
|
||||
|
||||
// Second call - should use cache
|
||||
startTime = System.DateTime.UtcNow;
|
||||
var suggestions2 = ComponentResolver.GetAIPropertySuggestions(input, properties);
|
||||
var suggestions2 = ComponentResolver.GetFuzzyPropertySuggestions(input, properties);
|
||||
var secondCallTime = (System.DateTime.UtcNow - startTime).TotalMilliseconds;
|
||||
|
||||
Assert.AreEqual(suggestions1.Count, suggestions2.Count, "Cached results should be identical");
|
||||
|
|
@ -315,8 +315,9 @@ namespace MCPForUnityTests.Editor.Tools
|
|||
};
|
||||
|
||||
// Expect the error logs from the invalid property
|
||||
LogAssert.Expect(LogType.Error, new System.Text.RegularExpressions.Regex("Unexpected error converting token to UnityEngine.Vector3"));
|
||||
LogAssert.Expect(LogType.Error, new System.Text.RegularExpressions.Regex("SetProperty.*Failed to set 'velocity'"));
|
||||
// Note: PropertyConversion logs "Error converting token to..." when conversion fails
|
||||
LogAssert.Expect(LogType.Error, new System.Text.RegularExpressions.Regex("Error converting token to UnityEngine.Vector3"));
|
||||
LogAssert.Expect(LogType.Error, new System.Text.RegularExpressions.Regex(@"\[SetProperty\].*Failed to set 'velocity'"));
|
||||
LogAssert.Expect(LogType.Warning, new System.Text.RegularExpressions.Regex("Property 'velocity' not found"));
|
||||
|
||||
// Act
|
||||
|
|
@ -555,5 +556,81 @@ namespace MCPForUnityTests.Editor.Tools
|
|||
UnityEngine.Object.DestroyImmediate(material2);
|
||||
UnityEngine.Object.DestroyImmediate(testObject);
|
||||
}
|
||||
|
||||
#region Prefab Asset Handling Tests
|
||||
|
||||
[Test]
|
||||
public void HandleCommand_WithPrefabPath_ReturnsGuidanceError_ForModifyAction()
|
||||
{
|
||||
// Arrange - Attempt to modify a prefab asset directly
|
||||
var modifyParams = new JObject
|
||||
{
|
||||
["action"] = "modify",
|
||||
["target"] = "Assets/Prefabs/MyPrefab.prefab"
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = ManageGameObject.HandleCommand(modifyParams);
|
||||
|
||||
// Assert - Should return an error with guidance to use correct tools
|
||||
Assert.IsNotNull(result, "Should return a result");
|
||||
var errorResponse = result as MCPForUnity.Editor.Helpers.ErrorResponse;
|
||||
Assert.IsNotNull(errorResponse, "Should return an ErrorResponse");
|
||||
Assert.IsFalse(errorResponse.Success, "Should indicate failure");
|
||||
Assert.That(errorResponse.Error, Does.Contain("prefab asset"), "Error should mention prefab asset");
|
||||
Assert.That(errorResponse.Error, Does.Contain("manage_asset"), "Error should guide to manage_asset");
|
||||
Assert.That(errorResponse.Error, Does.Contain("manage_prefabs"), "Error should guide to manage_prefabs");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void HandleCommand_WithPrefabPath_ReturnsGuidanceError_ForDeleteAction()
|
||||
{
|
||||
// Arrange - Attempt to delete a prefab asset directly
|
||||
var deleteParams = new JObject
|
||||
{
|
||||
["action"] = "delete",
|
||||
["target"] = "Assets/Prefabs/SomePrefab.prefab"
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = ManageGameObject.HandleCommand(deleteParams);
|
||||
|
||||
// Assert - Should return an error with guidance
|
||||
Assert.IsNotNull(result, "Should return a result");
|
||||
var errorResponse = result as MCPForUnity.Editor.Helpers.ErrorResponse;
|
||||
Assert.IsNotNull(errorResponse, "Should return an ErrorResponse");
|
||||
Assert.IsFalse(errorResponse.Success, "Should indicate failure");
|
||||
Assert.That(errorResponse.Error, Does.Contain("prefab asset"), "Error should mention prefab asset");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void HandleCommand_WithPrefabPath_AllowsCreateAction()
|
||||
{
|
||||
// Arrange - Create (instantiate) from a prefab should be allowed
|
||||
// Note: This will fail because the prefab doesn't exist, but the error should NOT be
|
||||
// the prefab redirection error - it should be a "prefab not found" type error
|
||||
var createParams = new JObject
|
||||
{
|
||||
["action"] = "create",
|
||||
["prefab_path"] = "Assets/Prefabs/NonExistent.prefab",
|
||||
["name"] = "TestInstance"
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = ManageGameObject.HandleCommand(createParams);
|
||||
|
||||
// Assert - Should NOT return the prefab redirection error
|
||||
// (It may fail for other reasons like prefab not found, but not due to redirection)
|
||||
var errorResponse = result as MCPForUnity.Editor.Helpers.ErrorResponse;
|
||||
if (errorResponse != null)
|
||||
{
|
||||
// If there's an error, it should NOT be the prefab asset guidance error
|
||||
Assert.That(errorResponse.Error, Does.Not.Contain("Use 'manage_asset'"),
|
||||
"Create action should not be blocked by prefab check");
|
||||
}
|
||||
// If it's not an error, that's also fine (means create was allowed)
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -54,7 +54,7 @@ namespace MCPForUnityTests.Editor.Tools
|
|||
|
||||
var result = ToJObject(ManageMaterial.HandleCommand(paramsObj));
|
||||
|
||||
Assert.AreEqual("success", result.Value<string>("status"), result.ToString());
|
||||
Assert.IsTrue(result.Value<bool>("success"), result.ToString());
|
||||
var mat = AssetDatabase.LoadAssetAtPath<Material>(_matPath);
|
||||
Assert.AreEqual(Color.red, mat.color);
|
||||
}
|
||||
|
|
@ -75,7 +75,7 @@ namespace MCPForUnityTests.Editor.Tools
|
|||
|
||||
var result = ToJObject(ManageMaterial.HandleCommand(paramsObj));
|
||||
|
||||
Assert.AreEqual("success", result.Value<string>("status"), result.ToString());
|
||||
Assert.IsTrue(result.Value<bool>("success"), result.ToString());
|
||||
var mat = AssetDatabase.LoadAssetAtPath<Material>(_matPath);
|
||||
Assert.AreEqual(Color.green, mat.color);
|
||||
}
|
||||
|
|
@ -93,7 +93,7 @@ namespace MCPForUnityTests.Editor.Tools
|
|||
|
||||
var result = ToJObject(ManageMaterial.HandleCommand(paramsObj));
|
||||
|
||||
Assert.AreEqual("success", result.Value<string>("status"));
|
||||
Assert.IsTrue(result.Value<bool>("success"), result.ToString());
|
||||
}
|
||||
|
||||
[Test]
|
||||
|
|
@ -112,8 +112,8 @@ namespace MCPForUnityTests.Editor.Tools
|
|||
|
||||
var result = ToJObject(ManageMaterial.HandleCommand(paramsObj));
|
||||
|
||||
Assert.AreEqual("error", result.Value<string>("status"));
|
||||
string msg = result.Value<string>("message");
|
||||
Assert.IsFalse(result.Value<bool>("success"));
|
||||
string msg = result.Value<string>("error");
|
||||
|
||||
// Verify we get exception details
|
||||
Assert.IsTrue(msg.Contains("Invalid JSON"), "Should mention Invalid JSON");
|
||||
|
|
@ -140,9 +140,9 @@ namespace MCPForUnityTests.Editor.Tools
|
|||
var result = ToJObject(ManageMaterial.HandleCommand(paramsObj));
|
||||
|
||||
// We accept either success (ignored) or specific error, but not crash
|
||||
// Assert.AreNotEqual("internal_error", result.Value<string>("status"));
|
||||
var status = result.Value<string>("status");
|
||||
Assert.IsTrue(status == "success" || status == "error", $"Status should be success or error, got {status}");
|
||||
// The new response format uses a bool "success" field
|
||||
var success = result.Value<bool?>("success");
|
||||
Assert.IsNotNull(success, "Response should have success field");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -61,10 +61,10 @@ namespace MCPForUnityTests.Editor.Tools
|
|||
var result = ToJObject(ManageMaterial.HandleCommand(paramsObj));
|
||||
|
||||
// Assert
|
||||
Assert.AreEqual("error", result.Value<string>("status"));
|
||||
Assert.IsFalse(result.Value<bool>("success"));
|
||||
|
||||
// We expect more detailed error message after fix
|
||||
var message = result.Value<string>("message");
|
||||
var message = result.Value<string>("error");
|
||||
Assert.IsTrue(message.StartsWith("Invalid JSON in properties"), "Message should start with prefix");
|
||||
Assert.AreNotEqual("Invalid JSON in properties", message, "Message should contain exception details");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -80,8 +80,8 @@ namespace MCPForUnityTests.Editor.Tools
|
|||
["color"] = new JArray(1f, 0f, 0f, 1f)
|
||||
};
|
||||
var resultBadPath = ToJObject(ManageMaterial.HandleCommand(paramsBadPath));
|
||||
Assert.AreEqual("error", resultBadPath.Value<string>("status"));
|
||||
StringAssert.Contains("Could not find material", resultBadPath.Value<string>("message"));
|
||||
Assert.IsFalse(resultBadPath.Value<bool>("success"));
|
||||
StringAssert.Contains("Could not find material", resultBadPath.Value<string>("error"));
|
||||
|
||||
// 2. Bad color array (too short)
|
||||
var paramsBadColor = new JObject
|
||||
|
|
@ -91,8 +91,8 @@ namespace MCPForUnityTests.Editor.Tools
|
|||
["color"] = new JArray(1f) // Invalid
|
||||
};
|
||||
var resultBadColor = ToJObject(ManageMaterial.HandleCommand(paramsBadColor));
|
||||
Assert.AreEqual("error", resultBadColor.Value<string>("status"));
|
||||
StringAssert.Contains("Invalid color format", resultBadColor.Value<string>("message"));
|
||||
Assert.IsFalse(resultBadColor.Value<bool>("success"));
|
||||
StringAssert.Contains("Invalid color format", resultBadColor.Value<string>("error"));
|
||||
|
||||
// 3. Bad slot index
|
||||
// Assign material first
|
||||
|
|
@ -108,8 +108,8 @@ namespace MCPForUnityTests.Editor.Tools
|
|||
["slot"] = 99
|
||||
};
|
||||
var resultBadSlot = ToJObject(ManageMaterial.HandleCommand(paramsBadSlot));
|
||||
Assert.AreEqual("error", resultBadSlot.Value<string>("status"));
|
||||
StringAssert.Contains("out of bounds", resultBadSlot.Value<string>("message"));
|
||||
Assert.IsFalse(resultBadSlot.Value<bool>("success"));
|
||||
StringAssert.Contains("out of bounds", resultBadSlot.Value<string>("error"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
|
|
@ -137,7 +137,7 @@ namespace MCPForUnityTests.Editor.Tools
|
|||
};
|
||||
|
||||
var result = ToJObject(ManageMaterial.HandleCommand(paramsObj));
|
||||
Assert.AreEqual("success", result.Value<string>("status"));
|
||||
Assert.IsTrue(result.Value<bool>("success"), result.ToString());
|
||||
|
||||
// Assert
|
||||
// 1. Renderer has property block with Red
|
||||
|
|
@ -168,7 +168,7 @@ namespace MCPForUnityTests.Editor.Tools
|
|||
["materialPath"] = _matPath
|
||||
};
|
||||
var assignResult = ToJObject(ManageMaterial.HandleCommand(assignParams));
|
||||
Assert.AreEqual("success", assignResult.Value<string>("status"));
|
||||
Assert.IsTrue(assignResult.Value<bool>("success"), assignResult.ToString());
|
||||
|
||||
// Verify assignment
|
||||
var renderer = _cube.GetComponent<Renderer>();
|
||||
|
|
@ -183,7 +183,7 @@ namespace MCPForUnityTests.Editor.Tools
|
|||
["color"] = new JArray(newColor.r, newColor.g, newColor.b, newColor.a)
|
||||
};
|
||||
var colorResult = ToJObject(ManageMaterial.HandleCommand(colorParams));
|
||||
Assert.AreEqual("success", colorResult.Value<string>("status"));
|
||||
Assert.IsTrue(colorResult.Value<bool>("success"), colorResult.ToString());
|
||||
|
||||
// Verify color changed on renderer (because it's shared)
|
||||
var propName = renderer.sharedMaterial.HasProperty("_BaseColor") ? "_BaseColor" : "_Color";
|
||||
|
|
|
|||
|
|
@ -84,7 +84,7 @@ namespace MCPForUnityTests.Editor.Tools
|
|||
var result = ToJObject(ManageMaterial.HandleCommand(paramsObj));
|
||||
|
||||
// Assert
|
||||
Assert.AreEqual("success", result.Value<string>("status"), result.ToString());
|
||||
Assert.IsTrue(result.Value<bool>("success"), result.ToString());
|
||||
|
||||
mat = AssetDatabase.LoadAssetAtPath<Material>(_matPath); // Reload
|
||||
var prop = mat.shader.name == "Standard" ? "_Color" : "_BaseColor";
|
||||
|
|
@ -109,7 +109,7 @@ namespace MCPForUnityTests.Editor.Tools
|
|||
var result = ToJObject(ManageMaterial.HandleCommand(paramsObj));
|
||||
|
||||
// Assert
|
||||
Assert.AreEqual("success", result.Value<string>("status"), result.ToString());
|
||||
Assert.IsTrue(result.Value<bool>("success"), result.ToString());
|
||||
|
||||
var mat = AssetDatabase.LoadAssetAtPath<Material>(_matPath);
|
||||
var prop = mat.HasProperty("_BaseColor") ? "_BaseColor" : "_Color";
|
||||
|
|
@ -140,7 +140,7 @@ namespace MCPForUnityTests.Editor.Tools
|
|||
var result = ToJObject(ManageMaterial.HandleCommand(paramsObj));
|
||||
|
||||
// Assert
|
||||
Assert.AreEqual("success", result.Value<string>("status"), result.ToString());
|
||||
Assert.IsTrue(result.Value<bool>("success"), result.ToString());
|
||||
|
||||
var renderer = go.GetComponent<Renderer>();
|
||||
Assert.IsNotNull(renderer.sharedMaterial);
|
||||
|
|
@ -181,7 +181,7 @@ namespace MCPForUnityTests.Editor.Tools
|
|||
var result = ToJObject(ManageMaterial.HandleCommand(paramsObj));
|
||||
|
||||
// Assert
|
||||
Assert.AreEqual("success", result.Value<string>("status"), result.ToString());
|
||||
Assert.IsTrue(result.Value<bool>("success"), result.ToString());
|
||||
|
||||
var renderer = go.GetComponent<Renderer>();
|
||||
var block = new MaterialPropertyBlock();
|
||||
|
|
@ -215,10 +215,12 @@ namespace MCPForUnityTests.Editor.Tools
|
|||
var result = ToJObject(ManageMaterial.HandleCommand(paramsObj));
|
||||
|
||||
// Assert
|
||||
Assert.AreEqual("success", result.Value<string>("status"), result.ToString());
|
||||
Assert.IsNotNull(result["properties"]);
|
||||
Assert.IsInstanceOf<JArray>(result["properties"]);
|
||||
var props = result["properties"] as JArray;
|
||||
Assert.IsTrue(result.Value<bool>("success"), result.ToString());
|
||||
var data = result["data"] as JObject;
|
||||
Assert.IsNotNull(data, "Response should have data object");
|
||||
Assert.IsNotNull(data["properties"]);
|
||||
Assert.IsInstanceOf<JArray>(data["properties"]);
|
||||
var props = data["properties"] as JArray;
|
||||
Assert.IsTrue(props.Count > 0);
|
||||
|
||||
// Check for standard properties
|
||||
|
|
|
|||
|
|
@ -144,7 +144,7 @@ namespace MCPForUnityTests.Editor.Tools
|
|||
};
|
||||
|
||||
var assignResult = ToJObject(ManageMaterial.HandleCommand(assignParams));
|
||||
Assert.AreEqual("success", assignResult.Value<string>("status"), assignResult.ToString());
|
||||
Assert.IsTrue(assignResult.Value<bool>("success"), assignResult.ToString());
|
||||
|
||||
var renderer = _sphere.GetComponent<MeshRenderer>();
|
||||
Assert.IsNotNull(renderer, "Sphere should have MeshRenderer.");
|
||||
|
|
|
|||
Loading…
Reference in New Issue