🔧 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
dsarno 2026-01-06 12:58:17 -08:00 committed by GitHub
parent dbdaa546b2
commit 5511a2b8ad
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
57 changed files with 1991 additions and 1113 deletions

View File

@ -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
--- ---

View File

@ -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')):

View File

@ -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()

View File

@ -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('/');

View File

@ -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;
}
}
}
}

View File

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 13dead161bc4540eeb771961df437779
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -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>

View File

@ -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;
}
}
}

View File

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: ad678f7b0a2e6458bbdb38a15d857acf
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -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
};
}
}
}

View File

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 745564d5894d74c0ca24db39c77bab2c
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -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();
}
} }
} }

View File

@ -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;
}
}
}

View File

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 4b4187d5b338a453fbe0baceaeea6bcd
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -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()
}
});
}
}

View File

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 24d94c9c030bd4ff1ab208c748f26b01
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -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.";
}
}
}

View File

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 2cdf06f869b124741af31f27b25742db
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -945,19 +945,32 @@ 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)
{ {
results.Add(pid); string localAddr = parts[1];
if (localAddr.EndsWith(portSuffix) && int.TryParse(parts[parts.Length - 1], out int pid))
{
results.Add(pid);
}
} }
} }
} }
@ -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 { }

View File

@ -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;
// Use standard pagination response
var paginatedResult = PaginationResponse<int>.Create(allIds, pagination);
// Apply pagination return new SuccessResponse("Found GameObjects", new
var pagedIds = allIds.Skip(cursor).Take(pageSize).ToList();
// Calculate next cursor
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)
{ {

View File

@ -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>

View File

@ -73,56 +73,48 @@ 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
Component newComponent = ComponentOps.AddComponent(targetGo, type, out string error);
if (newComponent == null)
{
return new ErrorResponse(error ?? $"Failed to add component '{componentTypeName}'.");
}
// Set properties if provided
JObject properties = @params["properties"] as JObject ?? @params["componentProperties"] as JObject; JObject properties = @params["properties"] as JObject ?? @params["componentProperties"] as JObject;
if (properties != null && properties.HasValues)
try
{ {
// Undo.AddComponent creates its own undo record, no need for RecordObject // Record for undo before modifying properties
Component newComponent = Undo.AddComponent(targetGo, type); Undo.RecordObject(newComponent, "Modify Component Properties");
SetPropertiesOnComponent(newComponent, properties);
if (newComponent == null)
{
return new ErrorResponse($"Failed to add component '{componentType}' to '{targetGo.name}'.");
}
// Set properties if provided
if (properties != null && properties.HasValues)
{
SetPropertiesOnComponent(newComponent, properties);
}
EditorUtility.SetDirty(targetGo);
return new
{
success = true,
message = $"Component '{componentType}' added to '{targetGo.name}'.",
data = new
{
instanceID = targetGo.GetInstanceID(),
componentType = type.FullName,
componentInstanceID = newComponent.GetInstanceID()
}
};
} }
catch (Exception e)
EditorUtility.SetDirty(targetGo);
return new
{ {
return new ErrorResponse($"Error adding component '{componentType}': {e.Message}"); success = true,
} message = $"Component '{componentTypeName}' added to '{targetGo.name}'.",
data = new
{
instanceID = targetGo.GetInstanceID(),
componentType = type.FullName,
componentInstanceID = newComponent.GetInstanceID()
}
};
} }
private static object RemoveComponent(JObject @params, JToken targetToken, string searchMethod) private static object RemoveComponent(JObject @params, JToken targetToken, string searchMethod)
@ -133,50 +125,37 @@ 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); EditorUtility.SetDirty(targetGo);
if (component == null)
{
return new ErrorResponse($"Component '{componentType}' not found on '{targetGo.name}'.");
}
try return new
{ {
Undo.DestroyObjectImmediate(component); success = true,
EditorUtility.SetDirty(targetGo); message = $"Component '{componentTypeName}' removed from '{targetGo.name}'.",
data = new
return new
{ {
success = true, instanceID = targetGo.GetInstanceID()
message = $"Component '{componentType}' removed from '{targetGo.name}'.", }
data = new };
{
instanceID = targetGo.GetInstanceID()
}
};
}
catch (Exception e)
{
return new ErrorResponse($"Error removing component '{componentType}': {e.Message}");
}
} }
private static object SetProperty(JObject @params, JToken targetToken, string searchMethod) 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 return null; // Success
{
var convertedValue = ConvertValue(value, propInfo.PropertyType);
propInfo.SetValue(component, convertedValue);
return null; // Success
}
catch (Exception e)
{
Debug.LogWarning($"[ManageComponents] Failed to set property '{propertyName}': {e.Message}");
return $"Failed to set property '{propertyName}': {e.Message}";
}
} }
// Try field 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

View File

@ -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' (?) return new ErrorResponse(
if (action == "modify" || action == "set_component_property") $"Target '{targetPath}' is a prefab asset. " +
{ $"Use 'manage_asset' with action='modify' for prefab asset modifications, " +
Debug.Log( $"or 'manage_prefabs' with action='open_stage' to edit the prefab in isolation mode."
$"[ManageGameObject->ManageAsset] Redirecting action '{action}' for prefab '{targetPath}' to ManageAsset." );
);
// Prepare params for ManageAsset.ModifyAsset
JObject assetParams = new JObject();
assetParams["action"] = "modify"; // ManageAsset uses "modify"
assetParams["path"] = targetPath;
// Extract properties.
// For 'set_component_property', combine componentName and componentProperties.
// For 'modify', directly use componentProperties.
JObject properties = null;
if (action == "set_component_property")
{
string compName = @params["componentName"]?.ToString();
JObject compProps = @params["componentProperties"]?[compName] as JObject; // Handle potential nesting
if (string.IsNullOrEmpty(compName))
return new ErrorResponse(
"Missing 'componentName' for 'set_component_property' on prefab."
);
if (compProps == null)
return new ErrorResponse(
$"Missing or invalid 'componentProperties' for component '{compName}' for 'set_component_property' on prefab."
);
properties = new JObject();
properties[compName] = compProps;
}
else // action == "modify"
{
properties = @params["componentProperties"] as JObject;
if (properties == null)
return new ErrorResponse(
"Missing 'componentProperties' for 'modify' action on prefab."
);
}
assetParams["properties"] = properties;
// Call ManageAsset handler
return ManageAsset.HandleCommand(assetParams);
}
else if (
action == "delete"
|| action == "add_component"
|| action == "remove_component"
|| action == "get_components"
) // Added get_components here too
{
// Explicitly block other modifications on the prefab asset itself via manage_gameobject
return new ErrorResponse(
$"Action '{action}' on a prefab asset ('{targetPath}') should be performed using the 'manage_asset' command."
);
}
// Allow 'create' (instantiation) and 'find' to proceed, although finding a prefab asset by path might be less common via manage_gameobject.
// No specific handling needed here, the code below will run.
} }
// --- End Prefab Redirection Check --- // --- End Prefab Asset Check ---
try try
{ {
@ -398,43 +334,30 @@ 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))
try
{ {
newGo.tag = tagToSet; Debug.Log($"[ManageGameObject.Create] Tag '{tag}' not found. Creating it.");
} try
catch (UnityException ex)
{
if (ex.Message.Contains("is not defined"))
{ {
Debug.LogWarning( InternalEditorUtility.AddTag(tag);
$"[ManageGameObject.Create] Tag '{tagToSet}' not found. Attempting to create it."
);
try
{
InternalEditorUtility.AddTag(tagToSet);
newGo.tag = tagToSet; // Retry
Debug.Log(
$"[ManageGameObject.Create] Tag '{tagToSet}' created and assigned successfully."
);
}
catch (Exception innerEx)
{
UnityEngine.Object.DestroyImmediate(newGo); // Clean up
return new ErrorResponse(
$"Failed to create or assign tag '{tagToSet}' during creation: {innerEx.Message}."
);
}
} }
else catch (Exception ex)
{ {
UnityEngine.Object.DestroyImmediate(newGo); // Clean up UnityEngine.Object.DestroyImmediate(newGo); // Clean up
return new ErrorResponse( return new ErrorResponse($"Failed to create tag '{tag}': {ex.Message}.");
$"Failed to set tag to '{tagToSet}' during creation: {ex.Message}."
);
} }
} }
try
{
newGo.tag = tag;
}
catch (Exception ex)
{
UnityEngine.Object.DestroyImmediate(newGo); // Clean up
return new ErrorResponse($"Failed to set tag to '{tag}' during creation: {ex.Message}.");
}
} }
// Set Layer (new for create action) // Set Layer (new for create action)
@ -666,49 +589,29 @@ 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;
// 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 try
{ {
targetGo.tag = tagToSet; targetGo.tag = tagToSet;
modified = true; modified = true;
} }
catch (UnityException ex) catch (Exception ex)
{ {
// Check if the error is specifically because the tag doesn't exist return new ErrorResponse($"Failed to set tag to '{tagToSet}': {ex.Message}.");
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
targetGo.tag = tagToSet;
modified = true;
Debug.Log(
$"[ManageGameObject] Tag '{tagToSet}' created and assigned successfully."
);
}
catch (Exception innerEx)
{
// 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}.");
}
} }
} }
@ -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>();
} }
} }

View File

@ -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
{ {

View File

@ -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();
} }

View File

@ -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;
} }
} }
} }

View File

@ -547,24 +547,44 @@ 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++)
{ {
// Check if server is actually accepting connections var delay = attempt < 6 ? shortDelay : longDelay;
if (!MCPServiceLocator.Server.IsLocalHttpServerRunning())
{
await Task.Delay(delay);
continue;
}
bool started = await bridgeService.StartAsync(); // Check if server is actually accepting connections
if (started) bool serverDetected = MCPServiceLocator.Server.IsLocalHttpServerRunning();
if (serverDetected)
{ {
await VerifyBridgeConnectionAsync(); // Server detected - try to connect
UpdateConnectionStatus(); bool started = await bridgeService.StartAsync();
return; if (started)
{
await VerifyBridgeConnectionAsync();
UpdateConnectionStatus();
return;
}
}
else if (attempt >= 20)
{
// After many attempts without detection, try connecting anyway as a last resort.
// This handles cases where process detection fails but the server is actually running.
// Only try once every 3 attempts to avoid spamming connection errors (at attempts 20, 23, 26, 29).
if ((attempt - 20) % 3 != 0) continue;
bool started = await bridgeService.StartAsync();
if (started)
{
await VerifyBridgeConnectionAsync();
UpdateConnectionStatus();
return;
}
} }
if (attempt < maxAttempts - 1) if (attempt < maxAttempts - 1)

View File

@ -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()

View File

@ -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

View File

@ -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",

View File

@ -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}

View File

@ -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)

View File

@ -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}

View File

@ -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}

View File

@ -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}

View File

@ -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 {

View File

@ -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}

View File

@ -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,

View File

@ -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(

View File

@ -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__}"

View File

@ -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):

View File

@ -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

View File

@ -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")]

View File

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 93766a50487224f02b29aae42971e08b
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 0
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 20332651bb6f64cadb92cf3c6d68bed5
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -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
}
}

View File

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: a6d0177f4432b41c6bf7e0013cd5a2f2
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -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");

View File

@ -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]

View File

@ -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

View File

@ -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

View File

@ -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
} }
} }

View File

@ -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");
} }
} }
} }

View File

@ -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");
} }

View File

@ -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";

View File

@ -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

View File

@ -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.");