2025-12-29 12:15:50 +08:00
using System ;
using System.Collections.Generic ;
using System.Globalization ;
using System.IO ;
using System.Linq ;
using System.Reflection ;
using System.Text.RegularExpressions ;
using MCPForUnity.Editor.Helpers ;
using Newtonsoft.Json.Linq ;
using UnityEditor ;
using UnityEngine ;
namespace MCPForUnity.Editor.Tools
{
/// <summary>
/// Single tool for ScriptableObject workflows:
/// - action=create: create a ScriptableObject asset (and optionally apply patches)
/// - action=modify: apply serialized property patches to an existing asset
///
/// Patching is performed via SerializedObject/SerializedProperty paths (Unity-native), not reflection.
/// </summary>
[McpForUnityTool("manage_scriptable_object", AutoRegister = false)]
public static class ManageScriptableObject
{
private const string CodeCompilingOrReloading = "compiling_or_reloading" ;
private const string CodeInvalidParams = "invalid_params" ;
private const string CodeTypeNotFound = "type_not_found" ;
private const string CodeInvalidFolderPath = "invalid_folder_path" ;
private const string CodeTargetNotFound = "target_not_found" ;
private const string CodeAssetCreateFailed = "asset_create_failed" ;
private static readonly HashSet < string > ValidActions = new ( StringComparer . OrdinalIgnoreCase )
{
// NOTE: Action strings are normalized by NormalizeAction() (lowercased, '_'/'-' removed),
// so we only need the canonical normalized forms here.
"create" ,
"createso" ,
"modify" ,
"modifyso" ,
} ;
public static object HandleCommand ( JObject @params )
{
if ( @params = = null )
{
return new ErrorResponse ( CodeInvalidParams ) ;
}
if ( EditorApplication . isCompiling | | EditorApplication . isUpdating )
{
// Unity is transient; treat as retryable on the client side.
return new ErrorResponse ( CodeCompilingOrReloading , new { hint = "retry" } ) ;
}
// Allow JSON-string parameters for objects/arrays.
JsonUtil . CoerceJsonStringParameter ( @params , "target" ) ;
CoerceJsonStringArrayParameter ( @params , "patches" ) ;
string actionRaw = @params [ "action" ] ? . ToString ( ) ;
if ( string . IsNullOrWhiteSpace ( actionRaw ) )
{
return new ErrorResponse ( CodeInvalidParams , new { message = "'action' is required." , validActions = ValidActions . ToArray ( ) } ) ;
}
string action = NormalizeAction ( actionRaw ) ;
if ( ! ValidActions . Contains ( action ) )
{
return new ErrorResponse ( CodeInvalidParams , new { message = $"Unknown action: '{actionRaw}'." , validActions = ValidActions . ToArray ( ) } ) ;
}
if ( IsCreateAction ( action ) )
{
return HandleCreate ( @params ) ;
}
return HandleModify ( @params ) ;
}
private static object HandleCreate ( JObject @params )
{
string typeName = @params [ "typeName" ] ? . ToString ( ) ? ? @params [ "type_name" ] ? . ToString ( ) ;
string folderPath = @params [ "folderPath" ] ? . ToString ( ) ? ? @params [ "folder_path" ] ? . ToString ( ) ;
string assetName = @params [ "assetName" ] ? . ToString ( ) ? ? @params [ "asset_name" ] ? . ToString ( ) ;
bool overwrite = @params [ "overwrite" ] ? . ToObject < bool? > ( ) ? ? false ;
if ( string . IsNullOrWhiteSpace ( typeName ) )
{
return new ErrorResponse ( CodeInvalidParams , new { message = "'typeName' is required." } ) ;
}
if ( string . IsNullOrWhiteSpace ( folderPath ) )
{
return new ErrorResponse ( CodeInvalidParams , new { message = "'folderPath' is required." } ) ;
}
if ( string . IsNullOrWhiteSpace ( assetName ) )
{
return new ErrorResponse ( CodeInvalidParams , new { message = "'assetName' is required." } ) ;
}
if ( assetName . Contains ( "/" ) | | assetName . Contains ( "\\" ) )
{
return new ErrorResponse ( CodeInvalidParams , new { message = "'assetName' must not contain path separators." } ) ;
}
if ( ! TryNormalizeFolderPath ( folderPath , out var normalizedFolder , out var folderNormalizeError ) )
{
return new ErrorResponse ( CodeInvalidFolderPath , new { message = folderNormalizeError , folderPath } ) ;
}
if ( ! EnsureFolderExists ( normalizedFolder , out var folderError ) )
{
return new ErrorResponse ( CodeInvalidFolderPath , new { message = folderError , folderPath = normalizedFolder } ) ;
}
var resolvedType = ResolveType ( typeName ) ;
if ( resolvedType = = null | | ! typeof ( ScriptableObject ) . IsAssignableFrom ( resolvedType ) )
{
return new ErrorResponse ( CodeTypeNotFound , new { message = $"ScriptableObject type not found: '{typeName}'" , typeName } ) ;
}
string fileName = assetName . EndsWith ( ".asset" , StringComparison . OrdinalIgnoreCase )
? assetName
: assetName + ".asset" ;
string desiredPath = $"{normalizedFolder.TrimEnd('/')}/{fileName}" ;
string finalPath = overwrite ? desiredPath : AssetDatabase . GenerateUniqueAssetPath ( desiredPath ) ;
ScriptableObject instance ;
try
{
instance = ScriptableObject . CreateInstance ( resolvedType ) ;
if ( instance = = null )
{
return new ErrorResponse ( CodeAssetCreateFailed , new { message = "CreateInstance returned null." , typeName = resolvedType . FullName } ) ;
}
}
catch ( Exception ex )
{
return new ErrorResponse ( CodeAssetCreateFailed , new { message = ex . Message , typeName = resolvedType . FullName } ) ;
}
// GUID-preserving overwrite logic
bool isNewAsset = true ;
try
{
if ( overwrite )
{
var existingAsset = AssetDatabase . LoadAssetAtPath < ScriptableObject > ( finalPath ) ;
if ( existingAsset ! = null & & existingAsset . GetType ( ) = = resolvedType )
{
// Preserve GUID by overwriting existing asset data in-place
EditorUtility . CopySerialized ( instance , existingAsset ) ;
// Fix for "Main Object Name does not match filename" warning:
// CopySerialized overwrites the name with the (empty) name of the new instance.
// We must restore the correct name to match the filename.
existingAsset . name = Path . GetFileNameWithoutExtension ( finalPath ) ;
UnityEngine . Object . DestroyImmediate ( instance ) ; // Destroy temporary instance
instance = existingAsset ; // Proceed with patching the existing asset
isNewAsset = false ;
// Mark dirty to ensure changes are picked up
EditorUtility . SetDirty ( instance ) ;
}
else if ( existingAsset ! = null )
{
// Type mismatch or not a ScriptableObject - must delete and recreate to change type, losing GUID
// (Or we could warn, but overwrite usually implies replacing)
AssetDatabase . DeleteAsset ( finalPath ) ;
}
}
if ( isNewAsset )
{
// Ensure the new instance has the correct name before creating asset to avoid warnings
instance . name = Path . GetFileNameWithoutExtension ( finalPath ) ;
AssetDatabase . CreateAsset ( instance , finalPath ) ;
}
}
catch ( Exception ex )
{
return new ErrorResponse ( CodeAssetCreateFailed , new { message = ex . Message , path = finalPath } ) ;
}
string guid = AssetDatabase . AssetPathToGUID ( finalPath ) ;
var patchesToken = @params [ "patches" ] ;
object patchResults = null ;
var warnings = new List < string > ( ) ;
if ( patchesToken is JArray patches & & patches . Count > 0 )
{
var patchApply = ApplyPatches ( instance , patches ) ;
patchResults = patchApply . results ;
warnings . AddRange ( patchApply . warnings ) ;
}
EditorUtility . SetDirty ( instance ) ;
AssetDatabase . SaveAssets ( ) ;
return new SuccessResponse (
"ScriptableObject created." ,
new
{
guid ,
path = finalPath ,
typeNameResolved = resolvedType . FullName ,
patchResults ,
warnings = warnings . Count > 0 ? warnings : null
}
) ;
}
private static object HandleModify ( JObject @params )
{
if ( ! TryResolveTarget ( @params [ "target" ] , out var target , out var targetPath , out var targetGuid , out var err ) )
{
return err ;
}
var patchesToken = @params [ "patches" ] ;
if ( patchesToken = = null | | patchesToken . Type = = JTokenType . Null )
{
return new ErrorResponse ( CodeInvalidParams , new { message = "'patches' is required." , targetPath , targetGuid } ) ;
}
if ( patchesToken is not JArray patches )
{
return new ErrorResponse ( CodeInvalidParams , new { message = "'patches' must be an array." , targetPath , targetGuid } ) ;
}
var ( results , warnings ) = ApplyPatches ( target , patches ) ;
return new SuccessResponse (
"Serialized properties patched." ,
new
{
targetGuid ,
targetPath ,
targetTypeName = target . GetType ( ) . FullName ,
results ,
warnings = warnings . Count > 0 ? warnings : null
}
) ;
}
private static ( List < object > results , List < string > warnings ) ApplyPatches ( UnityEngine . Object target , JArray patches )
{
var warnings = new List < string > ( ) ;
var results = new List < object > ( patches . Count ) ;
bool anyChanged = false ;
var so = new SerializedObject ( target ) ;
so . Update ( ) ;
for ( int i = 0 ; i < patches . Count ; i + + )
{
if ( patches [ i ] is not JObject patchObj )
{
results . Add ( new { propertyPath = "" , op = "" , ok = false , message = $"Patch at index {i} must be an object." } ) ;
continue ;
}
string propertyPath = patchObj [ "propertyPath" ] ? . ToString ( )
? ? patchObj [ "property_path" ] ? . ToString ( )
? ? patchObj [ "path" ] ? . ToString ( ) ;
string op = ( patchObj [ "op" ] ? . ToString ( ) ? ? "set" ) . Trim ( ) ;
if ( string . IsNullOrWhiteSpace ( propertyPath ) )
{
results . Add ( new { propertyPath = propertyPath ? ? "" , op , ok = false , message = "Missing required field: propertyPath" } ) ;
continue ;
}
if ( string . IsNullOrWhiteSpace ( op ) )
{
op = "set" ;
}
var patchResult = ApplyPatch ( so , propertyPath , op , patchObj , out bool changed ) ;
anyChanged | = changed ;
results . Add ( patchResult ) ;
// Array resize should be applied immediately so later paths resolve.
if ( string . Equals ( op , "array_resize" , StringComparison . OrdinalIgnoreCase ) & & changed )
{
so . ApplyModifiedProperties ( ) ;
so . Update ( ) ;
}
}
if ( anyChanged )
{
so . ApplyModifiedProperties ( ) ;
EditorUtility . SetDirty ( target ) ;
AssetDatabase . SaveAssets ( ) ;
}
return ( results , warnings ) ;
}
private static object ApplyPatch ( SerializedObject so , string propertyPath , string op , JObject patchObj , out bool changed )
{
changed = false ;
try
{
string normalizedOp = op . Trim ( ) . ToLowerInvariant ( ) ;
switch ( normalizedOp )
{
case "array_resize" :
return ApplyArrayResize ( so , propertyPath , patchObj , out changed ) ;
case "set" :
default :
return ApplySet ( so , propertyPath , patchObj , out changed ) ;
}
}
catch ( Exception ex )
{
return new { propertyPath , op , ok = false , message = ex . Message } ;
}
}
private static object ApplyArrayResize ( SerializedObject so , string propertyPath , JObject patchObj , out bool changed )
{
changed = false ;
if ( ! TryGetInt ( patchObj [ "value" ] , out int newSize ) )
{
return new { propertyPath , op = "array_resize" , ok = false , message = "array_resize requires integer 'value'." } ;
}
newSize = Math . Max ( 0 , newSize ) ;
// Unity supports resizing either:
// - the array/list property itself (prop.isArray -> prop.arraySize)
// - the synthetic leaf property "<array>.Array.size" (prop.intValue)
/ /
// Different Unity versions/serialization edge cases can fail to resolve the synthetic leaf via FindProperty
// (or can return different property types), so we keep a "best-effort" fallback:
// - Prefer acting on the requested path if it resolves.
// - If the requested path doesn't resolve, try to resolve the *array property* and set arraySize directly.
SerializedProperty prop = so . FindProperty ( propertyPath ) ;
SerializedProperty arrayProp = null ;
if ( propertyPath . EndsWith ( ".Array.size" , StringComparison . Ordinal ) )
{
// Caller explicitly targeted the synthetic leaf. Resolve the parent array property as a fallback
// (Unity sometimes fails to resolve the synthetic leaf in certain serialization contexts).
var arrayPath = propertyPath . Substring ( 0 , propertyPath . Length - ".Array.size" . Length ) ;
arrayProp = so . FindProperty ( arrayPath ) ;
}
else
{
// Caller targeted either the array property itself (e.g., "items") or some other property.
// If it's already an array, we can resize it directly. Otherwise, we attempt to resolve
// a synthetic ".Array.size" leaf as a convenience, which some clients may pass.
arrayProp = prop ! = null & & prop . isArray ? prop : so . FindProperty ( propertyPath + ".Array.size" ) ;
}
if ( prop = = null )
{
// If we failed to find the direct property but we *can* find the array property, use that.
if ( arrayProp ! = null & & arrayProp . isArray )
{
if ( arrayProp . arraySize ! = newSize )
{
arrayProp . arraySize = newSize ;
changed = true ;
}
return new
{
propertyPath ,
op = "array_resize" ,
ok = true ,
resolvedPropertyType = "Array" ,
message = $"Set array size to {newSize}."
} ;
}
return new { propertyPath , op = "array_resize" , ok = false , message = $"Property not found: {propertyPath}" } ;
}
// Unity may represent ".Array.size" as either Integer or ArraySize depending on version.
if ( ( prop . propertyType = = SerializedPropertyType . Integer | | prop . propertyType = = SerializedPropertyType . ArraySize )
& & propertyPath . EndsWith ( ".Array.size" , StringComparison . Ordinal ) )
{
// We successfully resolved the synthetic leaf; write the size through its intValue.
if ( prop . intValue ! = newSize )
{
prop . intValue = newSize ;
changed = true ;
}
return new { propertyPath , op = "array_resize" , ok = true , resolvedPropertyType = prop . propertyType . ToString ( ) , message = $"Set array size to {newSize}." } ;
}
if ( prop . isArray )
{
// We resolved the array property itself; write through arraySize.
if ( prop . arraySize ! = newSize )
{
prop . arraySize = newSize ;
changed = true ;
}
return new { propertyPath , op = "array_resize" , ok = true , resolvedPropertyType = "Array" , message = $"Set array size to {newSize}." } ;
}
return new { propertyPath , op = "array_resize" , ok = false , resolvedPropertyType = prop . propertyType . ToString ( ) , message = $"Property is not an array or array-size field: {propertyPath}" } ;
}
private static object ApplySet ( SerializedObject so , string propertyPath , JObject patchObj , out bool changed )
{
changed = false ;
var prop = so . FindProperty ( propertyPath ) ;
if ( prop = = null )
{
return new { propertyPath , op = "set" , ok = false , message = $"Property not found: {propertyPath}" } ;
}
if ( prop . propertyType = = SerializedPropertyType . ObjectReference )
{
var refObj = patchObj [ "ref" ] as JObject ;
UnityEngine . Object newRef = null ;
string refGuid = refObj ? [ "guid" ] ? . ToString ( ) ;
string refPath = refObj ? [ "path" ] ? . ToString ( ) ;
if ( refObj = = null & & patchObj [ "value" ] ? . Type = = JTokenType . Null )
{
newRef = null ;
}
else if ( ! string . IsNullOrEmpty ( refGuid ) | | ! string . IsNullOrEmpty ( refPath ) )
{
string resolvedPath = ! string . IsNullOrEmpty ( refGuid )
? AssetDatabase . GUIDToAssetPath ( refGuid )
: AssetPathUtility . SanitizeAssetPath ( refPath ) ;
if ( ! string . IsNullOrEmpty ( resolvedPath ) )
{
newRef = AssetDatabase . LoadAssetAtPath < UnityEngine . Object > ( resolvedPath ) ;
}
}
if ( prop . objectReferenceValue ! = newRef )
{
prop . objectReferenceValue = newRef ;
changed = true ;
}
return new { propertyPath , op = "set" , ok = true , resolvedPropertyType = prop . propertyType . ToString ( ) , message = newRef = = null ? "Cleared reference." : "Set reference." } ;
}
var valueToken = patchObj [ "value" ] ;
if ( valueToken = = null )
{
return new { propertyPath , op = "set" , ok = false , resolvedPropertyType = prop . propertyType . ToString ( ) , message = "Missing required field: value" } ;
}
bool ok = TrySetValue ( prop , valueToken , out string message ) ;
changed = ok ;
return new { propertyPath , op = "set" , ok , resolvedPropertyType = prop . propertyType . ToString ( ) , message } ;
}
private static bool TrySetValue ( SerializedProperty prop , JToken valueToken , out string message )
{
message = null ;
try
{
// Supported Types: Integer, Boolean, Float, String, Enum, Vector2, Vector3, Vector4, Color
switch ( prop . propertyType )
{
case SerializedPropertyType . Integer :
if ( ! TryGetInt ( valueToken , out var intVal ) ) { message = "Expected integer value." ; return false ; }
prop . intValue = intVal ; message = "Set int." ; return true ;
case SerializedPropertyType . Boolean :
if ( ! TryGetBool ( valueToken , out var boolVal ) ) { message = "Expected boolean value." ; return false ; }
prop . boolValue = boolVal ; message = "Set bool." ; return true ;
case SerializedPropertyType . Float :
if ( ! TryGetFloat ( valueToken , out var floatVal ) ) { message = "Expected float value." ; return false ; }
prop . floatValue = floatVal ; message = "Set float." ; return true ;
case SerializedPropertyType . String :
prop . stringValue = valueToken . Type = = JTokenType . Null ? null : valueToken . ToString ( ) ;
message = "Set string." ; return true ;
case SerializedPropertyType . Enum :
return TrySetEnum ( prop , valueToken , out message ) ;
case SerializedPropertyType . Vector2 :
if ( ! TryGetVector2 ( valueToken , out var v2 ) ) { message = "Expected Vector2 (array or object)." ; return false ; }
prop . vector2Value = v2 ; message = "Set Vector2." ; return true ;
case SerializedPropertyType . Vector3 :
if ( ! TryGetVector3 ( valueToken , out var v3 ) ) { message = "Expected Vector3 (array or object)." ; return false ; }
prop . vector3Value = v3 ; message = "Set Vector3." ; return true ;
case SerializedPropertyType . Vector4 :
if ( ! TryGetVector4 ( valueToken , out var v4 ) ) { message = "Expected Vector4 (array or object)." ; return false ; }
prop . vector4Value = v4 ; message = "Set Vector4." ; return true ;
case SerializedPropertyType . Color :
if ( ! TryGetColor ( valueToken , out var col ) ) { message = "Expected Color (array or object)." ; return false ; }
prop . colorValue = col ; message = "Set Color." ; return true ;
default :
message = $"Unsupported SerializedPropertyType: {prop.propertyType}" ;
return false ;
}
}
catch ( Exception ex )
{
message = ex . Message ;
return false ;
}
}
private static bool TrySetEnum ( SerializedProperty prop , JToken valueToken , out string message )
{
message = null ;
var names = prop . enumNames ;
if ( names = = null | | names . Length = = 0 ) { message = "Enum has no names." ; return false ; }
if ( valueToken . Type = = JTokenType . Integer )
{
int idx = valueToken . Value < int > ( ) ;
if ( idx < 0 | | idx > = names . Length ) { message = $"Enum index out of range: {idx}" ; return false ; }
prop . enumValueIndex = idx ; message = "Set enum." ; return true ;
}
string s = valueToken . ToString ( ) ;
for ( int i = 0 ; i < names . Length ; i + + )
{
if ( string . Equals ( names [ i ] , s , StringComparison . OrdinalIgnoreCase ) )
{
prop . enumValueIndex = i ; message = "Set enum." ; return true ;
}
}
message = $"Unknown enum name '{s}'." ;
return false ;
}
private static bool TryResolveTarget ( JToken targetToken , out UnityEngine . Object target , out string targetPath , out string targetGuid , out object error )
{
target = null ;
targetPath = null ;
targetGuid = null ;
error = null ;
if ( targetToken is not JObject targetObj )
{
error = new ErrorResponse ( CodeInvalidParams , new { message = "'target' must be an object with {guid|path}." } ) ;
return false ;
}
string guid = targetObj [ "guid" ] ? . ToString ( ) ;
string path = targetObj [ "path" ] ? . ToString ( ) ;
if ( string . IsNullOrWhiteSpace ( guid ) & & string . IsNullOrWhiteSpace ( path ) )
{
error = new ErrorResponse ( CodeInvalidParams , new { message = "'target' must include 'guid' or 'path'." } ) ;
return false ;
}
string resolvedPath = ! string . IsNullOrWhiteSpace ( guid )
? AssetDatabase . GUIDToAssetPath ( guid )
: AssetPathUtility . SanitizeAssetPath ( path ) ;
if ( string . IsNullOrWhiteSpace ( resolvedPath ) )
{
error = new ErrorResponse ( CodeTargetNotFound , new { message = "Could not resolve target path." , guid , path } ) ;
return false ;
}
var obj = AssetDatabase . LoadAssetAtPath < UnityEngine . Object > ( resolvedPath ) ;
if ( obj = = null )
{
error = new ErrorResponse ( CodeTargetNotFound , new { message = "Target asset not found." , targetPath = resolvedPath , targetGuid = guid } ) ;
return false ;
}
target = obj ;
targetPath = resolvedPath ;
targetGuid = string . IsNullOrWhiteSpace ( guid ) ? AssetDatabase . AssetPathToGUID ( resolvedPath ) : guid ;
return true ;
}
private static void CoerceJsonStringArrayParameter ( JObject @params , string paramName )
{
var token = @params ? [ paramName ] ;
if ( token ! = null & & token . Type = = JTokenType . String )
{
try
{
var parsed = JToken . Parse ( token . ToString ( ) ) ;
if ( parsed is JArray arr )
{
@params [ paramName ] = arr ;
}
}
catch ( Exception e )
{
2026-01-07 13:33:20 +08:00
McpLog . Warn ( $"[MCP] Could not parse '{paramName}' JSON string: {e.Message}" ) ;
2025-12-29 12:15:50 +08:00
}
}
}
private static bool EnsureFolderExists ( string folderPath , out string error )
{
error = null ;
if ( string . IsNullOrWhiteSpace ( folderPath ) )
{
error = "Folder path is empty." ;
return false ;
}
// Expect normalized input here (Assets/... or Assets).
string sanitized = SanitizeSlashes ( folderPath ) ;
if ( ! sanitized . StartsWith ( "Assets/" , StringComparison . OrdinalIgnoreCase )
& & ! string . Equals ( sanitized , "Assets" , StringComparison . OrdinalIgnoreCase ) )
{
error = "Folder path must be under Assets/." ;
return false ;
}
if ( string . Equals ( sanitized , "Assets" , StringComparison . OrdinalIgnoreCase ) )
{
return true ;
}
sanitized = sanitized . TrimEnd ( '/' ) ;
if ( AssetDatabase . IsValidFolder ( sanitized ) )
{
return true ;
}
// Create recursively from Assets/
var parts = sanitized . Split ( new [ ] { '/' } , StringSplitOptions . RemoveEmptyEntries ) ;
if ( parts . Length = = 0 | | ! string . Equals ( parts [ 0 ] , "Assets" , StringComparison . OrdinalIgnoreCase ) )
{
error = "Folder path must start with Assets/" ;
return false ;
}
string current = "Assets" ;
for ( int i = 1 ; i < parts . Length ; i + + )
{
string next = current + "/" + parts [ i ] ;
if ( ! AssetDatabase . IsValidFolder ( next ) )
{
string guid = AssetDatabase . CreateFolder ( current , parts [ i ] ) ;
if ( string . IsNullOrEmpty ( guid ) )
{
error = $"Failed to create folder: {next}" ;
return false ;
}
}
current = next ;
}
return AssetDatabase . IsValidFolder ( sanitized ) ;
}
private static string SanitizeSlashes ( string path )
{
if ( string . IsNullOrWhiteSpace ( path ) )
{
return path ;
}
var s = path . Replace ( '\\' , '/' ) ;
while ( s . IndexOf ( "//" , StringComparison . Ordinal ) > = 0 )
{
s = s . Replace ( "//" , "/" , StringComparison . Ordinal ) ;
}
return s ;
}
private static bool TryNormalizeFolderPath ( string folderPath , out string normalized , out string error )
{
normalized = null ;
error = null ;
if ( string . IsNullOrWhiteSpace ( folderPath ) )
{
error = "Folder path is empty." ;
return false ;
}
var s = SanitizeSlashes ( folderPath . Trim ( ) ) ;
// Reject obvious non-project/invalid roots. We only support Assets/ (and relative paths that will be rooted under Assets/).
if ( s . StartsWith ( "/" , StringComparison . Ordinal )
| | s . StartsWith ( "file:" , StringComparison . OrdinalIgnoreCase )
| | Regex . IsMatch ( s , @"^[a-zA-Z]:" ) )
{
error = "Folder path must be a project-relative path under Assets/." ;
return false ;
}
if ( s . StartsWith ( "Packages/" , StringComparison . OrdinalIgnoreCase )
| | s . StartsWith ( "ProjectSettings/" , StringComparison . OrdinalIgnoreCase )
| | s . StartsWith ( "Library/" , StringComparison . OrdinalIgnoreCase ) )
{
error = "Folder path must be under Assets/." ;
return false ;
}
if ( string . Equals ( s , "Assets" , StringComparison . OrdinalIgnoreCase ) )
{
normalized = "Assets" ;
return true ;
}
if ( s . StartsWith ( "Assets/" , StringComparison . OrdinalIgnoreCase ) )
{
normalized = s . TrimEnd ( '/' ) ;
return true ;
}
// Allow relative paths like "Temp/MyFolder" and root them under Assets/.
normalized = ( "Assets/" + s . TrimStart ( '/' ) ) . TrimEnd ( '/' ) ;
return true ;
}
private static bool TryGetInt ( JToken token , out int value )
{
value = default ;
if ( token = = null | | token . Type = = JTokenType . Null ) return false ;
try
{
if ( token . Type = = JTokenType . Integer ) { value = token . Value < int > ( ) ; return true ; }
if ( token . Type = = JTokenType . Float ) { value = Convert . ToInt32 ( token . Value < double > ( ) ) ; return true ; }
var s = token . ToString ( ) . Trim ( ) ;
return int . TryParse ( s , NumberStyles . Integer , CultureInfo . InvariantCulture , out value ) ;
}
catch { return false ; }
}
private static bool TryGetFloat ( JToken token , out float value )
{
value = default ;
if ( token = = null | | token . Type = = JTokenType . Null ) return false ;
try
{
if ( token . Type = = JTokenType . Float | | token . Type = = JTokenType . Integer ) { value = token . Value < float > ( ) ; return true ; }
var s = token . ToString ( ) . Trim ( ) ;
return float . TryParse ( s , NumberStyles . Float , CultureInfo . InvariantCulture , out value ) ;
}
catch { return false ; }
}
private static bool TryGetBool ( JToken token , out bool value )
{
value = default ;
if ( token = = null | | token . Type = = JTokenType . Null ) return false ;
try
{
if ( token . Type = = JTokenType . Boolean ) { value = token . Value < bool > ( ) ; return true ; }
var s = token . ToString ( ) . Trim ( ) ;
return bool . TryParse ( s , out value ) ;
}
catch { return false ; }
}
// --- Vector/Color Parsing Helpers ---
private static bool TryGetVector2 ( JToken token , out Vector2 value )
{
value = default ;
if ( token = = null | | token . Type = = JTokenType . Null ) return false ;
// Handle [x, y]
if ( token is JArray arr & & arr . Count > = 2 )
{
if ( TryGetFloat ( arr [ 0 ] , out float x ) & & TryGetFloat ( arr [ 1 ] , out float y ) )
{
value = new Vector2 ( x , y ) ;
return true ;
}
}
// Handle { "x": ..., "y": ... }
if ( token is JObject obj )
{
if ( TryGetFloat ( obj [ "x" ] , out float x ) & & TryGetFloat ( obj [ "y" ] , out float y ) )
{
value = new Vector2 ( x , y ) ;
return true ;
}
}
return false ;
}
private static bool TryGetVector3 ( JToken token , out Vector3 value )
{
value = default ;
if ( token = = null | | token . Type = = JTokenType . Null ) return false ;
// Handle [x, y, z]
if ( token is JArray arr & & arr . Count > = 3 )
{
if ( TryGetFloat ( arr [ 0 ] , out float x ) & & TryGetFloat ( arr [ 1 ] , out float y ) & & TryGetFloat ( arr [ 2 ] , out float z ) )
{
value = new Vector3 ( x , y , z ) ;
return true ;
}
}
// Handle { "x": ..., "y": ..., "z": ... }
if ( token is JObject obj )
{
if ( TryGetFloat ( obj [ "x" ] , out float x ) & & TryGetFloat ( obj [ "y" ] , out float y ) & & TryGetFloat ( obj [ "z" ] , out float z ) )
{
value = new Vector3 ( x , y , z ) ;
return true ;
}
}
return false ;
}
private static bool TryGetVector4 ( JToken token , out Vector4 value )
{
value = default ;
if ( token = = null | | token . Type = = JTokenType . Null ) return false ;
// Handle [x, y, z, w]
if ( token is JArray arr & & arr . Count > = 4 )
{
if ( TryGetFloat ( arr [ 0 ] , out float x ) & & TryGetFloat ( arr [ 1 ] , out float y )
& & TryGetFloat ( arr [ 2 ] , out float z ) & & TryGetFloat ( arr [ 3 ] , out float w ) )
{
value = new Vector4 ( x , y , z , w ) ;
return true ;
}
}
// Handle { "x": ..., "y": ..., "z": ..., "w": ... }
if ( token is JObject obj )
{
if ( TryGetFloat ( obj [ "x" ] , out float x ) & & TryGetFloat ( obj [ "y" ] , out float y )
& & TryGetFloat ( obj [ "z" ] , out float z ) & & TryGetFloat ( obj [ "w" ] , out float w ) )
{
value = new Vector4 ( x , y , z , w ) ;
return true ;
}
}
return false ;
}
private static bool TryGetColor ( JToken token , out Color value )
{
value = default ;
if ( token = = null | | token . Type = = JTokenType . Null ) return false ;
// Handle [r, g, b, a]
if ( token is JArray arr & & arr . Count > = 3 )
{
float r = 0 , g = 0 , b = 0 , a = 1 ;
bool ok = TryGetFloat ( arr [ 0 ] , out r ) & & TryGetFloat ( arr [ 1 ] , out g ) & & TryGetFloat ( arr [ 2 ] , out b ) ;
if ( arr . Count > 3 ) TryGetFloat ( arr [ 3 ] , out a ) ;
if ( ok )
{
value = new Color ( r , g , b , a ) ;
return true ;
}
}
// Handle { "r": ..., "g": ..., "b": ..., "a": ... }
if ( token is JObject obj )
{
if ( TryGetFloat ( obj [ "r" ] , out float r ) & & TryGetFloat ( obj [ "g" ] , out float g ) & & TryGetFloat ( obj [ "b" ] , out float b ) )
{
// Alpha is optional, defaults to 1.0
float a = 1.0f ;
TryGetFloat ( obj [ "a" ] , out a ) ;
value = new Color ( r , g , b , a ) ;
return true ;
}
}
return false ;
}
private static string NormalizeAction ( string raw )
{
var s = raw . Trim ( ) ;
s = s . Replace ( "-" , "" ) . Replace ( "_" , "" ) ;
return s . ToLowerInvariant ( ) ;
}
private static bool IsCreateAction ( string normalized )
{
return normalized = = "create" | | normalized = = "createso" ;
}
🔧 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
2026-01-07 04:58:17 +08:00
/// <summary>
/// Resolves a type by name. Delegates to UnityTypeResolver.ResolveAny().
/// </summary>
2025-12-29 12:15:50 +08:00
private static Type ResolveType ( string typeName )
{
🔧 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
2026-01-07 04:58:17 +08:00
return Helpers . UnityTypeResolver . ResolveAny ( typeName ) ;
2025-12-29 12:15:50 +08:00
}
}
}