🔧 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")`
|
- Clean up: `mcp__UnityMCP__manage_gameobject(action="delete", target="GO_Test_Object")`
|
||||||
- **Pass criteria**: Pagination works (cursor present when more results available)
|
- **Pass criteria**: Pagination works (cursor present when more results available)
|
||||||
|
|
||||||
### GO-10. Deprecation Warnings
|
### GO-10. Removed Actions Return Error
|
||||||
**Goal**: Verify legacy actions log deprecation warnings
|
**Goal**: Verify legacy actions (find, get_components, etc.) return clear errors directing to new tools
|
||||||
**Actions**:
|
**Actions**:
|
||||||
- Call legacy action: `mcp__UnityMCP__manage_gameobject(action="find", search_term="Camera", search_method="by_component")`
|
- Call removed 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 response contains error indicating action is unknown/removed
|
||||||
- Verify warning mentions "find_gameobjects" as replacement
|
- **Pass criteria**: Error response received (legacy actions were removed, not deprecated)
|
||||||
- **Pass criteria**: Deprecation warning logged
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -473,8 +473,8 @@ jobs:
|
||||||
if localname(froot.tag) == 'testcase':
|
if localname(froot.tag) == 'testcase':
|
||||||
suite.append(froot)
|
suite.append(froot)
|
||||||
added += 1
|
added += 1
|
||||||
except Exception:
|
except Exception as e:
|
||||||
pass
|
print(f"Warning: Could not parse fragment {frag}: {e}")
|
||||||
|
|
||||||
if added:
|
if added:
|
||||||
for tc in list(suite.findall('.//testcase')):
|
for tc in list(suite.findall('.//testcase')):
|
||||||
|
|
|
||||||
|
|
@ -1235,7 +1235,12 @@ jobs:
|
||||||
return (0, 999)
|
return (0, 999)
|
||||||
if n.startswith('T-') and len(n) > 2:
|
if n.startswith('T-') and len(n) > 2:
|
||||||
return (1, ord(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
|
MAX_CHARS = 2000
|
||||||
seen = set()
|
seen = set()
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,17 @@ namespace MCPForUnity.Editor.Helpers
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static class AssetPathUtility
|
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>
|
/// <summary>
|
||||||
/// Normalizes a Unity asset path by ensuring forward slashes are used and that it is rooted under "Assets/".
|
/// Normalizes a Unity asset path by ensuring forward slashes are used and that it is rooted under "Assets/".
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|
@ -24,7 +35,7 @@ namespace MCPForUnity.Editor.Helpers
|
||||||
return path;
|
return path;
|
||||||
}
|
}
|
||||||
|
|
||||||
path = path.Replace('\\', '/');
|
path = NormalizeSeparators(path);
|
||||||
if (!path.StartsWith("Assets/", StringComparison.OrdinalIgnoreCase))
|
if (!path.StartsWith("Assets/", StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
return "Assets/" + path.TrimStart('/');
|
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>
|
/// <summary>
|
||||||
/// Finds a component type by name, searching loaded assemblies.
|
/// Finds a component type by name, searching loaded assemblies.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Delegates to UnityTypeResolver.ResolveComponent() for unified type resolution.
|
||||||
|
/// </remarks>
|
||||||
public static Type FindComponentType(string typeName)
|
public static Type FindComponentType(string typeName)
|
||||||
{
|
{
|
||||||
// Try direct type lookup first
|
return UnityTypeResolver.ResolveComponent(typeName);
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <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;
|
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)
|
if (Application.platform == RuntimePlatform.WindowsEditor)
|
||||||
{
|
{
|
||||||
// netstat -ano | findstr :<port>
|
// Run netstat -ano directly (without findstr) and filter in C#.
|
||||||
success = ExecPath.TryRun("cmd.exe", $"/c netstat -ano | findstr :{port}", Application.dataPath, out stdout, out stderr);
|
// Using findstr in a pipe causes the entire command to return exit code 1 when no matches are found,
|
||||||
if (success && !string.IsNullOrEmpty(stdout))
|
// 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);
|
var lines = stdout.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries);
|
||||||
foreach (var line in lines)
|
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);
|
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);
|
results.Add(pid);
|
||||||
}
|
}
|
||||||
|
|
@ -963,6 +975,7 @@ namespace MCPForUnity.Editor.Services
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
// lsof: only return LISTENers (avoids capturing random clients)
|
// lsof: only return LISTENers (avoids capturing random clients)
|
||||||
|
|
@ -1002,16 +1015,44 @@ namespace MCPForUnity.Editor.Services
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
bool debugLogs = false;
|
// Windows best-effort: First check process name with tasklist, then try to get command line with wmic
|
||||||
try { debugLogs = EditorPrefs.GetBool(EditorPrefKeys.DebugLogs, false); } catch { }
|
|
||||||
|
|
||||||
// Windows best-effort: tasklist /FI "PID eq X"
|
|
||||||
if (Application.platform == RuntimePlatform.WindowsEditor)
|
if (Application.platform == RuntimePlatform.WindowsEditor)
|
||||||
{
|
{
|
||||||
ExecPath.TryRun("cmd.exe", $"/c tasklist /FI \"PID eq {pid}\"", Application.dataPath, out var stdout, out var stderr, 5000);
|
// Step 1: Check if process name matches known server executables
|
||||||
string combined = ((stdout ?? string.Empty) + "\n" + (stderr ?? string.Empty)).ToLowerInvariant();
|
ExecPath.TryRun("cmd.exe", $"/c tasklist /FI \"PID eq {pid}\"", Application.dataPath, out var tasklistOut, out var tasklistErr, 5000);
|
||||||
// Common process names: python.exe, uv.exe, uvx.exe
|
string tasklistCombined = ((tasklistOut ?? string.Empty) + "\n" + (tasklistErr ?? string.Empty)).ToLowerInvariant();
|
||||||
return combined.Contains("python") || combined.Contains("uvx") || combined.Contains("uv.exe") || combined.Contains("uvx.exe");
|
|
||||||
|
// 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=
|
// macOS/Linux: ps -p pid -ww -o comm= -o args=
|
||||||
|
|
@ -1027,7 +1068,6 @@ namespace MCPForUnity.Editor.Services
|
||||||
string sCompact = NormalizeForMatch(raw);
|
string sCompact = NormalizeForMatch(raw);
|
||||||
if (!string.IsNullOrEmpty(s))
|
if (!string.IsNullOrEmpty(s))
|
||||||
{
|
{
|
||||||
|
|
||||||
bool mentionsMcp = sCompact.Contains("mcp-for-unity")
|
bool mentionsMcp = sCompact.Contains("mcp-for-unity")
|
||||||
|| sCompact.Contains("mcp_for_unity")
|
|| sCompact.Contains("mcp_for_unity")
|
||||||
|| sCompact.Contains("mcpforunity");
|
|| sCompact.Contains("mcpforunity");
|
||||||
|
|
@ -1058,15 +1098,6 @@ namespace MCPForUnity.Editor.Services
|
||||||
{
|
{
|
||||||
return true;
|
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 { }
|
catch { }
|
||||||
|
|
|
||||||
|
|
@ -37,41 +37,30 @@ namespace MCPForUnity.Editor.Tools
|
||||||
return new ErrorResponse("'searchTerm' or 'target' parameter is required.");
|
return new ErrorResponse("'searchTerm' or 'target' parameter is required.");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pagination parameters
|
// Pagination parameters using standard PaginationRequest
|
||||||
int pageSize = ParamCoercion.CoerceInt(@params["pageSize"] ?? @params["page_size"], 50);
|
var pagination = PaginationRequest.FromParams(@params, defaultPageSize: 50);
|
||||||
int cursor = ParamCoercion.CoerceInt(@params["cursor"], 0);
|
pagination.PageSize = Mathf.Clamp(pagination.PageSize, 1, 500);
|
||||||
|
|
||||||
// Search options
|
// Search options
|
||||||
bool includeInactive = ParamCoercion.CoerceBool(@params["includeInactive"] ?? @params["searchInactive"] ?? @params["include_inactive"], false);
|
bool includeInactive = ParamCoercion.CoerceBool(@params["includeInactive"] ?? @params["searchInactive"] ?? @params["include_inactive"], false);
|
||||||
|
|
||||||
// Validate pageSize bounds
|
|
||||||
pageSize = Mathf.Clamp(pageSize, 1, 500);
|
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
// Get all matching instance IDs
|
// Get all matching instance IDs
|
||||||
var allIds = GameObjectLookup.SearchGameObjects(searchMethod, searchTerm, includeInactive, 0);
|
var allIds = GameObjectLookup.SearchGameObjects(searchMethod, searchTerm, includeInactive, 0);
|
||||||
int totalCount = allIds.Count;
|
|
||||||
|
|
||||||
// Apply pagination
|
// Use standard pagination response
|
||||||
var pagedIds = allIds.Skip(cursor).Take(pageSize).ToList();
|
var paginatedResult = PaginationResponse<int>.Create(allIds, pagination);
|
||||||
|
|
||||||
// Calculate next cursor
|
return new SuccessResponse("Found GameObjects", new
|
||||||
int? nextCursor = cursor + pagedIds.Count < totalCount ? cursor + pagedIds.Count : (int?)null;
|
|
||||||
|
|
||||||
return new
|
|
||||||
{
|
{
|
||||||
success = true,
|
instanceIDs = paginatedResult.Items,
|
||||||
data = new
|
pageSize = paginatedResult.PageSize,
|
||||||
{
|
cursor = paginatedResult.Cursor,
|
||||||
instanceIDs = pagedIds,
|
nextCursor = paginatedResult.NextCursor,
|
||||||
pageSize = pageSize,
|
totalCount = paginatedResult.TotalCount,
|
||||||
cursor = cursor,
|
hasMore = paginatedResult.HasMore
|
||||||
nextCursor = nextCursor,
|
});
|
||||||
totalCount = totalCount,
|
|
||||||
hasMore = nextCursor.HasValue
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
catch (System.Exception ex)
|
catch (System.Exception ex)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -215,7 +215,7 @@ namespace MCPForUnity.Editor.Tools
|
||||||
|
|
||||||
if (propertiesForApply.HasValues)
|
if (propertiesForApply.HasValues)
|
||||||
{
|
{
|
||||||
MaterialOps.ApplyProperties(mat, propertiesForApply, ManageGameObject.InputSerializer);
|
MaterialOps.ApplyProperties(mat, propertiesForApply, UnityJsonSerializer.Instance);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
AssetDatabase.CreateAsset(mat, fullPath);
|
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.
|
// 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)
|
// 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!)
|
// Example: Modifying a ScriptableObject (Use manage_scriptable_object instead!)
|
||||||
else if (asset is ScriptableObject so)
|
else if (asset is ScriptableObject so)
|
||||||
|
|
@ -989,7 +989,7 @@ namespace MCPForUnity.Editor.Tools
|
||||||
System.Reflection.PropertyInfo propInfo = type.GetProperty(memberName, flags);
|
System.Reflection.PropertyInfo propInfo = type.GetProperty(memberName, flags);
|
||||||
if (propInfo != null && propInfo.CanWrite)
|
if (propInfo != null && propInfo.CanWrite)
|
||||||
{
|
{
|
||||||
object convertedValue = ConvertJTokenToType(value, propInfo.PropertyType);
|
object convertedValue = Helpers.PropertyConversion.TryConvertToType(value, propInfo.PropertyType);
|
||||||
if (
|
if (
|
||||||
convertedValue != null
|
convertedValue != null
|
||||||
&& !object.Equals(propInfo.GetValue(target), convertedValue)
|
&& !object.Equals(propInfo.GetValue(target), convertedValue)
|
||||||
|
|
@ -1004,7 +1004,7 @@ namespace MCPForUnity.Editor.Tools
|
||||||
System.Reflection.FieldInfo fieldInfo = type.GetField(memberName, flags);
|
System.Reflection.FieldInfo fieldInfo = type.GetField(memberName, flags);
|
||||||
if (fieldInfo != null)
|
if (fieldInfo != null)
|
||||||
{
|
{
|
||||||
object convertedValue = ConvertJTokenToType(value, fieldInfo.FieldType);
|
object convertedValue = Helpers.PropertyConversion.TryConvertToType(value, fieldInfo.FieldType);
|
||||||
if (
|
if (
|
||||||
convertedValue != null
|
convertedValue != null
|
||||||
&& !object.Equals(fieldInfo.GetValue(target), convertedValue)
|
&& !object.Equals(fieldInfo.GetValue(target), convertedValue)
|
||||||
|
|
@ -1025,89 +1025,6 @@ namespace MCPForUnity.Editor.Tools
|
||||||
return false;
|
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 ---
|
// --- Data Serialization ---
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
|
||||||
|
|
@ -73,35 +73,32 @@ namespace MCPForUnity.Editor.Tools
|
||||||
return new ErrorResponse($"Target GameObject ('{targetToken}') not found using method '{searchMethod ?? "default"}'.");
|
return new ErrorResponse($"Target GameObject ('{targetToken}') not found using method '{searchMethod ?? "default"}'.");
|
||||||
}
|
}
|
||||||
|
|
||||||
string componentType = ParamCoercion.CoerceString(@params["componentType"] ?? @params["component_type"], null);
|
string componentTypeName = ParamCoercion.CoerceString(@params["componentType"] ?? @params["component_type"], null);
|
||||||
if (string.IsNullOrEmpty(componentType))
|
if (string.IsNullOrEmpty(componentTypeName))
|
||||||
{
|
{
|
||||||
return new ErrorResponse("'componentType' parameter is required for 'add' action.");
|
return new ErrorResponse("'componentType' parameter is required for 'add' action.");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolve component type
|
// Resolve component type using unified type resolver
|
||||||
Type type = FindComponentType(componentType);
|
Type type = UnityTypeResolver.ResolveComponent(componentTypeName);
|
||||||
if (type == null)
|
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
|
// Use ComponentOps for the actual operation
|
||||||
JObject properties = @params["properties"] as JObject ?? @params["componentProperties"] as JObject;
|
Component newComponent = ComponentOps.AddComponent(targetGo, type, out string error);
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
// Undo.AddComponent creates its own undo record, no need for RecordObject
|
|
||||||
Component newComponent = Undo.AddComponent(targetGo, type);
|
|
||||||
|
|
||||||
if (newComponent == null)
|
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
|
// Set properties if provided
|
||||||
|
JObject properties = @params["properties"] as JObject ?? @params["componentProperties"] as JObject;
|
||||||
if (properties != null && properties.HasValues)
|
if (properties != null && properties.HasValues)
|
||||||
{
|
{
|
||||||
|
// Record for undo before modifying properties
|
||||||
|
Undo.RecordObject(newComponent, "Modify Component Properties");
|
||||||
SetPropertiesOnComponent(newComponent, properties);
|
SetPropertiesOnComponent(newComponent, properties);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -110,7 +107,7 @@ namespace MCPForUnity.Editor.Tools
|
||||||
return new
|
return new
|
||||||
{
|
{
|
||||||
success = true,
|
success = true,
|
||||||
message = $"Component '{componentType}' added to '{targetGo.name}'.",
|
message = $"Component '{componentTypeName}' added to '{targetGo.name}'.",
|
||||||
data = new
|
data = new
|
||||||
{
|
{
|
||||||
instanceID = targetGo.GetInstanceID(),
|
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)
|
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"}'.");
|
return new ErrorResponse($"Target GameObject ('{targetToken}') not found using method '{searchMethod ?? "default"}'.");
|
||||||
}
|
}
|
||||||
|
|
||||||
string componentType = ParamCoercion.CoerceString(@params["componentType"] ?? @params["component_type"], null);
|
string componentTypeName = ParamCoercion.CoerceString(@params["componentType"] ?? @params["component_type"], null);
|
||||||
if (string.IsNullOrEmpty(componentType))
|
if (string.IsNullOrEmpty(componentTypeName))
|
||||||
{
|
{
|
||||||
return new ErrorResponse("'componentType' parameter is required for 'remove' action.");
|
return new ErrorResponse("'componentType' parameter is required for 'remove' action.");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolve component type
|
// Resolve component type using unified type resolver
|
||||||
Type type = FindComponentType(componentType);
|
Type type = UnityTypeResolver.ResolveComponent(componentTypeName);
|
||||||
if (type == null)
|
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)
|
// Use ComponentOps for the actual operation
|
||||||
if (type == typeof(Transform))
|
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);
|
EditorUtility.SetDirty(targetGo);
|
||||||
|
|
||||||
return new
|
return new
|
||||||
{
|
{
|
||||||
success = true,
|
success = true,
|
||||||
message = $"Component '{componentType}' removed from '{targetGo.name}'.",
|
message = $"Component '{componentTypeName}' removed from '{targetGo.name}'.",
|
||||||
data = new
|
data = new
|
||||||
{
|
{
|
||||||
instanceID = targetGo.GetInstanceID()
|
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)
|
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.");
|
return new ErrorResponse("'componentType' parameter is required for 'set_property' action.");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolve component type
|
// Resolve component type using unified type resolver
|
||||||
Type type = FindComponentType(componentType);
|
Type type = UnityTypeResolver.ResolveComponent(componentType);
|
||||||
if (type == null)
|
if (type == null)
|
||||||
{
|
{
|
||||||
return new ErrorResponse($"Component type '{componentType}' not found.");
|
return new ErrorResponse($"Component type '{componentType}' not found.");
|
||||||
|
|
@ -309,16 +288,6 @@ namespace MCPForUnity.Editor.Tools
|
||||||
return GameObjectLookup.FindByTarget(targetToken, searchMethod ?? "by_name", true);
|
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)
|
private static void SetPropertiesOnComponent(Component component, JObject properties)
|
||||||
{
|
{
|
||||||
if (component == null || properties == null)
|
if (component == null || properties == null)
|
||||||
|
|
@ -340,78 +309,20 @@ namespace MCPForUnity.Editor.Tools
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Attempts to set a property or field on a component.
|
/// 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>
|
/// </summary>
|
||||||
private static string TrySetProperty(Component component, string propertyName, JToken value)
|
private static string TrySetProperty(Component component, string propertyName, JToken value)
|
||||||
{
|
{
|
||||||
if (component == null || string.IsNullOrEmpty(propertyName))
|
if (component == null || string.IsNullOrEmpty(propertyName))
|
||||||
return $"Invalid component or property name";
|
return "Invalid component or property name";
|
||||||
|
|
||||||
var type = component.GetType();
|
if (ComponentOps.SetProperty(component, propertyName, value, out string error))
|
||||||
|
|
||||||
// Try property first
|
|
||||||
var propInfo = type.GetProperty(propertyName, BindingFlags.Public | BindingFlags.Instance | BindingFlags.IgnoreCase);
|
|
||||||
if (propInfo != null && propInfo.CanWrite)
|
|
||||||
{
|
{
|
||||||
try
|
|
||||||
{
|
|
||||||
var convertedValue = ConvertValue(value, propInfo.PropertyType);
|
|
||||||
propInfo.SetValue(component, convertedValue);
|
|
||||||
return null; // Success
|
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
|
Debug.LogWarning($"[ManageComponents] {error}");
|
||||||
var fieldInfo = type.GetField(propertyName, BindingFlags.Public | BindingFlags.Instance | BindingFlags.IgnoreCase);
|
return error;
|
||||||
if (fieldInfo != null)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var convertedValue = ConvertValue(value, fieldInfo.FieldType);
|
|
||||||
fieldInfo.SetValue(component, convertedValue);
|
|
||||||
return null; // Success
|
|
||||||
}
|
|
||||||
catch (Exception e)
|
|
||||||
{
|
|
||||||
Debug.LogWarning($"[ManageComponents] Failed to set field '{propertyName}': {e.Message}");
|
|
||||||
return $"Failed to set field '{propertyName}': {e.Message}";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Debug.LogWarning($"[ManageComponents] Property or field '{propertyName}' not found on {type.Name}");
|
|
||||||
return $"Property '{propertyName}' not found on {type.Name}";
|
|
||||||
}
|
|
||||||
|
|
||||||
private static object ConvertValue(JToken token, Type targetType)
|
|
||||||
{
|
|
||||||
if (token == null || token.Type == JTokenType.Null)
|
|
||||||
return null;
|
|
||||||
|
|
||||||
// Handle Unity types
|
|
||||||
if (targetType == typeof(Vector3))
|
|
||||||
{
|
|
||||||
return VectorParsing.ParseVector3OrDefault(token);
|
|
||||||
}
|
|
||||||
if (targetType == typeof(Vector2))
|
|
||||||
{
|
|
||||||
return VectorParsing.ParseVector2(token) ?? Vector2.zero;
|
|
||||||
}
|
|
||||||
if (targetType == typeof(Quaternion))
|
|
||||||
{
|
|
||||||
return VectorParsing.ParseQuaternion(token) ?? Quaternion.identity;
|
|
||||||
}
|
|
||||||
if (targetType == typeof(Color))
|
|
||||||
{
|
|
||||||
return VectorParsing.ParseColor(token) ?? Color.white;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use Newtonsoft for other types
|
|
||||||
return token.ToObject(targetType);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
|
|
|
||||||
|
|
@ -22,20 +22,8 @@ namespace MCPForUnity.Editor.Tools
|
||||||
[McpForUnityTool("manage_gameobject", AutoRegister = false)]
|
[McpForUnityTool("manage_gameobject", AutoRegister = false)]
|
||||||
public static class ManageGameObject
|
public static class ManageGameObject
|
||||||
{
|
{
|
||||||
// Shared JsonSerializer to avoid per-call allocation overhead
|
// Use shared serializer from helper class (backwards-compatible alias)
|
||||||
internal static readonly JsonSerializer InputSerializer = JsonSerializer.Create(new JsonSerializerSettings
|
internal static JsonSerializer InputSerializer => UnityJsonSerializer.Instance;
|
||||||
{
|
|
||||||
Converters = new List<JsonConverter>
|
|
||||||
{
|
|
||||||
new Vector3Converter(),
|
|
||||||
new Vector2Converter(),
|
|
||||||
new QuaternionConverter(),
|
|
||||||
new ColorConverter(),
|
|
||||||
new RectConverter(),
|
|
||||||
new BoundsConverter(),
|
|
||||||
new UnityEngineObjectConverter()
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// --- Main Handler ---
|
// --- 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 =
|
string targetPath =
|
||||||
targetToken?.Type == JTokenType.String ? targetToken.ToString() : null;
|
targetToken?.Type == JTokenType.String ? targetToken.ToString() : null;
|
||||||
if (
|
if (
|
||||||
!string.IsNullOrEmpty(targetPath)
|
!string.IsNullOrEmpty(targetPath)
|
||||||
&& targetPath.EndsWith(".prefab", StringComparison.OrdinalIgnoreCase)
|
&& 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(
|
return new ErrorResponse(
|
||||||
"Missing 'componentName' for 'set_component_property' on prefab."
|
$"Target '{targetPath}' is a prefab asset. " +
|
||||||
);
|
$"Use 'manage_asset' with action='modify' for prefab asset modifications, " +
|
||||||
if (compProps == null)
|
$"or 'manage_prefabs' with action='open_stage' to edit the prefab in isolation mode."
|
||||||
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."
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
// --- End Prefab Asset Check ---
|
||||||
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 ---
|
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
|
@ -398,42 +334,29 @@ namespace MCPForUnity.Editor.Tools
|
||||||
// Set Tag (added for create action)
|
// Set Tag (added for create action)
|
||||||
if (!string.IsNullOrEmpty(tag))
|
if (!string.IsNullOrEmpty(tag))
|
||||||
{
|
{
|
||||||
// Similar logic as in ModifyGameObject for setting/creating tags
|
// Check if tag exists first (Unity doesn't throw exceptions for undefined tags, just logs a warning)
|
||||||
string tagToSet = string.IsNullOrEmpty(tag) ? "Untagged" : tag;
|
if (tag != "Untagged" && !System.Linq.Enumerable.Contains(InternalEditorUtility.tags, tag))
|
||||||
|
{
|
||||||
|
Debug.Log($"[ManageGameObject.Create] Tag '{tag}' not found. Creating it.");
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
newGo.tag = tagToSet;
|
InternalEditorUtility.AddTag(tag);
|
||||||
}
|
}
|
||||||
catch (UnityException ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
if (ex.Message.Contains("is not defined"))
|
UnityEngine.Object.DestroyImmediate(newGo); // Clean up
|
||||||
{
|
return new ErrorResponse($"Failed to create tag '{tag}': {ex.Message}.");
|
||||||
Debug.LogWarning(
|
}
|
||||||
$"[ManageGameObject.Create] Tag '{tagToSet}' not found. Attempting to create it."
|
}
|
||||||
);
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
InternalEditorUtility.AddTag(tagToSet);
|
newGo.tag = tag;
|
||||||
newGo.tag = tagToSet; // Retry
|
|
||||||
Debug.Log(
|
|
||||||
$"[ManageGameObject.Create] Tag '{tagToSet}' created and assigned successfully."
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
catch (Exception innerEx)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
UnityEngine.Object.DestroyImmediate(newGo); // Clean up
|
UnityEngine.Object.DestroyImmediate(newGo); // Clean up
|
||||||
return new ErrorResponse(
|
return new ErrorResponse($"Failed to set tag to '{tag}' during creation: {ex.Message}.");
|
||||||
$"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}."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -666,51 +589,31 @@ namespace MCPForUnity.Editor.Tools
|
||||||
{
|
{
|
||||||
// Ensure the tag is not empty, if empty, it means "Untagged" implicitly
|
// Ensure the tag is not empty, if empty, it means "Untagged" implicitly
|
||||||
string tagToSet = string.IsNullOrEmpty(tag) ? "Untagged" : tag;
|
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;
|
targetGo.tag = tagToSet;
|
||||||
modified = true;
|
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}.");
|
return new ErrorResponse($"Failed to set tag to '{tagToSet}': {ex.Message}.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Change Layer (using consolidated 'layer' parameter)
|
// Change Layer (using consolidated 'layer' parameter)
|
||||||
string layerName = @params["layer"]?.ToString();
|
string layerName = @params["layer"]?.ToString();
|
||||||
|
|
@ -1288,22 +1191,20 @@ namespace MCPForUnity.Editor.Tools
|
||||||
Type componentType = FindType(searchTerm);
|
Type componentType = FindType(searchTerm);
|
||||||
if (componentType != null)
|
if (componentType != null)
|
||||||
{
|
{
|
||||||
// Determine FindObjectsInactive based on the searchInactive flag
|
IEnumerable<GameObject> searchPoolComp;
|
||||||
FindObjectsInactive findInactive = searchInactive
|
if (rootSearchObject)
|
||||||
? FindObjectsInactive.Include
|
{
|
||||||
: FindObjectsInactive.Exclude;
|
searchPoolComp = rootSearchObject
|
||||||
// Replace FindObjectsOfType with FindObjectsByType, specifying the sorting mode and inactive state
|
|
||||||
var searchPoolComp = rootSearchObject
|
|
||||||
? rootSearchObject
|
|
||||||
.GetComponentsInChildren(componentType, searchInactive)
|
.GetComponentsInChildren(componentType, searchInactive)
|
||||||
.Select(c => (c as Component).gameObject)
|
|
||||||
: UnityEngine
|
|
||||||
.Object.FindObjectsByType(
|
|
||||||
componentType,
|
|
||||||
findInactive,
|
|
||||||
FindObjectsSortMode.None
|
|
||||||
)
|
|
||||||
.Select(c => (c as Component).gameObject);
|
.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
|
results.AddRange(searchPoolComp.Where(go => go != null)); // Ensure GO is valid
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
|
|
@ -1560,7 +1461,7 @@ namespace MCPForUnity.Editor.Tools
|
||||||
if (!setResult)
|
if (!setResult)
|
||||||
{
|
{
|
||||||
var availableProperties = ComponentResolver.GetAllComponentProperties(targetComponent.GetType());
|
var availableProperties = ComponentResolver.GetAllComponentProperties(targetComponent.GetType());
|
||||||
var suggestions = ComponentResolver.GetAIPropertySuggestions(propName, availableProperties);
|
var suggestions = ComponentResolver.GetFuzzyPropertySuggestions(propName, availableProperties);
|
||||||
var msg = suggestions.Any()
|
var msg = suggestions.Any()
|
||||||
? $"Property '{propName}' not found. Did you mean: {string.Join(", ", suggestions)}? Available: [{string.Join(", ", availableProperties)}]"
|
? $"Property '{propName}' not found. Did you mean: {string.Join(", ", suggestions)}? Available: [{string.Join(", ", availableProperties)}]"
|
||||||
: $"Property '{propName}' not found. Available: [{string.Join(", ", availableProperties)}]";
|
: $"Property '{propName}' not found. Available: [{string.Join(", ", availableProperties)}]";
|
||||||
|
|
@ -1591,6 +1492,9 @@ namespace MCPForUnity.Editor.Tools
|
||||||
BindingFlags flags =
|
BindingFlags flags =
|
||||||
BindingFlags.Public | BindingFlags.Instance | BindingFlags.IgnoreCase;
|
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
|
// Use shared serializer to avoid per-call allocation
|
||||||
var inputSerializer = InputSerializer;
|
var inputSerializer = InputSerializer;
|
||||||
|
|
||||||
|
|
@ -1604,7 +1508,9 @@ namespace MCPForUnity.Editor.Tools
|
||||||
return SetNestedProperty(target, memberName, value, inputSerializer);
|
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)
|
if (propInfo != null && propInfo.CanWrite)
|
||||||
{
|
{
|
||||||
// Use the inputSerializer for conversion
|
// Use the inputSerializer for conversion
|
||||||
|
|
@ -1621,7 +1527,9 @@ namespace MCPForUnity.Editor.Tools
|
||||||
}
|
}
|
||||||
else
|
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?
|
if (fieldInfo != null) // Check if !IsLiteral?
|
||||||
{
|
{
|
||||||
// Use the inputSerializer for conversion
|
// Use the inputSerializer for conversion
|
||||||
|
|
@ -1638,8 +1546,9 @@ namespace MCPForUnity.Editor.Tools
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
// Try NonPublic [SerializeField] fields
|
// Try NonPublic [SerializeField] fields (with both original and normalized names)
|
||||||
var npField = type.GetField(memberName, BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.IgnoreCase);
|
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)
|
if (npField != null && npField.GetCustomAttribute<SerializeField>() != null)
|
||||||
{
|
{
|
||||||
object convertedValue = ConvertJTokenToType(value, npField.FieldType, inputSerializer);
|
object convertedValue = ConvertJTokenToType(value, npField.FieldType, inputSerializer);
|
||||||
|
|
@ -1871,45 +1780,14 @@ namespace MCPForUnity.Editor.Tools
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Simple JToken to Type conversion for common Unity types, using JsonSerializer.
|
/// Simple JToken to Type conversion for common Unity types, using JsonSerializer.
|
||||||
/// </summary>
|
/// </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)
|
private static object ConvertJTokenToType(JToken token, Type targetType, JsonSerializer inputSerializer)
|
||||||
{
|
{
|
||||||
if (token == null || token.Type == JTokenType.Null)
|
return PropertyConversion.ConvertToType(token, targetType);
|
||||||
{
|
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- ParseJTokenTo... helpers are likely redundant now with the serializer approach ---
|
// --- 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.
|
/// Finds a specific UnityEngine.Object based on a find instruction JObject.
|
||||||
/// Primarily used by UnityEngineObjectConverter during deserialization.
|
/// Primarily used by UnityEngineObjectConverter during deserialization.
|
||||||
/// </summary>
|
/// </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)
|
public static UnityEngine.Object FindObjectByInstruction(JObject instruction, Type targetType)
|
||||||
{
|
{
|
||||||
string findTerm = instruction["find"]?.ToString();
|
return ObjectResolver.Resolve(instruction, targetType);
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -2134,125 +1921,18 @@ namespace MCPForUnity.Editor.Tools
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Robust component resolver that avoids Assembly.LoadFrom and supports assembly definitions.
|
/// Component resolver that delegates to UnityTypeResolver.
|
||||||
/// Prioritizes runtime (Player) assemblies over Editor assemblies.
|
/// Kept for backwards compatibility.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
internal static class ComponentResolver
|
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>
|
/// <summary>
|
||||||
/// Resolve a Component/MonoBehaviour type by short or fully-qualified name.
|
/// Resolve a Component/MonoBehaviour type by short or fully-qualified name.
|
||||||
/// Prefers runtime (Player) script assemblies; falls back to Editor assemblies.
|
/// Delegates to UnityTypeResolver.TryResolve with Component constraint.
|
||||||
/// Never uses Assembly.LoadFrom.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static bool TryResolve(string nameOrFullName, out Type type, out string error)
|
public static bool TryResolve(string nameOrFullName, out Type type, out string error)
|
||||||
{
|
{
|
||||||
error = string.Empty;
|
return UnityTypeResolver.TryResolve(nameOrFullName, out type, out error, typeof(Component));
|
||||||
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.";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
@ -2279,45 +1959,28 @@ namespace MCPForUnity.Editor.Tools
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <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>
|
/// </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())
|
if (string.IsNullOrWhiteSpace(userInput) || !availableProperties.Any())
|
||||||
return new List<string>();
|
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)}";
|
var cacheKey = $"{userInput.ToLowerInvariant()}:{string.Join(",", availableProperties)}";
|
||||||
if (PropertySuggestionCache.TryGetValue(cacheKey, out var cached))
|
if (PropertySuggestionCache.TryGetValue(cacheKey, out var cached))
|
||||||
return cached;
|
return cached;
|
||||||
|
|
||||||
try
|
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);
|
var suggestions = GetRuleBasedSuggestions(userInput, availableProperties);
|
||||||
|
|
||||||
PropertySuggestionCache[cacheKey] = suggestions;
|
PropertySuggestionCache[cacheKey] = suggestions;
|
||||||
return suggestions;
|
return suggestions;
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
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>();
|
return new List<string>();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ namespace MCPForUnity.Editor.Tools
|
||||||
string action = @params["action"]?.ToString();
|
string action = @params["action"]?.ToString();
|
||||||
if (string.IsNullOrEmpty(action))
|
if (string.IsNullOrEmpty(action))
|
||||||
{
|
{
|
||||||
return new { status = "error", message = "Action is required" };
|
return new ErrorResponse("Action is required");
|
||||||
}
|
}
|
||||||
|
|
||||||
try
|
try
|
||||||
|
|
@ -24,7 +24,7 @@ namespace MCPForUnity.Editor.Tools
|
||||||
switch (action)
|
switch (action)
|
||||||
{
|
{
|
||||||
case "ping":
|
case "ping":
|
||||||
return new { status = "success", tool = "manage_material" };
|
return new SuccessResponse("pong", new { tool = "manage_material" });
|
||||||
|
|
||||||
case "create":
|
case "create":
|
||||||
return CreateMaterial(@params);
|
return CreateMaterial(@params);
|
||||||
|
|
@ -45,12 +45,12 @@ namespace MCPForUnity.Editor.Tools
|
||||||
return GetMaterialInfo(@params);
|
return GetMaterialInfo(@params);
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return new { status = "error", message = $"Unknown action: {action}" };
|
return new ErrorResponse($"Unknown action: {action}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
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)
|
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
|
// Find material
|
||||||
var findInstruction = new JObject { ["find"] = materialPath };
|
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)
|
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");
|
Undo.RecordObject(mat, "Set Material Property");
|
||||||
|
|
@ -101,27 +101,27 @@ namespace MCPForUnity.Editor.Tools
|
||||||
// Check if it looks like an instruction
|
// Check if it looks like an instruction
|
||||||
if (value is JObject obj && (obj.ContainsKey("find") || obj.ContainsKey("method")))
|
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))
|
if (tex != null && mat.HasProperty(property))
|
||||||
{
|
{
|
||||||
mat.SetTexture(property, tex);
|
mat.SetTexture(property, tex);
|
||||||
EditorUtility.SetDirty(mat);
|
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)
|
// 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)
|
if (success)
|
||||||
{
|
{
|
||||||
EditorUtility.SetDirty(mat);
|
EditorUtility.SetDirty(mat);
|
||||||
return new { status = "success", message = $"Set property {property} on {mat.name}" };
|
return new SuccessResponse($"Set property {property} on {mat.name}");
|
||||||
}
|
}
|
||||||
else
|
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)
|
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 };
|
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)
|
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;
|
Color color;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
color = MaterialOps.ParseColor(colorToken, ManageGameObject.InputSerializer);
|
color = MaterialOps.ParseColor(colorToken, UnityJsonSerializer.Instance);
|
||||||
}
|
}
|
||||||
catch (Exception e)
|
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");
|
Undo.RecordObject(mat, "Set Material Color");
|
||||||
|
|
@ -185,11 +185,11 @@ namespace MCPForUnity.Editor.Tools
|
||||||
if (foundProp)
|
if (foundProp)
|
||||||
{
|
{
|
||||||
EditorUtility.SetDirty(mat);
|
EditorUtility.SetDirty(mat);
|
||||||
return new { status = "success", message = $"Set color on {property}" };
|
return new SuccessResponse($"Set color on {property}");
|
||||||
}
|
}
|
||||||
else
|
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))
|
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 };
|
var goInstruction = new JObject { ["find"] = target };
|
||||||
if (!string.IsNullOrEmpty(searchMethod)) goInstruction["method"] = searchMethod;
|
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)
|
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>();
|
Renderer renderer = go.GetComponent<Renderer>();
|
||||||
if (renderer == null)
|
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 };
|
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)
|
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");
|
Undo.RecordObject(renderer, "Assign Material");
|
||||||
|
|
@ -232,14 +232,14 @@ namespace MCPForUnity.Editor.Tools
|
||||||
Material[] sharedMats = renderer.sharedMaterials;
|
Material[] sharedMats = renderer.sharedMaterials;
|
||||||
if (slot < 0 || slot >= sharedMats.Length)
|
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;
|
sharedMats[slot] = mat;
|
||||||
renderer.sharedMaterials = sharedMats;
|
renderer.sharedMaterials = sharedMats;
|
||||||
|
|
||||||
EditorUtility.SetDirty(renderer);
|
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)
|
private static object SetRendererColor(JObject @params)
|
||||||
|
|
@ -252,39 +252,39 @@ namespace MCPForUnity.Editor.Tools
|
||||||
|
|
||||||
if (string.IsNullOrEmpty(target) || colorToken == null)
|
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;
|
Color color;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
color = MaterialOps.ParseColor(colorToken, ManageGameObject.InputSerializer);
|
color = MaterialOps.ParseColor(colorToken, UnityJsonSerializer.Instance);
|
||||||
}
|
}
|
||||||
catch (Exception e)
|
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 };
|
var goInstruction = new JObject { ["find"] = target };
|
||||||
if (!string.IsNullOrEmpty(searchMethod)) goInstruction["method"] = searchMethod;
|
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)
|
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>();
|
Renderer renderer = go.GetComponent<Renderer>();
|
||||||
if (renderer == null)
|
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 (mode == "property_block")
|
||||||
{
|
{
|
||||||
if (slot < 0 || slot >= renderer.sharedMaterials.Length)
|
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();
|
MaterialPropertyBlock block = new MaterialPropertyBlock();
|
||||||
|
|
@ -304,7 +304,7 @@ namespace MCPForUnity.Editor.Tools
|
||||||
|
|
||||||
renderer.SetPropertyBlock(block, slot);
|
renderer.SetPropertyBlock(block, slot);
|
||||||
EditorUtility.SetDirty(renderer);
|
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")
|
else if (mode == "shared")
|
||||||
{
|
{
|
||||||
|
|
@ -313,15 +313,15 @@ namespace MCPForUnity.Editor.Tools
|
||||||
Material mat = renderer.sharedMaterials[slot];
|
Material mat = renderer.sharedMaterials[slot];
|
||||||
if (mat == null)
|
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");
|
Undo.RecordObject(mat, "Set Material Color");
|
||||||
if (mat.HasProperty("_BaseColor")) mat.SetColor("_BaseColor", color);
|
if (mat.HasProperty("_BaseColor")) mat.SetColor("_BaseColor", color);
|
||||||
else mat.SetColor("_Color", color);
|
else mat.SetColor("_Color", color);
|
||||||
EditorUtility.SetDirty(mat);
|
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")
|
else if (mode == "instance")
|
||||||
{
|
{
|
||||||
|
|
@ -330,18 +330,18 @@ namespace MCPForUnity.Editor.Tools
|
||||||
Material mat = renderer.materials[slot];
|
Material mat = renderer.materials[slot];
|
||||||
if (mat == null)
|
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
|
// Note: Undo cannot fully revert material instantiation
|
||||||
Undo.RecordObject(mat, "Set Instance Material Color");
|
Undo.RecordObject(mat, "Set Instance Material Color");
|
||||||
if (mat.HasProperty("_BaseColor")) mat.SetColor("_BaseColor", color);
|
if (mat.HasProperty("_BaseColor")) mat.SetColor("_BaseColor", color);
|
||||||
else mat.SetColor("_Color", 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)
|
private static object GetMaterialInfo(JObject @params)
|
||||||
|
|
@ -349,15 +349,15 @@ namespace MCPForUnity.Editor.Tools
|
||||||
string materialPath = NormalizePath(@params["materialPath"]?.ToString());
|
string materialPath = NormalizePath(@params["materialPath"]?.ToString());
|
||||||
if (string.IsNullOrEmpty(materialPath))
|
if (string.IsNullOrEmpty(materialPath))
|
||||||
{
|
{
|
||||||
return new { status = "error", message = "materialPath is required" };
|
return new ErrorResponse("materialPath is required");
|
||||||
}
|
}
|
||||||
|
|
||||||
var findInstruction = new JObject { ["find"] = materialPath };
|
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)
|
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;
|
Shader shader = mat.shader;
|
||||||
|
|
@ -448,12 +448,11 @@ namespace MCPForUnity.Editor.Tools
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
return new {
|
return new SuccessResponse($"Retrieved material info for {mat.name}", new {
|
||||||
status = "success",
|
|
||||||
material = mat.name,
|
material = mat.name,
|
||||||
shader = shader.name,
|
shader = shader.name,
|
||||||
properties = properties
|
properties = properties
|
||||||
};
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private static object CreateMaterial(JObject @params)
|
private static object CreateMaterial(JObject @params)
|
||||||
|
|
@ -470,7 +469,7 @@ namespace MCPForUnity.Editor.Tools
|
||||||
if (propsToken.Type == JTokenType.String)
|
if (propsToken.Type == JTokenType.String)
|
||||||
{
|
{
|
||||||
try { properties = JObject.Parse(propsToken.ToString()); }
|
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)
|
else if (propsToken is JObject obj)
|
||||||
{
|
{
|
||||||
|
|
@ -480,26 +479,26 @@ namespace MCPForUnity.Editor.Tools
|
||||||
|
|
||||||
if (string.IsNullOrEmpty(materialPath))
|
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
|
// Safety check: SanitizeAssetPath should guarantee Assets/ prefix
|
||||||
// but we ensure it's valid for CreateAsset
|
// This check catches edge cases where normalization might fail
|
||||||
if (!materialPath.StartsWith("Assets/"))
|
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);
|
Shader shader = RenderPipelineUtility.ResolveShader(shaderName);
|
||||||
if (shader == null)
|
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
|
// Check for existing asset to avoid silent overwrite
|
||||||
if (AssetDatabase.LoadAssetAtPath<Material>(materialPath) != null)
|
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;
|
Material material = null;
|
||||||
|
|
@ -534,11 +533,11 @@ namespace MCPForUnity.Editor.Tools
|
||||||
Color color;
|
Color color;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
color = MaterialOps.ParseColor(colorToken, ManageGameObject.InputSerializer);
|
color = MaterialOps.ParseColor(colorToken, UnityJsonSerializer.Instance);
|
||||||
}
|
}
|
||||||
catch (Exception e)
|
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))
|
if (!string.IsNullOrEmpty(colorProperty))
|
||||||
|
|
@ -549,11 +548,7 @@ namespace MCPForUnity.Editor.Tools
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
return new
|
return new ErrorResponse($"Specified color property '{colorProperty}' does not exist on this material.");
|
||||||
{
|
|
||||||
status = "error",
|
|
||||||
message = $"Specified color property '{colorProperty}' does not exist on this material."
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else if (material.HasProperty("_BaseColor"))
|
else if (material.HasProperty("_BaseColor"))
|
||||||
|
|
@ -566,11 +561,7 @@ namespace MCPForUnity.Editor.Tools
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
return new
|
return new ErrorResponse("Could not find suitable color property (_BaseColor or _Color) on this material's shader.");
|
||||||
{
|
|
||||||
status = "error",
|
|
||||||
message = "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)
|
if (properties != null)
|
||||||
{
|
{
|
||||||
MaterialOps.ApplyProperties(material, properties, ManageGameObject.InputSerializer);
|
MaterialOps.ApplyProperties(material, properties, UnityJsonSerializer.Instance);
|
||||||
}
|
}
|
||||||
|
|
||||||
EditorUtility.SetDirty(material);
|
EditorUtility.SetDirty(material);
|
||||||
AssetDatabase.SaveAssets();
|
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
|
finally
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -396,7 +396,8 @@ namespace MCPForUnity.Editor.Tools
|
||||||
Camera cam = Camera.main;
|
Camera cam = Camera.main;
|
||||||
if (cam == null)
|
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();
|
cam = cams.FirstOrDefault();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -887,45 +887,12 @@ namespace MCPForUnity.Editor.Tools
|
||||||
return normalized == "create" || normalized == "createso";
|
return normalized == "create" || normalized == "createso";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Resolves a type by name. Delegates to UnityTypeResolver.ResolveAny().
|
||||||
|
/// </summary>
|
||||||
private static Type ResolveType(string typeName)
|
private static Type ResolveType(string typeName)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(typeName)) return null;
|
return Helpers.UnityTypeResolver.ResolveAny(typeName);
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -547,17 +547,36 @@ namespace MCPForUnity.Editor.Windows.Components.Connection
|
||||||
// Wait briefly for the HTTP server to become ready, then start the session.
|
// 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).
|
// This is called when THIS instance starts the server (not when detecting an external server).
|
||||||
var bridgeService = MCPServiceLocator.Bridge;
|
var bridgeService = MCPServiceLocator.Bridge;
|
||||||
const int maxAttempts = 10;
|
// Windows/dev mode may take much longer due to uv package resolution, fresh downloads, antivirus scans, etc.
|
||||||
var delay = TimeSpan.FromSeconds(1);
|
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++)
|
for (int attempt = 0; attempt < maxAttempts; attempt++)
|
||||||
{
|
{
|
||||||
|
var delay = attempt < 6 ? shortDelay : longDelay;
|
||||||
|
|
||||||
// Check if server is actually accepting connections
|
// Check if server is actually accepting connections
|
||||||
if (!MCPServiceLocator.Server.IsLocalHttpServerRunning())
|
bool serverDetected = MCPServiceLocator.Server.IsLocalHttpServerRunning();
|
||||||
|
|
||||||
|
if (serverDetected)
|
||||||
{
|
{
|
||||||
await Task.Delay(delay);
|
// Server detected - try to connect
|
||||||
continue;
|
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();
|
bool started = await bridgeService.StartAsync();
|
||||||
if (started)
|
if (started)
|
||||||
|
|
@ -566,6 +585,7 @@ namespace MCPForUnity.Editor.Windows.Components.Connection
|
||||||
UpdateConnectionStatus();
|
UpdateConnectionStatus();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (attempt < maxAttempts - 1)
|
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>
|
/// <summary>
|
||||||
/// Safe converter for Matrix4x4 that only accesses raw matrix elements (m00-m33).
|
/// Safe converter for Matrix4x4 that only accesses raw matrix elements (m00-m33).
|
||||||
/// Avoids computed properties (lossyScale, rotation, inverse) that call ValidTRS()
|
/// 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`
|
### 💡 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
|
❌ Slow: Create 5 cubes → 5 separate manage_gameobject calls
|
||||||
✅ Fast: Create 5 cubes → 1 batch_execute call with 5 commands
|
✅ 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. "
|
"Executes multiple MCP commands in a single batch for dramatically better performance. "
|
||||||
"STRONGLY RECOMMENDED when creating/modifying multiple objects, adding components to multiple targets, "
|
"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 "
|
"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(
|
annotations=ToolAnnotations(
|
||||||
title="Batch Execute",
|
title="Batch Execute",
|
||||||
|
|
|
||||||
|
|
@ -25,8 +25,6 @@ async def execute_menu_item(
|
||||||
menu_path: Annotated[str,
|
menu_path: Annotated[str,
|
||||||
"Menu path for 'execute' or 'exists' (e.g., 'File/Save Project')"] | None = None,
|
"Menu path for 'execute' or 'exists' (e.g., 'File/Save Project')"] | None = None,
|
||||||
) -> MCPResponse:
|
) -> MCPResponse:
|
||||||
# Get active instance from session state
|
|
||||||
# Removed session_state import
|
|
||||||
unity_instance = get_unity_instance_from_context(ctx)
|
unity_instance = get_unity_instance_from_context(ctx)
|
||||||
params_dict: dict[str, Any] = {"menuPath": menu_path}
|
params_dict: dict[str, Any] = {"menuPath": menu_path}
|
||||||
params_dict = {k: v for k, v in params_dict.items() if v is not None}
|
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)
|
unity_instance = get_unity_instance_from_context(ctx)
|
||||||
|
|
||||||
gate = await preflight(ctx, wait_for_no_compile=True, refresh_if_dirty=True)
|
# Validate required parameters before preflight I/O
|
||||||
if gate is not None:
|
|
||||||
return gate.model_dump()
|
|
||||||
|
|
||||||
if not search_term:
|
if not search_term:
|
||||||
return {
|
return {
|
||||||
"success": False,
|
"success": False,
|
||||||
"message": "Missing required parameter 'search_term'. Specify what to search for."
|
"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
|
# Coerce parameters
|
||||||
include_inactive = coerce_bool(include_inactive, default=False)
|
include_inactive = coerce_bool(include_inactive, default=False)
|
||||||
page_size = coerce_int(page_size, default=50)
|
page_size = coerce_int(page_size, default=50)
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
"""
|
"""
|
||||||
Defines the manage_asset tool for interacting with Unity assets.
|
Defines the manage_asset tool for interacting with Unity assets.
|
||||||
"""
|
"""
|
||||||
import ast
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
from typing import Annotated, Any, Literal
|
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.registry import mcp_for_unity_tool
|
||||||
from services.tools import get_unity_instance_from_context
|
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.unity_transport import send_with_unity_instance
|
||||||
from transport.legacy.unity_connection import async_send_command_with_retry
|
from transport.legacy.unity_connection import async_send_command_with_retry
|
||||||
from services.tools.preflight import preflight
|
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(
|
@mcp_for_unity_tool(
|
||||||
description=(
|
description=(
|
||||||
"Performs asset operations (import, create, modify, delete, etc.) in Unity.\n\n"
|
"Performs asset operations (import, create, modify, delete, etc.) in Unity.\n\n"
|
||||||
|
|
@ -96,7 +60,7 @@ async def manage_asset(
|
||||||
return gate.model_dump()
|
return gate.model_dump()
|
||||||
|
|
||||||
# --- Normalize properties using robust module-level helper ---
|
# --- Normalize properties using robust module-level helper ---
|
||||||
properties, parse_error = _normalize_properties(properties)
|
properties, parse_error = normalize_properties(properties)
|
||||||
if parse_error:
|
if parse_error:
|
||||||
await ctx.error(f"manage_asset: {parse_error}")
|
await ctx.error(f"manage_asset: {parse_error}")
|
||||||
return {"success": False, "message": 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 services.tools import get_unity_instance_from_context
|
||||||
from transport.unity_transport import send_with_unity_instance
|
from transport.unity_transport import send_with_unity_instance
|
||||||
from transport.legacy.unity_connection import async_send_command_with_retry
|
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
|
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(
|
@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."
|
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 ---
|
# --- Normalize properties with detailed error handling ---
|
||||||
properties, props_error = _normalize_properties(properties)
|
properties, props_error = normalize_properties(properties)
|
||||||
if props_error:
|
if props_error:
|
||||||
return {"success": False, "message": props_error}
|
return {"success": False, "message": props_error}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -46,13 +46,9 @@ async def manage_editor(
|
||||||
params = {
|
params = {
|
||||||
"action": action,
|
"action": action,
|
||||||
"waitForCompletion": wait_for_completion,
|
"waitForCompletion": wait_for_completion,
|
||||||
"toolName": tool_name, # Corrected parameter name to match C#
|
"toolName": tool_name,
|
||||||
"tagName": tag_name, # Pass tag name
|
"tagName": tag_name,
|
||||||
"layerName": layer_name, # Pass layer name
|
"layerName": layer_name,
|
||||||
# Add other parameters based on the action being performed
|
|
||||||
# "width": width,
|
|
||||||
# "height": height,
|
|
||||||
# etc.
|
|
||||||
}
|
}
|
||||||
params = {k: v for k, v in params.items() if v is not None}
|
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(
|
@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(
|
annotations=ToolAnnotations(
|
||||||
title="Manage GameObject",
|
title="Manage GameObject",
|
||||||
destructiveHint=True,
|
destructiveHint=True,
|
||||||
|
|
@ -91,7 +91,7 @@ def _normalize_component_properties(value: Any) -> tuple[dict[str, dict[str, Any
|
||||||
)
|
)
|
||||||
async def manage_gameobject(
|
async def manage_gameobject(
|
||||||
ctx: Context,
|
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,
|
target: Annotated[str,
|
||||||
"GameObject identifier by name or path for modify/delete/component actions"] | None = None,
|
"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"],
|
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:
|
if action is None:
|
||||||
return {
|
return {
|
||||||
"success": False,
|
"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 ---
|
# --- Normalize vector parameters using robust helper ---
|
||||||
|
|
@ -205,23 +205,7 @@ async def manage_gameobject(
|
||||||
return {"success": False, "message": comp_props_error}
|
return {"success": False, "message": comp_props_error}
|
||||||
|
|
||||||
try:
|
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
|
# 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 action in ["create", "modify"]:
|
||||||
if search_term is not None:
|
if search_term is not None:
|
||||||
return {
|
return {
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ from mcp.types import ToolAnnotations
|
||||||
|
|
||||||
from services.registry import mcp_for_unity_tool
|
from services.registry import mcp_for_unity_tool
|
||||||
from services.tools import get_unity_instance_from_context
|
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.unity_transport import send_with_unity_instance
|
||||||
from transport.legacy.unity_connection import async_send_command_with_retry
|
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__}"
|
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(
|
@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.",
|
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(
|
annotations=ToolAnnotations(
|
||||||
|
|
@ -117,7 +95,7 @@ async def manage_material(
|
||||||
return {"success": False, "message": color_error}
|
return {"success": False, "message": color_error}
|
||||||
|
|
||||||
# --- Normalize properties with validation ---
|
# --- Normalize properties with validation ---
|
||||||
properties, props_error = _normalize_properties(properties)
|
properties, props_error = normalize_properties(properties)
|
||||||
if props_error:
|
if props_error:
|
||||||
return {"success": False, "message": 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"],
|
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'"],
|
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",
|
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#')",
|
script_type: Annotated[str, "Script type (e.g., 'C#')",
|
||||||
"Type hint (e.g., 'MonoBehaviour')"] | None = None,
|
"Type hint (e.g., 'MonoBehaviour')"] | None = None,
|
||||||
namespace: Annotated[str, "Namespace for the script"] | 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.",
|
description="Manages shader scripts in Unity (create, read, update, delete). Read-only action: read. Modifying actions: create, update, delete.",
|
||||||
annotations=ToolAnnotations(
|
annotations=ToolAnnotations(
|
||||||
title="Manage Shader",
|
title="Manage Shader",
|
||||||
destructiveHint=True,
|
destructiveHint=True, # Note: 'read' action is non-destructive; 'create', 'update', 'delete' are destructive
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
async def manage_shader(
|
async def manage_shader(
|
||||||
|
|
|
||||||
|
|
@ -75,3 +75,38 @@ def coerce_int(value: Any, default: int | None = None) -> int | None:
|
||||||
return int(float(s))
|
return int(float(s))
|
||||||
except Exception:
|
except Exception:
|
||||||
return default
|
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
|
# Verify behavior: parsing fails with a clear error
|
||||||
assert result.get("success") is False
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_properties_dict_unchanged(self, monkeypatch):
|
async def test_properties_dict_unchanged(self, monkeypatch):
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,8 @@ import services.tools.manage_gameobject as manage_go_mod
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@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 = {}
|
captured = {}
|
||||||
|
|
||||||
async def fake_send(cmd, params, **kwargs):
|
async def fake_send(cmd, params, **kwargs):
|
||||||
|
|
@ -18,26 +19,24 @@ async def test_manage_gameobject_boolean_and_tag_mapping(monkeypatch):
|
||||||
fake_send,
|
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(
|
resp = await manage_go_mod.manage_gameobject(
|
||||||
ctx=DummyContext(),
|
ctx=DummyContext(),
|
||||||
action="find",
|
action="modify",
|
||||||
search_method="by_tag",
|
target="Player",
|
||||||
tag="Player",
|
set_active="true", # String should be coerced to bool
|
||||||
find_all="true",
|
|
||||||
search_inactive="0",
|
|
||||||
)
|
)
|
||||||
# Loosen equality: wrapper may include a diagnostic message
|
|
||||||
assert resp.get("success") is True
|
assert resp.get("success") is True
|
||||||
assert "data" in resp
|
assert captured["params"]["action"] == "modify"
|
||||||
# ensure tag mapped to searchTerm and booleans passed through; C# side coerces true/false already
|
assert captured["params"]["target"] == "Player"
|
||||||
assert captured["params"]["searchTerm"] == "Player"
|
# setActive string "true" is coerced to bool True
|
||||||
assert captured["params"]["findAll"] is True
|
assert captured["params"]["setActive"] is True
|
||||||
assert captured["params"]["searchInactive"] is False
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@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 = {}
|
captured = {}
|
||||||
|
|
||||||
async def fake_send(cmd, params, **kwargs):
|
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(
|
resp = await manage_go_mod.manage_gameobject(
|
||||||
ctx=DummyContext(),
|
ctx=DummyContext(),
|
||||||
action="get_components",
|
action="create",
|
||||||
target="Player",
|
name="TestObject",
|
||||||
search_method="by_name",
|
tag="Player",
|
||||||
page_size="25",
|
position=[1.0, 2.0, 3.0],
|
||||||
cursor="50",
|
|
||||||
max_components="100",
|
|
||||||
include_properties="true",
|
|
||||||
)
|
)
|
||||||
|
|
||||||
assert resp.get("success") is True
|
assert resp.get("success") is True
|
||||||
p = captured["params"]
|
p = captured["params"]
|
||||||
assert p["action"] == "get_components"
|
assert p["action"] == "create"
|
||||||
assert p["target"] == "Player"
|
assert p["name"] == "TestObject"
|
||||||
assert p["searchMethod"] == "by_name"
|
assert p["tag"] == "Player"
|
||||||
assert p["pageSize"] == 25
|
assert p["position"] == [1.0, 2.0, 3.0]
|
||||||
assert p["cursor"] == 50
|
|
||||||
assert p["maxComponents"] == 100
|
|
||||||
assert p["includeProperties"] is True
|
|
||||||
|
|
|
||||||
|
|
@ -21,9 +21,9 @@ public class LongUnityScriptClaudeTest : MonoBehaviour
|
||||||
private int padAccumulator = 0;
|
private int padAccumulator = 0;
|
||||||
private Vector3 padVector = Vector3.zero;
|
private Vector3 padVector = Vector3.zero;
|
||||||
|
|
||||||
// Animation blend hashes
|
// Animation blend hashes (match animator parameter names)
|
||||||
private static readonly int BlendXHash = Animator.StringToHash("BlendX");
|
private static readonly int BlendXHash = Animator.StringToHash("reachX");
|
||||||
private static readonly int BlendYHash = Animator.StringToHash("BlendY");
|
private static readonly int BlendYHash = Animator.StringToHash("reachY");
|
||||||
|
|
||||||
|
|
||||||
[Header("Tuning")]
|
[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]
|
[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");
|
Assert.IsEmpty(suggestions, "Null input should return no suggestions");
|
||||||
}
|
}
|
||||||
|
|
||||||
[Test]
|
[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");
|
Assert.IsEmpty(suggestions, "Empty input should return no suggestions");
|
||||||
}
|
}
|
||||||
|
|
||||||
[Test]
|
[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");
|
Assert.IsEmpty(suggestions, "Empty property list should return no suggestions");
|
||||||
}
|
}
|
||||||
|
|
||||||
[Test]
|
[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.Contains("maxReachDistance", suggestions, "Should find exact match after cleaning spaces");
|
||||||
Assert.GreaterOrEqual(suggestions.Count, 1, "Should return at least one match for exact match");
|
Assert.GreaterOrEqual(suggestions.Count, 1, "Should return at least one match for exact match");
|
||||||
}
|
}
|
||||||
|
|
||||||
[Test]
|
[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("maxReachDistance", suggestions, "Should match maxReachDistance");
|
||||||
Assert.Contains("maxHorizontalDistance", suggestions, "Should match maxHorizontalDistance");
|
Assert.Contains("maxHorizontalDistance", suggestions, "Should match maxHorizontalDistance");
|
||||||
|
|
@ -90,54 +90,54 @@ namespace MCPForUnityTests.Editor.Tools
|
||||||
}
|
}
|
||||||
|
|
||||||
[Test]
|
[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");
|
Assert.Contains("moveSpeed", suggestions, "Should find moveSpeed despite missing capital");
|
||||||
}
|
}
|
||||||
|
|
||||||
[Test]
|
[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
|
// Note: Current algorithm might not find "mass" but should handle it gracefully
|
||||||
Assert.IsNotNull(suggestions, "Should return valid suggestions list");
|
Assert.IsNotNull(suggestions, "Should return valid suggestions list");
|
||||||
}
|
}
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
public void GetAIPropertySuggestions_LimitsResults_ToReasonableNumber()
|
public void GetFuzzyPropertySuggestions_LimitsResults_ToReasonableNumber()
|
||||||
{
|
{
|
||||||
// Test with input that might match many properties
|
// 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");
|
Assert.LessOrEqual(suggestions.Count, 3, "Should limit suggestions to 3 or fewer");
|
||||||
}
|
}
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
public void GetAIPropertySuggestions_CachesResults()
|
public void GetFuzzyPropertySuggestions_CachesResults()
|
||||||
{
|
{
|
||||||
var input = "Max Reach Distance";
|
var input = "Max Reach Distance";
|
||||||
|
|
||||||
// First call
|
// First call
|
||||||
var suggestions1 = ComponentResolver.GetAIPropertySuggestions(input, sampleProperties);
|
var suggestions1 = ComponentResolver.GetFuzzyPropertySuggestions(input, sampleProperties);
|
||||||
|
|
||||||
// Second call should use cache (tested indirectly by ensuring consistency)
|
// 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");
|
Assert.AreEqual(suggestions1.Count, suggestions2.Count, "Cached results should be consistent");
|
||||||
CollectionAssert.AreEqual(suggestions1, suggestions2, "Cached results should be identical");
|
CollectionAssert.AreEqual(suggestions1, suggestions2, "Cached results should be identical");
|
||||||
}
|
}
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
public void GetAIPropertySuggestions_HandlesUnityNamingConventions()
|
public void GetFuzzyPropertySuggestions_HandlesUnityNamingConventions()
|
||||||
{
|
{
|
||||||
var unityStyleProperties = new List<string> { "isKinematic", "useGravity", "maxLinearVelocity" };
|
var unityStyleProperties = new List<string> { "isKinematic", "useGravity", "maxLinearVelocity" };
|
||||||
|
|
||||||
var suggestions1 = ComponentResolver.GetAIPropertySuggestions("is kinematic", unityStyleProperties);
|
var suggestions1 = ComponentResolver.GetFuzzyPropertySuggestions("is kinematic", unityStyleProperties);
|
||||||
var suggestions2 = ComponentResolver.GetAIPropertySuggestions("use gravity", unityStyleProperties);
|
var suggestions2 = ComponentResolver.GetFuzzyPropertySuggestions("use gravity", unityStyleProperties);
|
||||||
var suggestions3 = ComponentResolver.GetAIPropertySuggestions("max linear velocity", unityStyleProperties);
|
var suggestions3 = ComponentResolver.GetFuzzyPropertySuggestions("max linear velocity", unityStyleProperties);
|
||||||
|
|
||||||
Assert.Contains("isKinematic", suggestions1, "Should handle 'is' prefix convention");
|
Assert.Contains("isKinematic", suggestions1, "Should handle 'is' prefix convention");
|
||||||
Assert.Contains("useGravity", suggestions2, "Should handle 'use' prefix convention");
|
Assert.Contains("useGravity", suggestions2, "Should handle 'use' prefix convention");
|
||||||
|
|
@ -145,10 +145,10 @@ namespace MCPForUnityTests.Editor.Tools
|
||||||
}
|
}
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
public void GetAIPropertySuggestions_PrioritizesExactMatches()
|
public void GetFuzzyPropertySuggestions_PrioritizesExactMatches()
|
||||||
{
|
{
|
||||||
var properties = new List<string> { "speed", "moveSpeed", "maxSpeed", "speedMultiplier" };
|
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.IsNotEmpty(suggestions, "Should find suggestions");
|
||||||
Assert.Contains("speed", suggestions, "Exact match should be included in results");
|
Assert.Contains("speed", suggestions, "Exact match should be included in results");
|
||||||
|
|
@ -156,10 +156,10 @@ namespace MCPForUnityTests.Editor.Tools
|
||||||
}
|
}
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
public void GetAIPropertySuggestions_HandlesCaseInsensitive()
|
public void GetFuzzyPropertySuggestions_HandlesCaseInsensitive()
|
||||||
{
|
{
|
||||||
var suggestions1 = ComponentResolver.GetAIPropertySuggestions("MAXREACHDISTANCE", sampleProperties);
|
var suggestions1 = ComponentResolver.GetFuzzyPropertySuggestions("MAXREACHDISTANCE", sampleProperties);
|
||||||
var suggestions2 = ComponentResolver.GetAIPropertySuggestions("maxreachdistance", sampleProperties);
|
var suggestions2 = ComponentResolver.GetFuzzyPropertySuggestions("maxreachdistance", sampleProperties);
|
||||||
|
|
||||||
Assert.Contains("maxReachDistance", suggestions1, "Should handle uppercase input");
|
Assert.Contains("maxReachDistance", suggestions1, "Should handle uppercase input");
|
||||||
Assert.Contains("maxReachDistance", suggestions2, "Should handle lowercase input");
|
Assert.Contains("maxReachDistance", suggestions2, "Should handle lowercase input");
|
||||||
|
|
|
||||||
|
|
@ -54,7 +54,16 @@ namespace MCPForUnityTests.Editor.Tools
|
||||||
private static JObject ToJObject(object result)
|
private static JObject ToJObject(object result)
|
||||||
{
|
{
|
||||||
if (result == null) return new JObject();
|
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
|
#region Bulk GameObject Creation
|
||||||
|
|
@ -85,7 +94,8 @@ namespace MCPForUnityTests.Editor.Tools
|
||||||
|
|
||||||
sw.Stop();
|
sw.Stop();
|
||||||
Debug.Log($"[BulkCreate] Created {SMALL_BATCH} objects in {sw.ElapsedMilliseconds}ms");
|
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]
|
[Test]
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using NUnit.Framework;
|
using NUnit.Framework;
|
||||||
using UnityEngine;
|
using UnityEngine;
|
||||||
|
using UnityEditorInternal;
|
||||||
using Newtonsoft.Json.Linq;
|
using Newtonsoft.Json.Linq;
|
||||||
using MCPForUnity.Editor.Tools;
|
using MCPForUnity.Editor.Tools;
|
||||||
|
|
||||||
|
|
@ -401,25 +402,33 @@ namespace MCPForUnityTests.Editor.Tools
|
||||||
}
|
}
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
public void Create_WithInvalidTag_HandlesGracefully()
|
public void Create_WithNewTag_AutoCreatesTag()
|
||||||
{
|
{
|
||||||
// Expect the error log from Unity about invalid tag
|
const string testTag = "AutoCreatedTag12345";
|
||||||
UnityEngine.TestTools.LogAssert.Expect(LogType.Error,
|
|
||||||
new System.Text.RegularExpressions.Regex("Tag:.*NonExistentTag12345.*not defined"));
|
|
||||||
|
|
||||||
|
// Tags that don't exist are now auto-created
|
||||||
var p = new JObject
|
var p = new JObject
|
||||||
{
|
{
|
||||||
["action"] = "create",
|
["action"] = "create",
|
||||||
["name"] = "TestInvalidTag",
|
["name"] = "TestAutoTag",
|
||||||
["tag"] = "NonExistentTag12345"
|
["tag"] = testTag
|
||||||
};
|
};
|
||||||
|
|
||||||
var result = ManageGameObject.HandleCommand(p);
|
var result = ManageGameObject.HandleCommand(p);
|
||||||
// Current behavior: logs error but may create object anyway
|
var resultObj = result as JObject ?? JObject.FromObject(result);
|
||||||
Assert.IsNotNull(result, "Should return a result");
|
|
||||||
|
|
||||||
// Clean up if object was created
|
Assert.IsTrue(resultObj.Value<bool>("success"), resultObj.ToString());
|
||||||
FindAndTrack("TestInvalidTag");
|
|
||||||
|
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
|
#endregion
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using NUnit.Framework;
|
using NUnit.Framework;
|
||||||
using UnityEngine;
|
using UnityEngine;
|
||||||
|
using UnityEditorInternal;
|
||||||
using Newtonsoft.Json.Linq;
|
using Newtonsoft.Json.Linq;
|
||||||
using MCPForUnity.Editor.Tools;
|
using MCPForUnity.Editor.Tools;
|
||||||
|
|
||||||
|
|
@ -392,22 +393,30 @@ namespace MCPForUnityTests.Editor.Tools
|
||||||
}
|
}
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
public void Modify_InvalidTag_HandlesGracefully()
|
public void Modify_NewTag_AutoCreatesTag()
|
||||||
{
|
{
|
||||||
// Expect the error log from Unity about invalid tag
|
const string testTag = "AutoModifyTag12345";
|
||||||
UnityEngine.TestTools.LogAssert.Expect(LogType.Error,
|
|
||||||
new System.Text.RegularExpressions.Regex("Tag:.*NonExistentTag12345.*not defined"));
|
|
||||||
|
|
||||||
|
// Tags that don't exist are now auto-created
|
||||||
var p = new JObject
|
var p = new JObject
|
||||||
{
|
{
|
||||||
["action"] = "modify",
|
["action"] = "modify",
|
||||||
["target"] = "ModifyTestObject",
|
["target"] = "ModifyTestObject",
|
||||||
["tag"] = "NonExistentTag12345"
|
["tag"] = testTag
|
||||||
};
|
};
|
||||||
|
|
||||||
var result = ManageGameObject.HandleCommand(p);
|
var result = ManageGameObject.HandleCommand(p);
|
||||||
// Current behavior: logs error but continues
|
var resultObj = result as JObject ?? JObject.FromObject(result);
|
||||||
Assert.IsNotNull(result, "Should return a 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
|
#endregion
|
||||||
|
|
|
||||||
|
|
@ -122,7 +122,7 @@ namespace MCPForUnityTests.Editor.Tools
|
||||||
Assert.Contains("useGravity", properties, "Rigidbody should have useGravity property");
|
Assert.Contains("useGravity", properties, "Rigidbody should have useGravity property");
|
||||||
|
|
||||||
// Test AI suggestions
|
// 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'");
|
Assert.Contains("useGravity", suggestions, "Should suggest useGravity for 'Use Gravity'");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -153,7 +153,7 @@ namespace MCPForUnityTests.Editor.Tools
|
||||||
|
|
||||||
foreach (var (input, expected) in testCases)
|
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}'");
|
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
|
// This test verifies that error messages are helpful and contain suggestions
|
||||||
var testProperties = new List<string> { "mass", "velocity", "drag", "useGravity" };
|
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
|
// Even if no perfect match, should return valid list
|
||||||
Assert.IsNotNull(suggestions, "Should return valid suggestions list");
|
Assert.IsNotNull(suggestions, "Should return valid suggestions list");
|
||||||
|
|
||||||
// Test with completely invalid input
|
// 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");
|
Assert.IsNotNull(badSuggestions, "Should handle invalid input gracefully");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -181,12 +181,12 @@ namespace MCPForUnityTests.Editor.Tools
|
||||||
|
|
||||||
// First call - populate cache
|
// First call - populate cache
|
||||||
var startTime = System.DateTime.UtcNow;
|
var startTime = System.DateTime.UtcNow;
|
||||||
var suggestions1 = ComponentResolver.GetAIPropertySuggestions(input, properties);
|
var suggestions1 = ComponentResolver.GetFuzzyPropertySuggestions(input, properties);
|
||||||
var firstCallTime = (System.DateTime.UtcNow - startTime).TotalMilliseconds;
|
var firstCallTime = (System.DateTime.UtcNow - startTime).TotalMilliseconds;
|
||||||
|
|
||||||
// Second call - should use cache
|
// Second call - should use cache
|
||||||
startTime = System.DateTime.UtcNow;
|
startTime = System.DateTime.UtcNow;
|
||||||
var suggestions2 = ComponentResolver.GetAIPropertySuggestions(input, properties);
|
var suggestions2 = ComponentResolver.GetFuzzyPropertySuggestions(input, properties);
|
||||||
var secondCallTime = (System.DateTime.UtcNow - startTime).TotalMilliseconds;
|
var secondCallTime = (System.DateTime.UtcNow - startTime).TotalMilliseconds;
|
||||||
|
|
||||||
Assert.AreEqual(suggestions1.Count, suggestions2.Count, "Cached results should be identical");
|
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
|
// Expect the error logs from the invalid property
|
||||||
LogAssert.Expect(LogType.Error, new System.Text.RegularExpressions.Regex("Unexpected error converting token to UnityEngine.Vector3"));
|
// Note: PropertyConversion logs "Error converting token to..." when conversion fails
|
||||||
LogAssert.Expect(LogType.Error, new System.Text.RegularExpressions.Regex("SetProperty.*Failed to set 'velocity'"));
|
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"));
|
LogAssert.Expect(LogType.Warning, new System.Text.RegularExpressions.Regex("Property 'velocity' not found"));
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
|
|
@ -555,5 +556,81 @@ namespace MCPForUnityTests.Editor.Tools
|
||||||
UnityEngine.Object.DestroyImmediate(material2);
|
UnityEngine.Object.DestroyImmediate(material2);
|
||||||
UnityEngine.Object.DestroyImmediate(testObject);
|
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));
|
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);
|
var mat = AssetDatabase.LoadAssetAtPath<Material>(_matPath);
|
||||||
Assert.AreEqual(Color.red, mat.color);
|
Assert.AreEqual(Color.red, mat.color);
|
||||||
}
|
}
|
||||||
|
|
@ -75,7 +75,7 @@ namespace MCPForUnityTests.Editor.Tools
|
||||||
|
|
||||||
var result = ToJObject(ManageMaterial.HandleCommand(paramsObj));
|
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);
|
var mat = AssetDatabase.LoadAssetAtPath<Material>(_matPath);
|
||||||
Assert.AreEqual(Color.green, mat.color);
|
Assert.AreEqual(Color.green, mat.color);
|
||||||
}
|
}
|
||||||
|
|
@ -93,7 +93,7 @@ namespace MCPForUnityTests.Editor.Tools
|
||||||
|
|
||||||
var result = ToJObject(ManageMaterial.HandleCommand(paramsObj));
|
var result = ToJObject(ManageMaterial.HandleCommand(paramsObj));
|
||||||
|
|
||||||
Assert.AreEqual("success", result.Value<string>("status"));
|
Assert.IsTrue(result.Value<bool>("success"), result.ToString());
|
||||||
}
|
}
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
|
|
@ -112,8 +112,8 @@ namespace MCPForUnityTests.Editor.Tools
|
||||||
|
|
||||||
var result = ToJObject(ManageMaterial.HandleCommand(paramsObj));
|
var result = ToJObject(ManageMaterial.HandleCommand(paramsObj));
|
||||||
|
|
||||||
Assert.AreEqual("error", result.Value<string>("status"));
|
Assert.IsFalse(result.Value<bool>("success"));
|
||||||
string msg = result.Value<string>("message");
|
string msg = result.Value<string>("error");
|
||||||
|
|
||||||
// Verify we get exception details
|
// Verify we get exception details
|
||||||
Assert.IsTrue(msg.Contains("Invalid JSON"), "Should mention Invalid JSON");
|
Assert.IsTrue(msg.Contains("Invalid JSON"), "Should mention Invalid JSON");
|
||||||
|
|
@ -140,9 +140,9 @@ namespace MCPForUnityTests.Editor.Tools
|
||||||
var result = ToJObject(ManageMaterial.HandleCommand(paramsObj));
|
var result = ToJObject(ManageMaterial.HandleCommand(paramsObj));
|
||||||
|
|
||||||
// We accept either success (ignored) or specific error, but not crash
|
// We accept either success (ignored) or specific error, but not crash
|
||||||
// Assert.AreNotEqual("internal_error", result.Value<string>("status"));
|
// The new response format uses a bool "success" field
|
||||||
var status = result.Value<string>("status");
|
var success = result.Value<bool?>("success");
|
||||||
Assert.IsTrue(status == "success" || status == "error", $"Status should be success or error, got {status}");
|
Assert.IsNotNull(success, "Response should have success field");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -61,10 +61,10 @@ namespace MCPForUnityTests.Editor.Tools
|
||||||
var result = ToJObject(ManageMaterial.HandleCommand(paramsObj));
|
var result = ToJObject(ManageMaterial.HandleCommand(paramsObj));
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
Assert.AreEqual("error", result.Value<string>("status"));
|
Assert.IsFalse(result.Value<bool>("success"));
|
||||||
|
|
||||||
// We expect more detailed error message after fix
|
// 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.IsTrue(message.StartsWith("Invalid JSON in properties"), "Message should start with prefix");
|
||||||
Assert.AreNotEqual("Invalid JSON in properties", message, "Message should contain exception details");
|
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)
|
["color"] = new JArray(1f, 0f, 0f, 1f)
|
||||||
};
|
};
|
||||||
var resultBadPath = ToJObject(ManageMaterial.HandleCommand(paramsBadPath));
|
var resultBadPath = ToJObject(ManageMaterial.HandleCommand(paramsBadPath));
|
||||||
Assert.AreEqual("error", resultBadPath.Value<string>("status"));
|
Assert.IsFalse(resultBadPath.Value<bool>("success"));
|
||||||
StringAssert.Contains("Could not find material", resultBadPath.Value<string>("message"));
|
StringAssert.Contains("Could not find material", resultBadPath.Value<string>("error"));
|
||||||
|
|
||||||
// 2. Bad color array (too short)
|
// 2. Bad color array (too short)
|
||||||
var paramsBadColor = new JObject
|
var paramsBadColor = new JObject
|
||||||
|
|
@ -91,8 +91,8 @@ namespace MCPForUnityTests.Editor.Tools
|
||||||
["color"] = new JArray(1f) // Invalid
|
["color"] = new JArray(1f) // Invalid
|
||||||
};
|
};
|
||||||
var resultBadColor = ToJObject(ManageMaterial.HandleCommand(paramsBadColor));
|
var resultBadColor = ToJObject(ManageMaterial.HandleCommand(paramsBadColor));
|
||||||
Assert.AreEqual("error", resultBadColor.Value<string>("status"));
|
Assert.IsFalse(resultBadColor.Value<bool>("success"));
|
||||||
StringAssert.Contains("Invalid color format", resultBadColor.Value<string>("message"));
|
StringAssert.Contains("Invalid color format", resultBadColor.Value<string>("error"));
|
||||||
|
|
||||||
// 3. Bad slot index
|
// 3. Bad slot index
|
||||||
// Assign material first
|
// Assign material first
|
||||||
|
|
@ -108,8 +108,8 @@ namespace MCPForUnityTests.Editor.Tools
|
||||||
["slot"] = 99
|
["slot"] = 99
|
||||||
};
|
};
|
||||||
var resultBadSlot = ToJObject(ManageMaterial.HandleCommand(paramsBadSlot));
|
var resultBadSlot = ToJObject(ManageMaterial.HandleCommand(paramsBadSlot));
|
||||||
Assert.AreEqual("error", resultBadSlot.Value<string>("status"));
|
Assert.IsFalse(resultBadSlot.Value<bool>("success"));
|
||||||
StringAssert.Contains("out of bounds", resultBadSlot.Value<string>("message"));
|
StringAssert.Contains("out of bounds", resultBadSlot.Value<string>("error"));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
|
|
@ -137,7 +137,7 @@ namespace MCPForUnityTests.Editor.Tools
|
||||||
};
|
};
|
||||||
|
|
||||||
var result = ToJObject(ManageMaterial.HandleCommand(paramsObj));
|
var result = ToJObject(ManageMaterial.HandleCommand(paramsObj));
|
||||||
Assert.AreEqual("success", result.Value<string>("status"));
|
Assert.IsTrue(result.Value<bool>("success"), result.ToString());
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
// 1. Renderer has property block with Red
|
// 1. Renderer has property block with Red
|
||||||
|
|
@ -168,7 +168,7 @@ namespace MCPForUnityTests.Editor.Tools
|
||||||
["materialPath"] = _matPath
|
["materialPath"] = _matPath
|
||||||
};
|
};
|
||||||
var assignResult = ToJObject(ManageMaterial.HandleCommand(assignParams));
|
var assignResult = ToJObject(ManageMaterial.HandleCommand(assignParams));
|
||||||
Assert.AreEqual("success", assignResult.Value<string>("status"));
|
Assert.IsTrue(assignResult.Value<bool>("success"), assignResult.ToString());
|
||||||
|
|
||||||
// Verify assignment
|
// Verify assignment
|
||||||
var renderer = _cube.GetComponent<Renderer>();
|
var renderer = _cube.GetComponent<Renderer>();
|
||||||
|
|
@ -183,7 +183,7 @@ namespace MCPForUnityTests.Editor.Tools
|
||||||
["color"] = new JArray(newColor.r, newColor.g, newColor.b, newColor.a)
|
["color"] = new JArray(newColor.r, newColor.g, newColor.b, newColor.a)
|
||||||
};
|
};
|
||||||
var colorResult = ToJObject(ManageMaterial.HandleCommand(colorParams));
|
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)
|
// Verify color changed on renderer (because it's shared)
|
||||||
var propName = renderer.sharedMaterial.HasProperty("_BaseColor") ? "_BaseColor" : "_Color";
|
var propName = renderer.sharedMaterial.HasProperty("_BaseColor") ? "_BaseColor" : "_Color";
|
||||||
|
|
|
||||||
|
|
@ -84,7 +84,7 @@ namespace MCPForUnityTests.Editor.Tools
|
||||||
var result = ToJObject(ManageMaterial.HandleCommand(paramsObj));
|
var result = ToJObject(ManageMaterial.HandleCommand(paramsObj));
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
Assert.AreEqual("success", result.Value<string>("status"), result.ToString());
|
Assert.IsTrue(result.Value<bool>("success"), result.ToString());
|
||||||
|
|
||||||
mat = AssetDatabase.LoadAssetAtPath<Material>(_matPath); // Reload
|
mat = AssetDatabase.LoadAssetAtPath<Material>(_matPath); // Reload
|
||||||
var prop = mat.shader.name == "Standard" ? "_Color" : "_BaseColor";
|
var prop = mat.shader.name == "Standard" ? "_Color" : "_BaseColor";
|
||||||
|
|
@ -109,7 +109,7 @@ namespace MCPForUnityTests.Editor.Tools
|
||||||
var result = ToJObject(ManageMaterial.HandleCommand(paramsObj));
|
var result = ToJObject(ManageMaterial.HandleCommand(paramsObj));
|
||||||
|
|
||||||
// Assert
|
// 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 mat = AssetDatabase.LoadAssetAtPath<Material>(_matPath);
|
||||||
var prop = mat.HasProperty("_BaseColor") ? "_BaseColor" : "_Color";
|
var prop = mat.HasProperty("_BaseColor") ? "_BaseColor" : "_Color";
|
||||||
|
|
@ -140,7 +140,7 @@ namespace MCPForUnityTests.Editor.Tools
|
||||||
var result = ToJObject(ManageMaterial.HandleCommand(paramsObj));
|
var result = ToJObject(ManageMaterial.HandleCommand(paramsObj));
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
Assert.AreEqual("success", result.Value<string>("status"), result.ToString());
|
Assert.IsTrue(result.Value<bool>("success"), result.ToString());
|
||||||
|
|
||||||
var renderer = go.GetComponent<Renderer>();
|
var renderer = go.GetComponent<Renderer>();
|
||||||
Assert.IsNotNull(renderer.sharedMaterial);
|
Assert.IsNotNull(renderer.sharedMaterial);
|
||||||
|
|
@ -181,7 +181,7 @@ namespace MCPForUnityTests.Editor.Tools
|
||||||
var result = ToJObject(ManageMaterial.HandleCommand(paramsObj));
|
var result = ToJObject(ManageMaterial.HandleCommand(paramsObj));
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
Assert.AreEqual("success", result.Value<string>("status"), result.ToString());
|
Assert.IsTrue(result.Value<bool>("success"), result.ToString());
|
||||||
|
|
||||||
var renderer = go.GetComponent<Renderer>();
|
var renderer = go.GetComponent<Renderer>();
|
||||||
var block = new MaterialPropertyBlock();
|
var block = new MaterialPropertyBlock();
|
||||||
|
|
@ -215,10 +215,12 @@ namespace MCPForUnityTests.Editor.Tools
|
||||||
var result = ToJObject(ManageMaterial.HandleCommand(paramsObj));
|
var result = ToJObject(ManageMaterial.HandleCommand(paramsObj));
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
Assert.AreEqual("success", result.Value<string>("status"), result.ToString());
|
Assert.IsTrue(result.Value<bool>("success"), result.ToString());
|
||||||
Assert.IsNotNull(result["properties"]);
|
var data = result["data"] as JObject;
|
||||||
Assert.IsInstanceOf<JArray>(result["properties"]);
|
Assert.IsNotNull(data, "Response should have data object");
|
||||||
var props = result["properties"] as JArray;
|
Assert.IsNotNull(data["properties"]);
|
||||||
|
Assert.IsInstanceOf<JArray>(data["properties"]);
|
||||||
|
var props = data["properties"] as JArray;
|
||||||
Assert.IsTrue(props.Count > 0);
|
Assert.IsTrue(props.Count > 0);
|
||||||
|
|
||||||
// Check for standard properties
|
// Check for standard properties
|
||||||
|
|
|
||||||
|
|
@ -144,7 +144,7 @@ namespace MCPForUnityTests.Editor.Tools
|
||||||
};
|
};
|
||||||
|
|
||||||
var assignResult = ToJObject(ManageMaterial.HandleCommand(assignParams));
|
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>();
|
var renderer = _sphere.GetComponent<MeshRenderer>();
|
||||||
Assert.IsNotNull(renderer, "Sphere should have MeshRenderer.");
|
Assert.IsNotNull(renderer, "Sphere should have MeshRenderer.");
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue