2025-10-04 08:23:28 +08:00
using System ;
using System.Collections.Generic ;
using System.Globalization ;
using System.IO ;
using System.Linq ;
using Newtonsoft.Json.Linq ;
using UnityEditor ;
using UnityEngine ;
using MCPForUnity.Editor.Helpers ; // For Response class
using static MCPForUnity . Editor . Tools . ManageGameObject ;
#if UNITY_6000_0_OR_NEWER
using PhysicsMaterialType = UnityEngine . PhysicsMaterial ;
using PhysicsMaterialCombine = UnityEngine . PhysicsMaterialCombine ;
# else
using PhysicsMaterialType = UnityEngine . PhysicMaterial ;
using PhysicsMaterialCombine = UnityEngine . PhysicMaterialCombine ;
# endif
namespace MCPForUnity.Editor.Tools
{
/// <summary>
/// Handles asset management operations within the Unity project.
/// </summary>
[McpForUnityTool("manage_asset")]
public static class ManageAsset
{
// --- Main Handler ---
// Define the list of valid actions
private static readonly List < string > ValidActions = new List < string >
{
"import" ,
"create" ,
"modify" ,
"delete" ,
"duplicate" ,
"move" ,
"rename" ,
"search" ,
"get_info" ,
"create_folder" ,
"get_components" ,
} ;
public static object HandleCommand ( JObject @params )
{
string action = @params [ "action" ] ? . ToString ( ) . ToLower ( ) ;
if ( string . IsNullOrEmpty ( action ) )
{
return Response . Error ( "Action parameter is required." ) ;
}
// Check if the action is valid before switching
if ( ! ValidActions . Contains ( action ) )
{
string validActionsList = string . Join ( ", " , ValidActions ) ;
return Response . Error (
$"Unknown action: '{action}'. Valid actions are: {validActionsList}"
) ;
}
// Common parameters
string path = @params [ "path" ] ? . ToString ( ) ;
2025-10-24 08:57:27 +08:00
// Coerce string JSON to JObject for 'properties' if provided as a JSON string
2025-10-24 09:25:29 +08:00
var propertiesToken = @params [ "properties" ] ;
if ( propertiesToken ! = null & & propertiesToken . Type = = JTokenType . String )
{
try
{
var parsed = JObject . Parse ( propertiesToken . ToString ( ) ) ;
@params [ "properties" ] = parsed ;
}
catch ( Exception e )
{
Debug . LogWarning ( $"[ManageAsset] Could not parse 'properties' JSON string: {e.Message}" ) ;
}
}
2025-10-24 08:57:27 +08:00
2025-10-04 08:23:28 +08:00
try
{
switch ( action )
{
case "import" :
// Note: Unity typically auto-imports. This might re-import or configure import settings.
return ReimportAsset ( path , @params [ "properties" ] as JObject ) ;
case "create" :
return CreateAsset ( @params ) ;
case "modify" :
2025-10-25 02:43:26 +08:00
var properties = @params [ "properties" ] as JObject ;
return ModifyAsset ( path , properties ) ;
2025-10-04 08:23:28 +08:00
case "delete" :
return DeleteAsset ( path ) ;
case "duplicate" :
return DuplicateAsset ( path , @params [ "destination" ] ? . ToString ( ) ) ;
case "move" : // Often same as rename if within Assets/
case "rename" :
return MoveOrRenameAsset ( path , @params [ "destination" ] ? . ToString ( ) ) ;
case "search" :
return SearchAssets ( @params ) ;
case "get_info" :
return GetAssetInfo (
path ,
@params [ "generatePreview" ] ? . ToObject < bool > ( ) ? ? false
) ;
case "create_folder" : // Added specific action for clarity
return CreateFolder ( path ) ;
case "get_components" :
return GetComponentsFromAsset ( path ) ;
default :
// This error message is less likely to be hit now, but kept here as a fallback or for potential future modifications.
string validActionsListDefault = string . Join ( ", " , ValidActions ) ;
return Response . Error (
$"Unknown action: '{action}'. Valid actions are: {validActionsListDefault}"
) ;
}
}
catch ( Exception e )
{
Debug . LogError ( $"[ManageAsset] Action '{action}' failed for path '{path}': {e}" ) ;
return Response . Error (
$"Internal error processing action '{action}' on '{path}': {e.Message}"
) ;
}
}
// --- Action Implementations ---
private static object ReimportAsset ( string path , JObject properties )
{
if ( string . IsNullOrEmpty ( path ) )
return Response . Error ( "'path' is required for reimport." ) ;
string fullPath = AssetPathUtility . SanitizeAssetPath ( path ) ;
if ( ! AssetExists ( fullPath ) )
return Response . Error ( $"Asset not found at path: {fullPath}" ) ;
try
{
// TODO: Apply importer properties before reimporting?
// This is complex as it requires getting the AssetImporter, casting it,
// applying properties via reflection or specific methods, saving, then reimporting.
if ( properties ! = null & & properties . HasValues )
{
Debug . LogWarning (
"[ManageAsset.Reimport] Modifying importer properties before reimport is not fully implemented yet."
) ;
// AssetImporter importer = AssetImporter.GetAtPath(fullPath);
// if (importer != null) { /* Apply properties */ AssetDatabase.WriteImportSettingsIfDirty(fullPath); }
}
AssetDatabase . ImportAsset ( fullPath , ImportAssetOptions . ForceUpdate ) ;
// AssetDatabase.Refresh(); // Usually ImportAsset handles refresh
return Response . Success ( $"Asset '{fullPath}' reimported." , GetAssetData ( fullPath ) ) ;
}
catch ( Exception e )
{
return Response . Error ( $"Failed to reimport asset '{fullPath}': {e.Message}" ) ;
}
}
private static object CreateAsset ( JObject @params )
{
string path = @params [ "path" ] ? . ToString ( ) ;
string assetType = @params [ "assetType" ] ? . ToString ( ) ;
JObject properties = @params [ "properties" ] as JObject ;
if ( string . IsNullOrEmpty ( path ) )
return Response . Error ( "'path' is required for create." ) ;
if ( string . IsNullOrEmpty ( assetType ) )
return Response . Error ( "'assetType' is required for create." ) ;
string fullPath = AssetPathUtility . SanitizeAssetPath ( path ) ;
string directory = Path . GetDirectoryName ( fullPath ) ;
// Ensure directory exists
if ( ! Directory . Exists ( Path . Combine ( Directory . GetCurrentDirectory ( ) , directory ) ) )
{
Directory . CreateDirectory ( Path . Combine ( Directory . GetCurrentDirectory ( ) , directory ) ) ;
AssetDatabase . Refresh ( ) ; // Make sure Unity knows about the new folder
}
if ( AssetExists ( fullPath ) )
return Response . Error ( $"Asset already exists at path: {fullPath}" ) ;
try
{
UnityEngine . Object newAsset = null ;
string lowerAssetType = assetType . ToLowerInvariant ( ) ;
// Handle common asset types
if ( lowerAssetType = = "folder" )
{
return CreateFolder ( path ) ; // Use dedicated method
}
else if ( lowerAssetType = = "material" )
{
// Prefer provided shader; fall back to common pipelines
var requested = properties ? [ "shader" ] ? . ToString ( ) ;
Shader shader =
( ! string . IsNullOrEmpty ( requested ) ? Shader . Find ( requested ) : null )
? ? Shader . Find ( "Universal Render Pipeline/Lit" )
? ? Shader . Find ( "HDRP/Lit" )
? ? Shader . Find ( "Standard" )
? ? Shader . Find ( "Unlit/Color" ) ;
if ( shader = = null )
return Response . Error ( $"Could not find a suitable shader (requested: '{requested ?? " none "}')." ) ;
var mat = new Material ( shader ) ;
if ( properties ! = null )
ApplyMaterialProperties ( mat , properties ) ;
AssetDatabase . CreateAsset ( mat , fullPath ) ;
newAsset = mat ;
}
else if ( lowerAssetType = = "physicsmaterial" )
{
PhysicsMaterialType pmat = new PhysicsMaterialType ( ) ;
if ( properties ! = null )
ApplyPhysicsMaterialProperties ( pmat , properties ) ;
AssetDatabase . CreateAsset ( pmat , fullPath ) ;
newAsset = pmat ;
}
else if ( lowerAssetType = = "scriptableobject" )
{
string scriptClassName = properties ? [ "scriptClass" ] ? . ToString ( ) ;
if ( string . IsNullOrEmpty ( scriptClassName ) )
return Response . Error (
"'scriptClass' property required when creating ScriptableObject asset."
) ;
Type scriptType = ComponentResolver . TryResolve ( scriptClassName , out var resolvedType , out var error ) ? resolvedType : null ;
if (
scriptType = = null
| | ! typeof ( ScriptableObject ) . IsAssignableFrom ( scriptType )
)
{
var reason = scriptType = = null
? ( string . IsNullOrEmpty ( error ) ? "Type not found." : error )
: "Type found but does not inherit from ScriptableObject." ;
return Response . Error ( $"Script class '{scriptClassName}' invalid: {reason}" ) ;
}
ScriptableObject so = ScriptableObject . CreateInstance ( scriptType ) ;
// TODO: Apply properties from JObject to the ScriptableObject instance?
AssetDatabase . CreateAsset ( so , fullPath ) ;
newAsset = so ;
}
else if ( lowerAssetType = = "prefab" )
{
// Creating prefabs usually involves saving an existing GameObject hierarchy.
// A common pattern is to create an empty GameObject, configure it, and then save it.
return Response . Error (
"Creating prefabs programmatically usually requires a source GameObject. Use manage_gameobject to create/configure, then save as prefab via a separate mechanism or future enhancement."
) ;
// Example (conceptual):
// GameObject source = GameObject.Find(properties["sourceGameObject"].ToString());
// if(source != null) PrefabUtility.SaveAsPrefabAsset(source, fullPath);
}
// TODO: Add more asset types (Animation Controller, Scene, etc.)
else
{
// Generic creation attempt (might fail or create empty files)
// For some types, just creating the file might be enough if Unity imports it.
// File.Create(Path.Combine(Directory.GetCurrentDirectory(), fullPath)).Close();
// AssetDatabase.ImportAsset(fullPath); // Let Unity try to import it
// newAsset = AssetDatabase.LoadAssetAtPath<UnityEngine.Object>(fullPath);
return Response . Error (
$"Creation for asset type '{assetType}' is not explicitly supported yet. Supported: Folder, Material, ScriptableObject."
) ;
}
if (
newAsset = = null
& & ! Directory . Exists ( Path . Combine ( Directory . GetCurrentDirectory ( ) , fullPath ) )
) // Check if it wasn't a folder and asset wasn't created
{
return Response . Error (
$"Failed to create asset '{assetType}' at '{fullPath}'. See logs for details."
) ;
}
AssetDatabase . SaveAssets ( ) ;
// AssetDatabase.Refresh(); // CreateAsset often handles refresh
return Response . Success (
$"Asset '{fullPath}' created successfully." ,
GetAssetData ( fullPath )
) ;
}
catch ( Exception e )
{
return Response . Error ( $"Failed to create asset at '{fullPath}': {e.Message}" ) ;
}
}
private static object CreateFolder ( string path )
{
if ( string . IsNullOrEmpty ( path ) )
return Response . Error ( "'path' is required for create_folder." ) ;
string fullPath = AssetPathUtility . SanitizeAssetPath ( path ) ;
string parentDir = Path . GetDirectoryName ( fullPath ) ;
string folderName = Path . GetFileName ( fullPath ) ;
if ( AssetExists ( fullPath ) )
{
// Check if it's actually a folder already
if ( AssetDatabase . IsValidFolder ( fullPath ) )
{
return Response . Success (
$"Folder already exists at path: {fullPath}" ,
GetAssetData ( fullPath )
) ;
}
else
{
return Response . Error (
$"An asset (not a folder) already exists at path: {fullPath}"
) ;
}
}
try
{
// Ensure parent exists
if ( ! string . IsNullOrEmpty ( parentDir ) & & ! AssetDatabase . IsValidFolder ( parentDir ) )
{
// Recursively create parent folders if needed (AssetDatabase handles this internally)
// Or we can do it manually: Directory.CreateDirectory(Path.Combine(Directory.GetCurrentDirectory(), parentDir)); AssetDatabase.Refresh();
}
string guid = AssetDatabase . CreateFolder ( parentDir , folderName ) ;
if ( string . IsNullOrEmpty ( guid ) )
{
return Response . Error (
$"Failed to create folder '{fullPath}'. Check logs and permissions."
) ;
}
// AssetDatabase.Refresh(); // CreateFolder usually handles refresh
return Response . Success (
$"Folder '{fullPath}' created successfully." ,
GetAssetData ( fullPath )
) ;
}
catch ( Exception e )
{
return Response . Error ( $"Failed to create folder '{fullPath}': {e.Message}" ) ;
}
}
private static object ModifyAsset ( string path , JObject properties )
{
if ( string . IsNullOrEmpty ( path ) )
return Response . Error ( "'path' is required for modify." ) ;
if ( properties = = null | | ! properties . HasValues )
return Response . Error ( "'properties' are required for modify." ) ;
string fullPath = AssetPathUtility . SanitizeAssetPath ( path ) ;
if ( ! AssetExists ( fullPath ) )
return Response . Error ( $"Asset not found at path: {fullPath}" ) ;
try
{
UnityEngine . Object asset = AssetDatabase . LoadAssetAtPath < UnityEngine . Object > (
fullPath
) ;
if ( asset = = null )
return Response . Error ( $"Failed to load asset at path: {fullPath}" ) ;
bool modified = false ; // Flag to track if any changes were made
// --- NEW: Handle GameObject / Prefab Component Modification ---
if ( asset is GameObject gameObject )
{
// Iterate through the properties JSON: keys are component names, values are properties objects for that component
foreach ( var prop in properties . Properties ( ) )
{
string componentName = prop . Name ; // e.g., "Collectible"
// Check if the value associated with the component name is actually an object containing properties
if (
prop . Value is JObject componentProperties
& & componentProperties . HasValues
) // e.g., {"bobSpeed": 2.0}
{
// Resolve component type via ComponentResolver, then fetch by Type
Component targetComponent = null ;
bool resolved = ComponentResolver . TryResolve ( componentName , out var compType , out var compError ) ;
if ( resolved )
{
targetComponent = gameObject . GetComponent ( compType ) ;
}
// Only warn about resolution failure if component also not found
if ( targetComponent = = null & & ! resolved )
{
Debug . LogWarning (
$"[ManageAsset.ModifyAsset] Failed to resolve component '{componentName}' on '{gameObject.name}': {compError}"
) ;
}
if ( targetComponent ! = null )
{
// Apply the nested properties (e.g., bobSpeed) to the found component instance
// Use |= to ensure 'modified' becomes true if any component is successfully modified
modified | = ApplyObjectProperties (
targetComponent ,
componentProperties
) ;
}
else
{
// Log a warning if a specified component couldn't be found
Debug . LogWarning (
$"[ManageAsset.ModifyAsset] Component '{componentName}' not found on GameObject '{gameObject.name}' in asset '{fullPath}'. Skipping modification for this component."
) ;
}
}
else
{
// Log a warning if the structure isn't {"ComponentName": {"prop": value}}
// We could potentially try to apply this property directly to the GameObject here if needed,
// but the primary goal is component modification.
Debug . LogWarning (
$"[ManageAsset.ModifyAsset] Property '{prop.Name}' for GameObject modification should have a JSON object value containing component properties. Value was: {prop.Value.Type}. Skipping."
) ;
}
}
// Note: 'modified' is now true if ANY component property was successfully changed.
}
// --- End NEW ---
// --- Existing logic for other asset types (now as else-if) ---
// Example: Modifying a Material
else if ( asset is Material material )
{
// Apply properties directly to the material. If this modifies, it sets modified=true.
// Use |= in case the asset was already marked modified by previous logic (though unlikely here)
modified | = ApplyMaterialProperties ( material , properties ) ;
}
// Example: Modifying a ScriptableObject
else if ( asset is ScriptableObject so )
{
// Apply properties directly to the ScriptableObject.
modified | = ApplyObjectProperties ( so , properties ) ; // General helper
}
// Example: Modifying TextureImporter settings
else if ( asset is Texture )
{
AssetImporter importer = AssetImporter . GetAtPath ( fullPath ) ;
if ( importer is TextureImporter textureImporter )
{
bool importerModified = ApplyObjectProperties ( textureImporter , properties ) ;
if ( importerModified )
{
// Importer settings need saving and reimporting
AssetDatabase . WriteImportSettingsIfDirty ( fullPath ) ;
AssetDatabase . ImportAsset ( fullPath , ImportAssetOptions . ForceUpdate ) ; // Reimport to apply changes
modified = true ; // Mark overall operation as modified
}
}
else
{
Debug . LogWarning ( $"Could not get TextureImporter for {fullPath}." ) ;
}
}
// TODO: Add modification logic for other common asset types (Models, AudioClips importers, etc.)
else // Fallback for other asset types OR direct properties on non-GameObject assets
{
// This block handles non-GameObject/Material/ScriptableObject/Texture assets.
// Attempts to apply properties directly to the asset itself.
Debug . LogWarning (
$"[ManageAsset.ModifyAsset] Asset type '{asset.GetType().Name}' at '{fullPath}' is not explicitly handled for component modification. Attempting generic property setting on the asset itself."
) ;
modified | = ApplyObjectProperties ( asset , properties ) ;
}
// --- End Existing Logic ---
// Check if any modification happened (either component or direct asset modification)
if ( modified )
{
// Mark the asset as dirty (important for prefabs/SOs) so Unity knows to save it.
EditorUtility . SetDirty ( asset ) ;
// Save all modified assets to disk.
AssetDatabase . SaveAssets ( ) ;
// Refresh might be needed in some edge cases, but SaveAssets usually covers it.
// AssetDatabase.Refresh();
return Response . Success (
$"Asset '{fullPath}' modified successfully." ,
GetAssetData ( fullPath )
) ;
}
else
{
// If no changes were made (e.g., component not found, property names incorrect, value unchanged), return a success message indicating nothing changed.
return Response . Success (
$"No applicable or modifiable properties found for asset '{fullPath}'. Check component names, property names, and values." ,
GetAssetData ( fullPath )
) ;
// Previous message: return Response.Success($"No applicable properties found to modify for asset '{fullPath}'.", GetAssetData(fullPath));
}
}
catch ( Exception e )
{
// Log the detailed error internally
Debug . LogError ( $"[ManageAsset] Action 'modify' failed for path '{path}': {e}" ) ;
// Return a user-friendly error message
return Response . Error ( $"Failed to modify asset '{fullPath}': {e.Message}" ) ;
}
}
private static object DeleteAsset ( string path )
{
if ( string . IsNullOrEmpty ( path ) )
return Response . Error ( "'path' is required for delete." ) ;
string fullPath = AssetPathUtility . SanitizeAssetPath ( path ) ;
if ( ! AssetExists ( fullPath ) )
return Response . Error ( $"Asset not found at path: {fullPath}" ) ;
try
{
bool success = AssetDatabase . DeleteAsset ( fullPath ) ;
if ( success )
{
// AssetDatabase.Refresh(); // DeleteAsset usually handles refresh
return Response . Success ( $"Asset '{fullPath}' deleted successfully." ) ;
}
else
{
// This might happen if the file couldn't be deleted (e.g., locked)
return Response . Error (
$"Failed to delete asset '{fullPath}'. Check logs or if the file is locked."
) ;
}
}
catch ( Exception e )
{
return Response . Error ( $"Error deleting asset '{fullPath}': {e.Message}" ) ;
}
}
private static object DuplicateAsset ( string path , string destinationPath )
{
if ( string . IsNullOrEmpty ( path ) )
return Response . Error ( "'path' is required for duplicate." ) ;
string sourcePath = AssetPathUtility . SanitizeAssetPath ( path ) ;
if ( ! AssetExists ( sourcePath ) )
return Response . Error ( $"Source asset not found at path: {sourcePath}" ) ;
string destPath ;
if ( string . IsNullOrEmpty ( destinationPath ) )
{
// Generate a unique path if destination is not provided
destPath = AssetDatabase . GenerateUniqueAssetPath ( sourcePath ) ;
}
else
{
destPath = AssetPathUtility . SanitizeAssetPath ( destinationPath ) ;
if ( AssetExists ( destPath ) )
return Response . Error ( $"Asset already exists at destination path: {destPath}" ) ;
// Ensure destination directory exists
EnsureDirectoryExists ( Path . GetDirectoryName ( destPath ) ) ;
}
try
{
bool success = AssetDatabase . CopyAsset ( sourcePath , destPath ) ;
if ( success )
{
// AssetDatabase.Refresh();
return Response . Success (
$"Asset '{sourcePath}' duplicated to '{destPath}'." ,
GetAssetData ( destPath )
) ;
}
else
{
return Response . Error (
$"Failed to duplicate asset from '{sourcePath}' to '{destPath}'."
) ;
}
}
catch ( Exception e )
{
return Response . Error ( $"Error duplicating asset '{sourcePath}': {e.Message}" ) ;
}
}
private static object MoveOrRenameAsset ( string path , string destinationPath )
{
if ( string . IsNullOrEmpty ( path ) )
return Response . Error ( "'path' is required for move/rename." ) ;
if ( string . IsNullOrEmpty ( destinationPath ) )
return Response . Error ( "'destination' path is required for move/rename." ) ;
string sourcePath = AssetPathUtility . SanitizeAssetPath ( path ) ;
string destPath = AssetPathUtility . SanitizeAssetPath ( destinationPath ) ;
if ( ! AssetExists ( sourcePath ) )
return Response . Error ( $"Source asset not found at path: {sourcePath}" ) ;
if ( AssetExists ( destPath ) )
return Response . Error (
$"An asset already exists at the destination path: {destPath}"
) ;
// Ensure destination directory exists
EnsureDirectoryExists ( Path . GetDirectoryName ( destPath ) ) ;
try
{
// Validate will return an error string if failed, null if successful
string error = AssetDatabase . ValidateMoveAsset ( sourcePath , destPath ) ;
if ( ! string . IsNullOrEmpty ( error ) )
{
return Response . Error (
$"Failed to move/rename asset from '{sourcePath}' to '{destPath}': {error}"
) ;
}
string guid = AssetDatabase . MoveAsset ( sourcePath , destPath ) ;
if ( ! string . IsNullOrEmpty ( guid ) ) // MoveAsset returns the new GUID on success
{
// AssetDatabase.Refresh(); // MoveAsset usually handles refresh
return Response . Success (
$"Asset moved/renamed from '{sourcePath}' to '{destPath}'." ,
GetAssetData ( destPath )
) ;
}
else
{
// This case might not be reachable if ValidateMoveAsset passes, but good to have
return Response . Error (
$"MoveAsset call failed unexpectedly for '{sourcePath}' to '{destPath}'."
) ;
}
}
catch ( Exception e )
{
return Response . Error ( $"Error moving/renaming asset '{sourcePath}': {e.Message}" ) ;
}
}
private static object SearchAssets ( JObject @params )
{
string searchPattern = @params [ "searchPattern" ] ? . ToString ( ) ;
string filterType = @params [ "filterType" ] ? . ToString ( ) ;
string pathScope = @params [ "path" ] ? . ToString ( ) ; // Use path as folder scope
string filterDateAfterStr = @params [ "filterDateAfter" ] ? . ToString ( ) ;
int pageSize = @params [ "pageSize" ] ? . ToObject < int? > ( ) ? ? 50 ; // Default page size
int pageNumber = @params [ "pageNumber" ] ? . ToObject < int? > ( ) ? ? 1 ; // Default page number (1-based)
bool generatePreview = @params [ "generatePreview" ] ? . ToObject < bool > ( ) ? ? false ;
List < string > searchFilters = new List < string > ( ) ;
if ( ! string . IsNullOrEmpty ( searchPattern ) )
searchFilters . Add ( searchPattern ) ;
if ( ! string . IsNullOrEmpty ( filterType ) )
searchFilters . Add ( $"t:{filterType}" ) ;
string [ ] folderScope = null ;
if ( ! string . IsNullOrEmpty ( pathScope ) )
{
folderScope = new string [ ] { AssetPathUtility . SanitizeAssetPath ( pathScope ) } ;
if ( ! AssetDatabase . IsValidFolder ( folderScope [ 0 ] ) )
{
// Maybe the user provided a file path instead of a folder?
// We could search in the containing folder, or return an error.
Debug . LogWarning (
$"Search path '{folderScope[0]}' is not a valid folder. Searching entire project."
) ;
folderScope = null ; // Search everywhere if path isn't a folder
}
}
DateTime ? filterDateAfter = null ;
if ( ! string . IsNullOrEmpty ( filterDateAfterStr ) )
{
if (
DateTime . TryParse (
filterDateAfterStr ,
CultureInfo . InvariantCulture ,
DateTimeStyles . AssumeUniversal | DateTimeStyles . AdjustToUniversal ,
out DateTime parsedDate
)
)
{
filterDateAfter = parsedDate ;
}
else
{
Debug . LogWarning (
$"Could not parse filterDateAfter: '{filterDateAfterStr}'. Expected ISO 8601 format."
) ;
}
}
try
{
string [ ] guids = AssetDatabase . FindAssets (
string . Join ( " " , searchFilters ) ,
folderScope
) ;
List < object > results = new List < object > ( ) ;
int totalFound = 0 ;
foreach ( string guid in guids )
{
string assetPath = AssetDatabase . GUIDToAssetPath ( guid ) ;
if ( string . IsNullOrEmpty ( assetPath ) )
continue ;
// Apply date filter if present
if ( filterDateAfter . HasValue )
{
DateTime lastWriteTime = File . GetLastWriteTimeUtc (
Path . Combine ( Directory . GetCurrentDirectory ( ) , assetPath )
) ;
if ( lastWriteTime < = filterDateAfter . Value )
{
continue ; // Skip assets older than or equal to the filter date
}
}
totalFound + + ; // Count matching assets before pagination
results . Add ( GetAssetData ( assetPath , generatePreview ) ) ;
}
// Apply pagination
int startIndex = ( pageNumber - 1 ) * pageSize ;
var pagedResults = results . Skip ( startIndex ) . Take ( pageSize ) . ToList ( ) ;
return Response . Success (
$"Found {totalFound} asset(s). Returning page {pageNumber} ({pagedResults.Count} assets)." ,
new
{
totalAssets = totalFound ,
pageSize = pageSize ,
pageNumber = pageNumber ,
assets = pagedResults ,
}
) ;
}
catch ( Exception e )
{
return Response . Error ( $"Error searching assets: {e.Message}" ) ;
}
}
private static object GetAssetInfo ( string path , bool generatePreview )
{
if ( string . IsNullOrEmpty ( path ) )
return Response . Error ( "'path' is required for get_info." ) ;
string fullPath = AssetPathUtility . SanitizeAssetPath ( path ) ;
if ( ! AssetExists ( fullPath ) )
return Response . Error ( $"Asset not found at path: {fullPath}" ) ;
try
{
return Response . Success (
"Asset info retrieved." ,
GetAssetData ( fullPath , generatePreview )
) ;
}
catch ( Exception e )
{
return Response . Error ( $"Error getting info for asset '{fullPath}': {e.Message}" ) ;
}
}
/// <summary>
/// Retrieves components attached to a GameObject asset (like a Prefab).
/// </summary>
/// <param name="path">The asset path of the GameObject or Prefab.</param>
/// <returns>A response object containing a list of component type names or an error.</returns>
private static object GetComponentsFromAsset ( string path )
{
// 1. Validate input path
if ( string . IsNullOrEmpty ( path ) )
return Response . Error ( "'path' is required for get_components." ) ;
// 2. Sanitize and check existence
string fullPath = AssetPathUtility . SanitizeAssetPath ( path ) ;
if ( ! AssetExists ( fullPath ) )
return Response . Error ( $"Asset not found at path: {fullPath}" ) ;
try
{
// 3. Load the asset
UnityEngine . Object asset = AssetDatabase . LoadAssetAtPath < UnityEngine . Object > (
fullPath
) ;
if ( asset = = null )
return Response . Error ( $"Failed to load asset at path: {fullPath}" ) ;
// 4. Check if it's a GameObject (Prefabs load as GameObjects)
GameObject gameObject = asset as GameObject ;
if ( gameObject = = null )
{
// Also check if it's *directly* a Component type (less common for primary assets)
Component componentAsset = asset as Component ;
if ( componentAsset ! = null )
{
// If the asset itself *is* a component, maybe return just its info?
// This is an edge case. Let's stick to GameObjects for now.
return Response . Error (
$"Asset at '{fullPath}' is a Component ({asset.GetType().FullName}), not a GameObject. Components are typically retrieved *from* a GameObject."
) ;
}
return Response . Error (
$"Asset at '{fullPath}' is not a GameObject (Type: {asset.GetType().FullName}). Cannot get components from this asset type."
) ;
}
// 5. Get components
Component [ ] components = gameObject . GetComponents < Component > ( ) ;
// 6. Format component data
List < object > componentList = components
. Select ( comp = > new
{
typeName = comp . GetType ( ) . FullName ,
instanceID = comp . GetInstanceID ( ) ,
// TODO: Add more component-specific details here if needed in the future?
// Requires reflection or specific handling per component type.
} )
. ToList < object > ( ) ; // Explicit cast for clarity if needed
// 7. Return success response
return Response . Success (
$"Found {componentList.Count} component(s) on asset '{fullPath}'." ,
componentList
) ;
}
catch ( Exception e )
{
Debug . LogError (
$"[ManageAsset.GetComponentsFromAsset] Error getting components for '{fullPath}': {e}"
) ;
return Response . Error (
$"Error getting components for asset '{fullPath}': {e.Message}"
) ;
}
}
// --- Internal Helpers ---
/// <summary>
/// Ensures the asset path starts with "Assets/".
/// </summary>
/// <summary>
/// Checks if an asset exists at the given path (file or folder).
/// </summary>
private static bool AssetExists ( string sanitizedPath )
{
// AssetDatabase APIs are generally preferred over raw File/Directory checks for assets.
// Check if it's a known asset GUID.
if ( ! string . IsNullOrEmpty ( AssetDatabase . AssetPathToGUID ( sanitizedPath ) ) )
{
return true ;
}
// AssetPathToGUID might not work for newly created folders not yet refreshed.
// Check directory explicitly for folders.
if ( Directory . Exists ( Path . Combine ( Directory . GetCurrentDirectory ( ) , sanitizedPath ) ) )
{
// Check if it's considered a *valid* folder by Unity
return AssetDatabase . IsValidFolder ( sanitizedPath ) ;
}
// Check file existence for non-folder assets.
if ( File . Exists ( Path . Combine ( Directory . GetCurrentDirectory ( ) , sanitizedPath ) ) )
{
return true ; // Assume if file exists, it's an asset or will be imported
}
return false ;
// Alternative: return !string.IsNullOrEmpty(AssetDatabase.AssetPathToGUID(sanitizedPath));
}
/// <summary>
/// Ensures the directory for a given asset path exists, creating it if necessary.
/// </summary>
private static void EnsureDirectoryExists ( string directoryPath )
{
if ( string . IsNullOrEmpty ( directoryPath ) )
return ;
string fullDirPath = Path . Combine ( Directory . GetCurrentDirectory ( ) , directoryPath ) ;
if ( ! Directory . Exists ( fullDirPath ) )
{
Directory . CreateDirectory ( fullDirPath ) ;
AssetDatabase . Refresh ( ) ; // Let Unity know about the new folder
}
}
/// <summary>
/// Applies properties from JObject to a Material.
/// </summary>
private static bool ApplyMaterialProperties ( Material mat , JObject properties )
{
if ( mat = = null | | properties = = null )
return false ;
bool modified = false ;
// Example: Set shader
if ( properties [ "shader" ] ? . Type = = JTokenType . String )
{
Shader newShader = Shader . Find ( properties [ "shader" ] . ToString ( ) ) ;
if ( newShader ! = null & & mat . shader ! = newShader )
{
mat . shader = newShader ;
modified = true ;
}
}
// Example: Set color property
if ( properties [ "color" ] is JObject colorProps )
{
Feature/session based instance routing (#369)
* Add support for multiple Unity instances
* fix port detection
* add missing unity_instance parameter
* add instance params for resources
* Fix CodeRabbit review feedback
- Fix partial framed response handling in port discovery
Add _recv_exact() helper to ensure complete frame reading
Prevents healthy Unity instances from being misidentified as offline
- Remove unused default_conn variables in server.py (2 files)
Fixes Ruff F841 lint error that would block CI/CD
- Preserve sync/async nature of resources in wrapper
Check if original function is coroutine before wrapping
Prevents 'dict object is not awaitable' runtime errors
- Fix reconnection to preserve instance_id
Add instance_id tracking to UnityConnection dataclass
Reconnection now targets the same Unity instance instead of any available one
Prevents operations from being applied to wrong project
- Add instance logging to manage_asset for debugging
Helps troubleshoot multi-instance scenarios
🤖 Generated with Claude Code
Co-Authored-By: Claude <noreply@anthropic.com>
* Fix CodeRabbit feedback: reconnection fallback and annotations safety
Address 3 CodeRabbit review comments:
1. Critical: Guard reconnection fallback to prevent wrong instance routing
- When instance_id is set but rediscovery fails, now raises ConnectionError
- Added 'from e' to preserve exception chain for better debugging
- Prevents silently connecting to different Unity instance
- Ensures multi-instance routing integrity
2. Minor: Guard __annotations__ access in resource registration
- Use getattr(func, '__annotations__', {}) instead of direct access
- Prevents AttributeError for functions without type hints
3. Minor: Remove unused get_type_hints import
- Clean up unused import in resources/__init__.py
All changes applied to both Server/ and MCPForUnity/ directories.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* Fix instance sorting and logging issues
- Fix sorting logic for instances without heartbeat data: use epoch timestamp instead of current time to properly deprioritize instances with None last_heartbeat
- Use logger.exception() instead of logger.error() in disconnect_all() to include stack traces for better debugging
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* update uv.lock to prepare for merging into main
* Restore Python 3.10 lockfiles and package list_unity_instances tool
* Deduplicate Unity instance discovery by port
* Scope status-file reload checks to the active instance
* refactor: implement FastMCP middleware for session-based instance routing
Replaces module-level session_state.py with UnityInstanceMiddleware class
that follows FastMCP best practices. Middleware intercepts all tool calls
via on_call_tool hook and injects active Unity instance into request state.
Key changes:
- Add UnityInstanceMiddleware class with on_call_tool hook
- Tools now use ctx.get_state("unity_instance") instead of direct session_state calls
- Remove unity_instance parameter from all tool schemas to prevent LLM hallucination
- Convert list_unity_instances tool to unity_instances resource (read-only data)
- Update error messages to reference unity://instances resource
- Add set_state/get_state methods to DummyContext test helper
- All 67 tests passing (55 passed, 5 skipped, 7 xpassed)
Architecture benefits:
- Centralized session management in middleware
- Standard FastMCP patterns (middleware + request state)
- Cleaner separation of concerns
- Prevents AI hallucination of invalid instance IDs
* fix: convert resource templates to static resources for discoverability
Convert MCP resources from URI templates with query parameters to static
resources to fix discoverability in MCP clients like Claude Code.
Changes:
- Remove {?force_refresh} from unity://instances
- Remove {?unity_instance} from mcpforunity://menu-items
- Remove {?unity_instance} from mcpforunity://tests
- Keep {mode} path parameter in mcpforunity://tests/{mode} (legitimate)
Root cause: Query parameters {?param} trigger ResourceTemplate registration,
which are listed via resources/templates/list instead of resources/list.
Claude Code's ListMcpResourcesTool only queries resources/list, making
templates undiscoverable.
Solution: Remove optional query parameters from URIs. Instance routing is
handled by middleware/context, and force_refresh was cache control that
doesn't belong in resource identity.
Impact: Resources now discoverable via standard resources/list endpoint and
work with all MCP clients including Claude Code and Cursor.
Requires FastMCP >=2.13.0 for proper RFC 6570 query parameter support.
* feat: improve material properties and sync Server resources
Material Property Improvements (ManageAsset.cs):
- Add GetMainColorPropertyName() helper that auto-detects shader color properties
- Tries _BaseColor (URP), _Color (Standard), _MainColor, _Tint, _TintColor
- Update both named and array color property handling to use auto-detection
- Add warning messages when color properties don't exist on materials
- Split HasProperty check from SetColor to enable error reporting
This fixes the issue where simple color array format [r,g,b,a] defaulted to
_Color property, causing silent failures with URP Lit shader which uses _BaseColor.
Server Resource Sync:
- Sync Server/resources with MCPForUnity/UnityMcpServer~/src/resources
- Remove query parameters from resource URIs for discoverability
- Use session-based instance routing via get_unity_instance_from_context()
* fix: repair instance routing and simplify get_unity_instance_from_context
PROBLEM:
Instance routing was failing - scripts went to wrong Unity instances.
Script1 (intended: ramble) -> went to UnityMCPTests ❌
Script2 (intended: UnityMCPTests) -> went to ramble ❌
ROOT CAUSE:
Two incompatible approaches for accessing active instance:
1. Middleware: ctx.set_state() / ctx.get_state() - used by most tools
2. Legacy: ctx.request_context.meta - used by script tools
Script tools were reading from wrong location, middleware had no effect.
FIX:
1. Updated get_unity_instance_from_context() to read from ctx.get_state()
2. Removed legacy request_context.meta code path (98 lines removed)
3. Single source of truth: middleware state only
TESTING:
- Added comprehensive test suite (21 tests) covering all scenarios
- Tests middleware state management, session isolation, race conditions
- Tests reproduce exact 4-script failure scenario
- All 88 tests pass (76 passed + 5 skipped + 7 xpassed)
- Verified fix with live 4-script test: 100% success rate
Files changed:
- Server/tools/__init__.py: Simplified from 75 lines to 15 lines
- MCPForUnity/UnityMcpServer~/src/tools/__init__.py: Same simplification
- tests/test_instance_routing_comprehensive.py: New comprehensive test suite
* refactor: standardize instance extraction and remove dead imports
- Standardize all 18 tools to use get_unity_instance_from_context() helper
instead of direct ctx.get_state() calls for consistency
- Remove dead session_state imports from with_unity_instance decorator
that would cause ModuleNotFoundError at runtime
- Update README.md with concise instance routing documentation
* fix: critical timezone and import bugs from code review
- Remove incorrect port safety check that treated reclaimed ports as errors
(GetPortWithFallback may legitimately return same port if it became available)
- Fix timezone-aware vs naive datetime mixing in unity_connection.py sorting
(use timestamp() for comparison to avoid TypeError)
- Normalize all datetime comparisons in port_discovery.py to UTC
(file_mtime and last_heartbeat now consistently timezone-aware)
- Add missing send_with_unity_instance import in Server/tools/manage_script.py
(was causing NameError at runtime on lines 108 and 488)
All 88 tests pass (76 passed + 5 skipped + 7 xpassed)
---------
Co-authored-by: Sakura <sakurachan@qq.com>
Co-authored-by: Claude <noreply@anthropic.com>
2025-11-06 01:43:36 +08:00
string propName = colorProps [ "name" ] ? . ToString ( ) ? ? GetMainColorPropertyName ( mat ) ; // Auto-detect if not specified
2025-10-04 08:23:28 +08:00
if ( colorProps [ "value" ] is JArray colArr & & colArr . Count > = 3 )
{
try
{
Color newColor = new Color (
colArr [ 0 ] . ToObject < float > ( ) ,
colArr [ 1 ] . ToObject < float > ( ) ,
colArr [ 2 ] . ToObject < float > ( ) ,
colArr . Count > 3 ? colArr [ 3 ] . ToObject < float > ( ) : 1.0f
) ;
Feature/session based instance routing (#369)
* Add support for multiple Unity instances
* fix port detection
* add missing unity_instance parameter
* add instance params for resources
* Fix CodeRabbit review feedback
- Fix partial framed response handling in port discovery
Add _recv_exact() helper to ensure complete frame reading
Prevents healthy Unity instances from being misidentified as offline
- Remove unused default_conn variables in server.py (2 files)
Fixes Ruff F841 lint error that would block CI/CD
- Preserve sync/async nature of resources in wrapper
Check if original function is coroutine before wrapping
Prevents 'dict object is not awaitable' runtime errors
- Fix reconnection to preserve instance_id
Add instance_id tracking to UnityConnection dataclass
Reconnection now targets the same Unity instance instead of any available one
Prevents operations from being applied to wrong project
- Add instance logging to manage_asset for debugging
Helps troubleshoot multi-instance scenarios
🤖 Generated with Claude Code
Co-Authored-By: Claude <noreply@anthropic.com>
* Fix CodeRabbit feedback: reconnection fallback and annotations safety
Address 3 CodeRabbit review comments:
1. Critical: Guard reconnection fallback to prevent wrong instance routing
- When instance_id is set but rediscovery fails, now raises ConnectionError
- Added 'from e' to preserve exception chain for better debugging
- Prevents silently connecting to different Unity instance
- Ensures multi-instance routing integrity
2. Minor: Guard __annotations__ access in resource registration
- Use getattr(func, '__annotations__', {}) instead of direct access
- Prevents AttributeError for functions without type hints
3. Minor: Remove unused get_type_hints import
- Clean up unused import in resources/__init__.py
All changes applied to both Server/ and MCPForUnity/ directories.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* Fix instance sorting and logging issues
- Fix sorting logic for instances without heartbeat data: use epoch timestamp instead of current time to properly deprioritize instances with None last_heartbeat
- Use logger.exception() instead of logger.error() in disconnect_all() to include stack traces for better debugging
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* update uv.lock to prepare for merging into main
* Restore Python 3.10 lockfiles and package list_unity_instances tool
* Deduplicate Unity instance discovery by port
* Scope status-file reload checks to the active instance
* refactor: implement FastMCP middleware for session-based instance routing
Replaces module-level session_state.py with UnityInstanceMiddleware class
that follows FastMCP best practices. Middleware intercepts all tool calls
via on_call_tool hook and injects active Unity instance into request state.
Key changes:
- Add UnityInstanceMiddleware class with on_call_tool hook
- Tools now use ctx.get_state("unity_instance") instead of direct session_state calls
- Remove unity_instance parameter from all tool schemas to prevent LLM hallucination
- Convert list_unity_instances tool to unity_instances resource (read-only data)
- Update error messages to reference unity://instances resource
- Add set_state/get_state methods to DummyContext test helper
- All 67 tests passing (55 passed, 5 skipped, 7 xpassed)
Architecture benefits:
- Centralized session management in middleware
- Standard FastMCP patterns (middleware + request state)
- Cleaner separation of concerns
- Prevents AI hallucination of invalid instance IDs
* fix: convert resource templates to static resources for discoverability
Convert MCP resources from URI templates with query parameters to static
resources to fix discoverability in MCP clients like Claude Code.
Changes:
- Remove {?force_refresh} from unity://instances
- Remove {?unity_instance} from mcpforunity://menu-items
- Remove {?unity_instance} from mcpforunity://tests
- Keep {mode} path parameter in mcpforunity://tests/{mode} (legitimate)
Root cause: Query parameters {?param} trigger ResourceTemplate registration,
which are listed via resources/templates/list instead of resources/list.
Claude Code's ListMcpResourcesTool only queries resources/list, making
templates undiscoverable.
Solution: Remove optional query parameters from URIs. Instance routing is
handled by middleware/context, and force_refresh was cache control that
doesn't belong in resource identity.
Impact: Resources now discoverable via standard resources/list endpoint and
work with all MCP clients including Claude Code and Cursor.
Requires FastMCP >=2.13.0 for proper RFC 6570 query parameter support.
* feat: improve material properties and sync Server resources
Material Property Improvements (ManageAsset.cs):
- Add GetMainColorPropertyName() helper that auto-detects shader color properties
- Tries _BaseColor (URP), _Color (Standard), _MainColor, _Tint, _TintColor
- Update both named and array color property handling to use auto-detection
- Add warning messages when color properties don't exist on materials
- Split HasProperty check from SetColor to enable error reporting
This fixes the issue where simple color array format [r,g,b,a] defaulted to
_Color property, causing silent failures with URP Lit shader which uses _BaseColor.
Server Resource Sync:
- Sync Server/resources with MCPForUnity/UnityMcpServer~/src/resources
- Remove query parameters from resource URIs for discoverability
- Use session-based instance routing via get_unity_instance_from_context()
* fix: repair instance routing and simplify get_unity_instance_from_context
PROBLEM:
Instance routing was failing - scripts went to wrong Unity instances.
Script1 (intended: ramble) -> went to UnityMCPTests ❌
Script2 (intended: UnityMCPTests) -> went to ramble ❌
ROOT CAUSE:
Two incompatible approaches for accessing active instance:
1. Middleware: ctx.set_state() / ctx.get_state() - used by most tools
2. Legacy: ctx.request_context.meta - used by script tools
Script tools were reading from wrong location, middleware had no effect.
FIX:
1. Updated get_unity_instance_from_context() to read from ctx.get_state()
2. Removed legacy request_context.meta code path (98 lines removed)
3. Single source of truth: middleware state only
TESTING:
- Added comprehensive test suite (21 tests) covering all scenarios
- Tests middleware state management, session isolation, race conditions
- Tests reproduce exact 4-script failure scenario
- All 88 tests pass (76 passed + 5 skipped + 7 xpassed)
- Verified fix with live 4-script test: 100% success rate
Files changed:
- Server/tools/__init__.py: Simplified from 75 lines to 15 lines
- MCPForUnity/UnityMcpServer~/src/tools/__init__.py: Same simplification
- tests/test_instance_routing_comprehensive.py: New comprehensive test suite
* refactor: standardize instance extraction and remove dead imports
- Standardize all 18 tools to use get_unity_instance_from_context() helper
instead of direct ctx.get_state() calls for consistency
- Remove dead session_state imports from with_unity_instance decorator
that would cause ModuleNotFoundError at runtime
- Update README.md with concise instance routing documentation
* fix: critical timezone and import bugs from code review
- Remove incorrect port safety check that treated reclaimed ports as errors
(GetPortWithFallback may legitimately return same port if it became available)
- Fix timezone-aware vs naive datetime mixing in unity_connection.py sorting
(use timestamp() for comparison to avoid TypeError)
- Normalize all datetime comparisons in port_discovery.py to UTC
(file_mtime and last_heartbeat now consistently timezone-aware)
- Add missing send_with_unity_instance import in Server/tools/manage_script.py
(was causing NameError at runtime on lines 108 and 488)
All 88 tests pass (76 passed + 5 skipped + 7 xpassed)
---------
Co-authored-by: Sakura <sakurachan@qq.com>
Co-authored-by: Claude <noreply@anthropic.com>
2025-11-06 01:43:36 +08:00
if ( mat . HasProperty ( propName ) )
2025-10-04 08:23:28 +08:00
{
Feature/session based instance routing (#369)
* Add support for multiple Unity instances
* fix port detection
* add missing unity_instance parameter
* add instance params for resources
* Fix CodeRabbit review feedback
- Fix partial framed response handling in port discovery
Add _recv_exact() helper to ensure complete frame reading
Prevents healthy Unity instances from being misidentified as offline
- Remove unused default_conn variables in server.py (2 files)
Fixes Ruff F841 lint error that would block CI/CD
- Preserve sync/async nature of resources in wrapper
Check if original function is coroutine before wrapping
Prevents 'dict object is not awaitable' runtime errors
- Fix reconnection to preserve instance_id
Add instance_id tracking to UnityConnection dataclass
Reconnection now targets the same Unity instance instead of any available one
Prevents operations from being applied to wrong project
- Add instance logging to manage_asset for debugging
Helps troubleshoot multi-instance scenarios
🤖 Generated with Claude Code
Co-Authored-By: Claude <noreply@anthropic.com>
* Fix CodeRabbit feedback: reconnection fallback and annotations safety
Address 3 CodeRabbit review comments:
1. Critical: Guard reconnection fallback to prevent wrong instance routing
- When instance_id is set but rediscovery fails, now raises ConnectionError
- Added 'from e' to preserve exception chain for better debugging
- Prevents silently connecting to different Unity instance
- Ensures multi-instance routing integrity
2. Minor: Guard __annotations__ access in resource registration
- Use getattr(func, '__annotations__', {}) instead of direct access
- Prevents AttributeError for functions without type hints
3. Minor: Remove unused get_type_hints import
- Clean up unused import in resources/__init__.py
All changes applied to both Server/ and MCPForUnity/ directories.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* Fix instance sorting and logging issues
- Fix sorting logic for instances without heartbeat data: use epoch timestamp instead of current time to properly deprioritize instances with None last_heartbeat
- Use logger.exception() instead of logger.error() in disconnect_all() to include stack traces for better debugging
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* update uv.lock to prepare for merging into main
* Restore Python 3.10 lockfiles and package list_unity_instances tool
* Deduplicate Unity instance discovery by port
* Scope status-file reload checks to the active instance
* refactor: implement FastMCP middleware for session-based instance routing
Replaces module-level session_state.py with UnityInstanceMiddleware class
that follows FastMCP best practices. Middleware intercepts all tool calls
via on_call_tool hook and injects active Unity instance into request state.
Key changes:
- Add UnityInstanceMiddleware class with on_call_tool hook
- Tools now use ctx.get_state("unity_instance") instead of direct session_state calls
- Remove unity_instance parameter from all tool schemas to prevent LLM hallucination
- Convert list_unity_instances tool to unity_instances resource (read-only data)
- Update error messages to reference unity://instances resource
- Add set_state/get_state methods to DummyContext test helper
- All 67 tests passing (55 passed, 5 skipped, 7 xpassed)
Architecture benefits:
- Centralized session management in middleware
- Standard FastMCP patterns (middleware + request state)
- Cleaner separation of concerns
- Prevents AI hallucination of invalid instance IDs
* fix: convert resource templates to static resources for discoverability
Convert MCP resources from URI templates with query parameters to static
resources to fix discoverability in MCP clients like Claude Code.
Changes:
- Remove {?force_refresh} from unity://instances
- Remove {?unity_instance} from mcpforunity://menu-items
- Remove {?unity_instance} from mcpforunity://tests
- Keep {mode} path parameter in mcpforunity://tests/{mode} (legitimate)
Root cause: Query parameters {?param} trigger ResourceTemplate registration,
which are listed via resources/templates/list instead of resources/list.
Claude Code's ListMcpResourcesTool only queries resources/list, making
templates undiscoverable.
Solution: Remove optional query parameters from URIs. Instance routing is
handled by middleware/context, and force_refresh was cache control that
doesn't belong in resource identity.
Impact: Resources now discoverable via standard resources/list endpoint and
work with all MCP clients including Claude Code and Cursor.
Requires FastMCP >=2.13.0 for proper RFC 6570 query parameter support.
* feat: improve material properties and sync Server resources
Material Property Improvements (ManageAsset.cs):
- Add GetMainColorPropertyName() helper that auto-detects shader color properties
- Tries _BaseColor (URP), _Color (Standard), _MainColor, _Tint, _TintColor
- Update both named and array color property handling to use auto-detection
- Add warning messages when color properties don't exist on materials
- Split HasProperty check from SetColor to enable error reporting
This fixes the issue where simple color array format [r,g,b,a] defaulted to
_Color property, causing silent failures with URP Lit shader which uses _BaseColor.
Server Resource Sync:
- Sync Server/resources with MCPForUnity/UnityMcpServer~/src/resources
- Remove query parameters from resource URIs for discoverability
- Use session-based instance routing via get_unity_instance_from_context()
* fix: repair instance routing and simplify get_unity_instance_from_context
PROBLEM:
Instance routing was failing - scripts went to wrong Unity instances.
Script1 (intended: ramble) -> went to UnityMCPTests ❌
Script2 (intended: UnityMCPTests) -> went to ramble ❌
ROOT CAUSE:
Two incompatible approaches for accessing active instance:
1. Middleware: ctx.set_state() / ctx.get_state() - used by most tools
2. Legacy: ctx.request_context.meta - used by script tools
Script tools were reading from wrong location, middleware had no effect.
FIX:
1. Updated get_unity_instance_from_context() to read from ctx.get_state()
2. Removed legacy request_context.meta code path (98 lines removed)
3. Single source of truth: middleware state only
TESTING:
- Added comprehensive test suite (21 tests) covering all scenarios
- Tests middleware state management, session isolation, race conditions
- Tests reproduce exact 4-script failure scenario
- All 88 tests pass (76 passed + 5 skipped + 7 xpassed)
- Verified fix with live 4-script test: 100% success rate
Files changed:
- Server/tools/__init__.py: Simplified from 75 lines to 15 lines
- MCPForUnity/UnityMcpServer~/src/tools/__init__.py: Same simplification
- tests/test_instance_routing_comprehensive.py: New comprehensive test suite
* refactor: standardize instance extraction and remove dead imports
- Standardize all 18 tools to use get_unity_instance_from_context() helper
instead of direct ctx.get_state() calls for consistency
- Remove dead session_state imports from with_unity_instance decorator
that would cause ModuleNotFoundError at runtime
- Update README.md with concise instance routing documentation
* fix: critical timezone and import bugs from code review
- Remove incorrect port safety check that treated reclaimed ports as errors
(GetPortWithFallback may legitimately return same port if it became available)
- Fix timezone-aware vs naive datetime mixing in unity_connection.py sorting
(use timestamp() for comparison to avoid TypeError)
- Normalize all datetime comparisons in port_discovery.py to UTC
(file_mtime and last_heartbeat now consistently timezone-aware)
- Add missing send_with_unity_instance import in Server/tools/manage_script.py
(was causing NameError at runtime on lines 108 and 488)
All 88 tests pass (76 passed + 5 skipped + 7 xpassed)
---------
Co-authored-by: Sakura <sakurachan@qq.com>
Co-authored-by: Claude <noreply@anthropic.com>
2025-11-06 01:43:36 +08:00
if ( mat . GetColor ( propName ) ! = newColor )
{
mat . SetColor ( propName , newColor ) ;
modified = true ;
}
}
else
{
Debug . LogWarning (
$"Material '{mat.name}' with shader '{mat.shader.name}' does not have color property '{propName}'. " +
$"Color not applied. Common color properties: _BaseColor (URP), _Color (Standard)"
) ;
2025-10-04 08:23:28 +08:00
}
}
catch ( Exception ex )
{
Debug . LogWarning (
$"Error parsing color property '{propName}': {ex.Message}"
) ;
}
}
}
else if ( properties [ "color" ] is JArray colorArr ) //Use color now with examples set in manage_asset.py
{
Feature/session based instance routing (#369)
* Add support for multiple Unity instances
* fix port detection
* add missing unity_instance parameter
* add instance params for resources
* Fix CodeRabbit review feedback
- Fix partial framed response handling in port discovery
Add _recv_exact() helper to ensure complete frame reading
Prevents healthy Unity instances from being misidentified as offline
- Remove unused default_conn variables in server.py (2 files)
Fixes Ruff F841 lint error that would block CI/CD
- Preserve sync/async nature of resources in wrapper
Check if original function is coroutine before wrapping
Prevents 'dict object is not awaitable' runtime errors
- Fix reconnection to preserve instance_id
Add instance_id tracking to UnityConnection dataclass
Reconnection now targets the same Unity instance instead of any available one
Prevents operations from being applied to wrong project
- Add instance logging to manage_asset for debugging
Helps troubleshoot multi-instance scenarios
🤖 Generated with Claude Code
Co-Authored-By: Claude <noreply@anthropic.com>
* Fix CodeRabbit feedback: reconnection fallback and annotations safety
Address 3 CodeRabbit review comments:
1. Critical: Guard reconnection fallback to prevent wrong instance routing
- When instance_id is set but rediscovery fails, now raises ConnectionError
- Added 'from e' to preserve exception chain for better debugging
- Prevents silently connecting to different Unity instance
- Ensures multi-instance routing integrity
2. Minor: Guard __annotations__ access in resource registration
- Use getattr(func, '__annotations__', {}) instead of direct access
- Prevents AttributeError for functions without type hints
3. Minor: Remove unused get_type_hints import
- Clean up unused import in resources/__init__.py
All changes applied to both Server/ and MCPForUnity/ directories.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* Fix instance sorting and logging issues
- Fix sorting logic for instances without heartbeat data: use epoch timestamp instead of current time to properly deprioritize instances with None last_heartbeat
- Use logger.exception() instead of logger.error() in disconnect_all() to include stack traces for better debugging
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* update uv.lock to prepare for merging into main
* Restore Python 3.10 lockfiles and package list_unity_instances tool
* Deduplicate Unity instance discovery by port
* Scope status-file reload checks to the active instance
* refactor: implement FastMCP middleware for session-based instance routing
Replaces module-level session_state.py with UnityInstanceMiddleware class
that follows FastMCP best practices. Middleware intercepts all tool calls
via on_call_tool hook and injects active Unity instance into request state.
Key changes:
- Add UnityInstanceMiddleware class with on_call_tool hook
- Tools now use ctx.get_state("unity_instance") instead of direct session_state calls
- Remove unity_instance parameter from all tool schemas to prevent LLM hallucination
- Convert list_unity_instances tool to unity_instances resource (read-only data)
- Update error messages to reference unity://instances resource
- Add set_state/get_state methods to DummyContext test helper
- All 67 tests passing (55 passed, 5 skipped, 7 xpassed)
Architecture benefits:
- Centralized session management in middleware
- Standard FastMCP patterns (middleware + request state)
- Cleaner separation of concerns
- Prevents AI hallucination of invalid instance IDs
* fix: convert resource templates to static resources for discoverability
Convert MCP resources from URI templates with query parameters to static
resources to fix discoverability in MCP clients like Claude Code.
Changes:
- Remove {?force_refresh} from unity://instances
- Remove {?unity_instance} from mcpforunity://menu-items
- Remove {?unity_instance} from mcpforunity://tests
- Keep {mode} path parameter in mcpforunity://tests/{mode} (legitimate)
Root cause: Query parameters {?param} trigger ResourceTemplate registration,
which are listed via resources/templates/list instead of resources/list.
Claude Code's ListMcpResourcesTool only queries resources/list, making
templates undiscoverable.
Solution: Remove optional query parameters from URIs. Instance routing is
handled by middleware/context, and force_refresh was cache control that
doesn't belong in resource identity.
Impact: Resources now discoverable via standard resources/list endpoint and
work with all MCP clients including Claude Code and Cursor.
Requires FastMCP >=2.13.0 for proper RFC 6570 query parameter support.
* feat: improve material properties and sync Server resources
Material Property Improvements (ManageAsset.cs):
- Add GetMainColorPropertyName() helper that auto-detects shader color properties
- Tries _BaseColor (URP), _Color (Standard), _MainColor, _Tint, _TintColor
- Update both named and array color property handling to use auto-detection
- Add warning messages when color properties don't exist on materials
- Split HasProperty check from SetColor to enable error reporting
This fixes the issue where simple color array format [r,g,b,a] defaulted to
_Color property, causing silent failures with URP Lit shader which uses _BaseColor.
Server Resource Sync:
- Sync Server/resources with MCPForUnity/UnityMcpServer~/src/resources
- Remove query parameters from resource URIs for discoverability
- Use session-based instance routing via get_unity_instance_from_context()
* fix: repair instance routing and simplify get_unity_instance_from_context
PROBLEM:
Instance routing was failing - scripts went to wrong Unity instances.
Script1 (intended: ramble) -> went to UnityMCPTests ❌
Script2 (intended: UnityMCPTests) -> went to ramble ❌
ROOT CAUSE:
Two incompatible approaches for accessing active instance:
1. Middleware: ctx.set_state() / ctx.get_state() - used by most tools
2. Legacy: ctx.request_context.meta - used by script tools
Script tools were reading from wrong location, middleware had no effect.
FIX:
1. Updated get_unity_instance_from_context() to read from ctx.get_state()
2. Removed legacy request_context.meta code path (98 lines removed)
3. Single source of truth: middleware state only
TESTING:
- Added comprehensive test suite (21 tests) covering all scenarios
- Tests middleware state management, session isolation, race conditions
- Tests reproduce exact 4-script failure scenario
- All 88 tests pass (76 passed + 5 skipped + 7 xpassed)
- Verified fix with live 4-script test: 100% success rate
Files changed:
- Server/tools/__init__.py: Simplified from 75 lines to 15 lines
- MCPForUnity/UnityMcpServer~/src/tools/__init__.py: Same simplification
- tests/test_instance_routing_comprehensive.py: New comprehensive test suite
* refactor: standardize instance extraction and remove dead imports
- Standardize all 18 tools to use get_unity_instance_from_context() helper
instead of direct ctx.get_state() calls for consistency
- Remove dead session_state imports from with_unity_instance decorator
that would cause ModuleNotFoundError at runtime
- Update README.md with concise instance routing documentation
* fix: critical timezone and import bugs from code review
- Remove incorrect port safety check that treated reclaimed ports as errors
(GetPortWithFallback may legitimately return same port if it became available)
- Fix timezone-aware vs naive datetime mixing in unity_connection.py sorting
(use timestamp() for comparison to avoid TypeError)
- Normalize all datetime comparisons in port_discovery.py to UTC
(file_mtime and last_heartbeat now consistently timezone-aware)
- Add missing send_with_unity_instance import in Server/tools/manage_script.py
(was causing NameError at runtime on lines 108 and 488)
All 88 tests pass (76 passed + 5 skipped + 7 xpassed)
---------
Co-authored-by: Sakura <sakurachan@qq.com>
Co-authored-by: Claude <noreply@anthropic.com>
2025-11-06 01:43:36 +08:00
// Auto-detect the main color property for the shader
string propName = GetMainColorPropertyName ( mat ) ;
2025-10-04 08:23:28 +08:00
try
{
if ( colorArr . Count > = 3 )
{
Color newColor = new Color (
colorArr [ 0 ] . ToObject < float > ( ) ,
colorArr [ 1 ] . ToObject < float > ( ) ,
colorArr [ 2 ] . ToObject < float > ( ) ,
colorArr . Count > 3 ? colorArr [ 3 ] . ToObject < float > ( ) : 1.0f
) ;
Feature/session based instance routing (#369)
* Add support for multiple Unity instances
* fix port detection
* add missing unity_instance parameter
* add instance params for resources
* Fix CodeRabbit review feedback
- Fix partial framed response handling in port discovery
Add _recv_exact() helper to ensure complete frame reading
Prevents healthy Unity instances from being misidentified as offline
- Remove unused default_conn variables in server.py (2 files)
Fixes Ruff F841 lint error that would block CI/CD
- Preserve sync/async nature of resources in wrapper
Check if original function is coroutine before wrapping
Prevents 'dict object is not awaitable' runtime errors
- Fix reconnection to preserve instance_id
Add instance_id tracking to UnityConnection dataclass
Reconnection now targets the same Unity instance instead of any available one
Prevents operations from being applied to wrong project
- Add instance logging to manage_asset for debugging
Helps troubleshoot multi-instance scenarios
🤖 Generated with Claude Code
Co-Authored-By: Claude <noreply@anthropic.com>
* Fix CodeRabbit feedback: reconnection fallback and annotations safety
Address 3 CodeRabbit review comments:
1. Critical: Guard reconnection fallback to prevent wrong instance routing
- When instance_id is set but rediscovery fails, now raises ConnectionError
- Added 'from e' to preserve exception chain for better debugging
- Prevents silently connecting to different Unity instance
- Ensures multi-instance routing integrity
2. Minor: Guard __annotations__ access in resource registration
- Use getattr(func, '__annotations__', {}) instead of direct access
- Prevents AttributeError for functions without type hints
3. Minor: Remove unused get_type_hints import
- Clean up unused import in resources/__init__.py
All changes applied to both Server/ and MCPForUnity/ directories.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* Fix instance sorting and logging issues
- Fix sorting logic for instances without heartbeat data: use epoch timestamp instead of current time to properly deprioritize instances with None last_heartbeat
- Use logger.exception() instead of logger.error() in disconnect_all() to include stack traces for better debugging
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* update uv.lock to prepare for merging into main
* Restore Python 3.10 lockfiles and package list_unity_instances tool
* Deduplicate Unity instance discovery by port
* Scope status-file reload checks to the active instance
* refactor: implement FastMCP middleware for session-based instance routing
Replaces module-level session_state.py with UnityInstanceMiddleware class
that follows FastMCP best practices. Middleware intercepts all tool calls
via on_call_tool hook and injects active Unity instance into request state.
Key changes:
- Add UnityInstanceMiddleware class with on_call_tool hook
- Tools now use ctx.get_state("unity_instance") instead of direct session_state calls
- Remove unity_instance parameter from all tool schemas to prevent LLM hallucination
- Convert list_unity_instances tool to unity_instances resource (read-only data)
- Update error messages to reference unity://instances resource
- Add set_state/get_state methods to DummyContext test helper
- All 67 tests passing (55 passed, 5 skipped, 7 xpassed)
Architecture benefits:
- Centralized session management in middleware
- Standard FastMCP patterns (middleware + request state)
- Cleaner separation of concerns
- Prevents AI hallucination of invalid instance IDs
* fix: convert resource templates to static resources for discoverability
Convert MCP resources from URI templates with query parameters to static
resources to fix discoverability in MCP clients like Claude Code.
Changes:
- Remove {?force_refresh} from unity://instances
- Remove {?unity_instance} from mcpforunity://menu-items
- Remove {?unity_instance} from mcpforunity://tests
- Keep {mode} path parameter in mcpforunity://tests/{mode} (legitimate)
Root cause: Query parameters {?param} trigger ResourceTemplate registration,
which are listed via resources/templates/list instead of resources/list.
Claude Code's ListMcpResourcesTool only queries resources/list, making
templates undiscoverable.
Solution: Remove optional query parameters from URIs. Instance routing is
handled by middleware/context, and force_refresh was cache control that
doesn't belong in resource identity.
Impact: Resources now discoverable via standard resources/list endpoint and
work with all MCP clients including Claude Code and Cursor.
Requires FastMCP >=2.13.0 for proper RFC 6570 query parameter support.
* feat: improve material properties and sync Server resources
Material Property Improvements (ManageAsset.cs):
- Add GetMainColorPropertyName() helper that auto-detects shader color properties
- Tries _BaseColor (URP), _Color (Standard), _MainColor, _Tint, _TintColor
- Update both named and array color property handling to use auto-detection
- Add warning messages when color properties don't exist on materials
- Split HasProperty check from SetColor to enable error reporting
This fixes the issue where simple color array format [r,g,b,a] defaulted to
_Color property, causing silent failures with URP Lit shader which uses _BaseColor.
Server Resource Sync:
- Sync Server/resources with MCPForUnity/UnityMcpServer~/src/resources
- Remove query parameters from resource URIs for discoverability
- Use session-based instance routing via get_unity_instance_from_context()
* fix: repair instance routing and simplify get_unity_instance_from_context
PROBLEM:
Instance routing was failing - scripts went to wrong Unity instances.
Script1 (intended: ramble) -> went to UnityMCPTests ❌
Script2 (intended: UnityMCPTests) -> went to ramble ❌
ROOT CAUSE:
Two incompatible approaches for accessing active instance:
1. Middleware: ctx.set_state() / ctx.get_state() - used by most tools
2. Legacy: ctx.request_context.meta - used by script tools
Script tools were reading from wrong location, middleware had no effect.
FIX:
1. Updated get_unity_instance_from_context() to read from ctx.get_state()
2. Removed legacy request_context.meta code path (98 lines removed)
3. Single source of truth: middleware state only
TESTING:
- Added comprehensive test suite (21 tests) covering all scenarios
- Tests middleware state management, session isolation, race conditions
- Tests reproduce exact 4-script failure scenario
- All 88 tests pass (76 passed + 5 skipped + 7 xpassed)
- Verified fix with live 4-script test: 100% success rate
Files changed:
- Server/tools/__init__.py: Simplified from 75 lines to 15 lines
- MCPForUnity/UnityMcpServer~/src/tools/__init__.py: Same simplification
- tests/test_instance_routing_comprehensive.py: New comprehensive test suite
* refactor: standardize instance extraction and remove dead imports
- Standardize all 18 tools to use get_unity_instance_from_context() helper
instead of direct ctx.get_state() calls for consistency
- Remove dead session_state imports from with_unity_instance decorator
that would cause ModuleNotFoundError at runtime
- Update README.md with concise instance routing documentation
* fix: critical timezone and import bugs from code review
- Remove incorrect port safety check that treated reclaimed ports as errors
(GetPortWithFallback may legitimately return same port if it became available)
- Fix timezone-aware vs naive datetime mixing in unity_connection.py sorting
(use timestamp() for comparison to avoid TypeError)
- Normalize all datetime comparisons in port_discovery.py to UTC
(file_mtime and last_heartbeat now consistently timezone-aware)
- Add missing send_with_unity_instance import in Server/tools/manage_script.py
(was causing NameError at runtime on lines 108 and 488)
All 88 tests pass (76 passed + 5 skipped + 7 xpassed)
---------
Co-authored-by: Sakura <sakurachan@qq.com>
Co-authored-by: Claude <noreply@anthropic.com>
2025-11-06 01:43:36 +08:00
if ( mat . HasProperty ( propName ) )
2025-10-04 08:23:28 +08:00
{
Feature/session based instance routing (#369)
* Add support for multiple Unity instances
* fix port detection
* add missing unity_instance parameter
* add instance params for resources
* Fix CodeRabbit review feedback
- Fix partial framed response handling in port discovery
Add _recv_exact() helper to ensure complete frame reading
Prevents healthy Unity instances from being misidentified as offline
- Remove unused default_conn variables in server.py (2 files)
Fixes Ruff F841 lint error that would block CI/CD
- Preserve sync/async nature of resources in wrapper
Check if original function is coroutine before wrapping
Prevents 'dict object is not awaitable' runtime errors
- Fix reconnection to preserve instance_id
Add instance_id tracking to UnityConnection dataclass
Reconnection now targets the same Unity instance instead of any available one
Prevents operations from being applied to wrong project
- Add instance logging to manage_asset for debugging
Helps troubleshoot multi-instance scenarios
🤖 Generated with Claude Code
Co-Authored-By: Claude <noreply@anthropic.com>
* Fix CodeRabbit feedback: reconnection fallback and annotations safety
Address 3 CodeRabbit review comments:
1. Critical: Guard reconnection fallback to prevent wrong instance routing
- When instance_id is set but rediscovery fails, now raises ConnectionError
- Added 'from e' to preserve exception chain for better debugging
- Prevents silently connecting to different Unity instance
- Ensures multi-instance routing integrity
2. Minor: Guard __annotations__ access in resource registration
- Use getattr(func, '__annotations__', {}) instead of direct access
- Prevents AttributeError for functions without type hints
3. Minor: Remove unused get_type_hints import
- Clean up unused import in resources/__init__.py
All changes applied to both Server/ and MCPForUnity/ directories.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* Fix instance sorting and logging issues
- Fix sorting logic for instances without heartbeat data: use epoch timestamp instead of current time to properly deprioritize instances with None last_heartbeat
- Use logger.exception() instead of logger.error() in disconnect_all() to include stack traces for better debugging
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* update uv.lock to prepare for merging into main
* Restore Python 3.10 lockfiles and package list_unity_instances tool
* Deduplicate Unity instance discovery by port
* Scope status-file reload checks to the active instance
* refactor: implement FastMCP middleware for session-based instance routing
Replaces module-level session_state.py with UnityInstanceMiddleware class
that follows FastMCP best practices. Middleware intercepts all tool calls
via on_call_tool hook and injects active Unity instance into request state.
Key changes:
- Add UnityInstanceMiddleware class with on_call_tool hook
- Tools now use ctx.get_state("unity_instance") instead of direct session_state calls
- Remove unity_instance parameter from all tool schemas to prevent LLM hallucination
- Convert list_unity_instances tool to unity_instances resource (read-only data)
- Update error messages to reference unity://instances resource
- Add set_state/get_state methods to DummyContext test helper
- All 67 tests passing (55 passed, 5 skipped, 7 xpassed)
Architecture benefits:
- Centralized session management in middleware
- Standard FastMCP patterns (middleware + request state)
- Cleaner separation of concerns
- Prevents AI hallucination of invalid instance IDs
* fix: convert resource templates to static resources for discoverability
Convert MCP resources from URI templates with query parameters to static
resources to fix discoverability in MCP clients like Claude Code.
Changes:
- Remove {?force_refresh} from unity://instances
- Remove {?unity_instance} from mcpforunity://menu-items
- Remove {?unity_instance} from mcpforunity://tests
- Keep {mode} path parameter in mcpforunity://tests/{mode} (legitimate)
Root cause: Query parameters {?param} trigger ResourceTemplate registration,
which are listed via resources/templates/list instead of resources/list.
Claude Code's ListMcpResourcesTool only queries resources/list, making
templates undiscoverable.
Solution: Remove optional query parameters from URIs. Instance routing is
handled by middleware/context, and force_refresh was cache control that
doesn't belong in resource identity.
Impact: Resources now discoverable via standard resources/list endpoint and
work with all MCP clients including Claude Code and Cursor.
Requires FastMCP >=2.13.0 for proper RFC 6570 query parameter support.
* feat: improve material properties and sync Server resources
Material Property Improvements (ManageAsset.cs):
- Add GetMainColorPropertyName() helper that auto-detects shader color properties
- Tries _BaseColor (URP), _Color (Standard), _MainColor, _Tint, _TintColor
- Update both named and array color property handling to use auto-detection
- Add warning messages when color properties don't exist on materials
- Split HasProperty check from SetColor to enable error reporting
This fixes the issue where simple color array format [r,g,b,a] defaulted to
_Color property, causing silent failures with URP Lit shader which uses _BaseColor.
Server Resource Sync:
- Sync Server/resources with MCPForUnity/UnityMcpServer~/src/resources
- Remove query parameters from resource URIs for discoverability
- Use session-based instance routing via get_unity_instance_from_context()
* fix: repair instance routing and simplify get_unity_instance_from_context
PROBLEM:
Instance routing was failing - scripts went to wrong Unity instances.
Script1 (intended: ramble) -> went to UnityMCPTests ❌
Script2 (intended: UnityMCPTests) -> went to ramble ❌
ROOT CAUSE:
Two incompatible approaches for accessing active instance:
1. Middleware: ctx.set_state() / ctx.get_state() - used by most tools
2. Legacy: ctx.request_context.meta - used by script tools
Script tools were reading from wrong location, middleware had no effect.
FIX:
1. Updated get_unity_instance_from_context() to read from ctx.get_state()
2. Removed legacy request_context.meta code path (98 lines removed)
3. Single source of truth: middleware state only
TESTING:
- Added comprehensive test suite (21 tests) covering all scenarios
- Tests middleware state management, session isolation, race conditions
- Tests reproduce exact 4-script failure scenario
- All 88 tests pass (76 passed + 5 skipped + 7 xpassed)
- Verified fix with live 4-script test: 100% success rate
Files changed:
- Server/tools/__init__.py: Simplified from 75 lines to 15 lines
- MCPForUnity/UnityMcpServer~/src/tools/__init__.py: Same simplification
- tests/test_instance_routing_comprehensive.py: New comprehensive test suite
* refactor: standardize instance extraction and remove dead imports
- Standardize all 18 tools to use get_unity_instance_from_context() helper
instead of direct ctx.get_state() calls for consistency
- Remove dead session_state imports from with_unity_instance decorator
that would cause ModuleNotFoundError at runtime
- Update README.md with concise instance routing documentation
* fix: critical timezone and import bugs from code review
- Remove incorrect port safety check that treated reclaimed ports as errors
(GetPortWithFallback may legitimately return same port if it became available)
- Fix timezone-aware vs naive datetime mixing in unity_connection.py sorting
(use timestamp() for comparison to avoid TypeError)
- Normalize all datetime comparisons in port_discovery.py to UTC
(file_mtime and last_heartbeat now consistently timezone-aware)
- Add missing send_with_unity_instance import in Server/tools/manage_script.py
(was causing NameError at runtime on lines 108 and 488)
All 88 tests pass (76 passed + 5 skipped + 7 xpassed)
---------
Co-authored-by: Sakura <sakurachan@qq.com>
Co-authored-by: Claude <noreply@anthropic.com>
2025-11-06 01:43:36 +08:00
if ( mat . GetColor ( propName ) ! = newColor )
{
mat . SetColor ( propName , newColor ) ;
modified = true ;
}
}
else
{
Debug . LogWarning (
$"Material '{mat.name}' with shader '{mat.shader.name}' does not have color property '{propName}'. " +
$"Color not applied. Common color properties: _BaseColor (URP), _Color (Standard)"
) ;
2025-10-04 08:23:28 +08:00
}
}
}
catch ( Exception ex )
{
Debug . LogWarning (
$"Error parsing color property '{propName}': {ex.Message}"
) ;
}
}
// Example: Set float property
if ( properties [ "float" ] is JObject floatProps )
{
string propName = floatProps [ "name" ] ? . ToString ( ) ;
if (
! string . IsNullOrEmpty ( propName ) & &
( floatProps [ "value" ] ? . Type = = JTokenType . Float | | floatProps [ "value" ] ? . Type = = JTokenType . Integer )
)
{
try
{
float newVal = floatProps [ "value" ] . ToObject < float > ( ) ;
if ( mat . HasProperty ( propName ) & & mat . GetFloat ( propName ) ! = newVal )
{
mat . SetFloat ( propName , newVal ) ;
modified = true ;
}
}
catch ( Exception ex )
{
Debug . LogWarning (
$"Error parsing float property '{propName}': {ex.Message}"
) ;
}
}
}
2025-10-25 02:43:26 +08:00
// Example: Set texture property (case-insensitive key and subkeys)
2025-10-04 08:23:28 +08:00
{
2025-10-25 02:43:26 +08:00
JObject texProps = null ;
var direct = properties . Property ( "texture" ) ;
if ( direct ! = null & & direct . Value is JObject t0 ) texProps = t0 ;
if ( texProps = = null )
2025-10-04 08:23:28 +08:00
{
2025-10-25 02:43:26 +08:00
var ci = properties . Properties ( ) . FirstOrDefault (
p = > string . Equals ( p . Name , "texture" , StringComparison . OrdinalIgnoreCase ) ) ;
if ( ci ! = null & & ci . Value is JObject t1 ) texProps = t1 ;
}
if ( texProps ! = null )
{
string rawName = ( texProps [ "name" ] ? ? texProps [ "Name" ] ) ? . ToString ( ) ;
string texPath = ( texProps [ "path" ] ? ? texProps [ "Path" ] ) ? . ToString ( ) ;
if ( ! string . IsNullOrEmpty ( texPath ) )
2025-10-04 08:23:28 +08:00
{
2025-10-25 02:43:26 +08:00
var newTex = AssetDatabase . LoadAssetAtPath < Texture > (
AssetPathUtility . SanitizeAssetPath ( texPath ) ) ;
if ( newTex = = null )
{
Debug . LogWarning ( $"Texture not found at path: {texPath}" ) ;
}
else
{
// Reuse alias resolver so friendly names like 'albedo' work here too
string candidateName = string . IsNullOrEmpty ( rawName ) ? "_BaseMap" : rawName ;
string targetProp = ResolvePropertyName ( candidateName ) ;
if ( ! string . IsNullOrEmpty ( targetProp ) & & mat . HasProperty ( targetProp ) )
{
if ( mat . GetTexture ( targetProp ) ! = newTex )
{
mat . SetTexture ( targetProp , newTex ) ;
modified = true ;
}
}
}
2025-10-04 08:23:28 +08:00
}
}
}
2025-10-24 09:25:29 +08:00
// --- Flexible direct property assignment ---
// Allow payloads like: { "_Color": [r,g,b,a] }, { "_Glossiness": 0.5 }, { "_MainTex": "Assets/.." }
// while retaining backward compatibility with the structured keys above.
// This iterates all top-level keys except the reserved structured ones and applies them
// if they match known shader properties.
var reservedKeys = new HashSet < string > ( StringComparer . OrdinalIgnoreCase ) { "shader" , "color" , "float" , "texture" } ;
// Helper resolves common URP/Standard aliasing (e.g., _Color <-> _BaseColor, _MainTex <-> _BaseMap, _Glossiness <-> _Smoothness)
string ResolvePropertyName ( string name )
{
if ( string . IsNullOrEmpty ( name ) ) return name ;
string [ ] candidates ;
2025-10-25 02:43:26 +08:00
var lower = name . ToLowerInvariant ( ) ;
switch ( lower )
2025-10-24 09:25:29 +08:00
{
2025-10-25 02:43:26 +08:00
case "_color" : candidates = new [ ] { "_Color" , "_BaseColor" } ; break ;
case "_basecolor" : candidates = new [ ] { "_BaseColor" , "_Color" } ; break ;
case "_maintex" : candidates = new [ ] { "_MainTex" , "_BaseMap" } ; break ;
case "_basemap" : candidates = new [ ] { "_BaseMap" , "_MainTex" } ; break ;
case "_glossiness" : candidates = new [ ] { "_Glossiness" , "_Smoothness" } ; break ;
case "_smoothness" : candidates = new [ ] { "_Smoothness" , "_Glossiness" } ; break ;
// Friendly names → shader property names
case "metallic" : candidates = new [ ] { "_Metallic" } ; break ;
case "smoothness" : candidates = new [ ] { "_Smoothness" , "_Glossiness" } ; break ;
case "albedo" : candidates = new [ ] { "_BaseMap" , "_MainTex" } ; break ;
default : candidates = new [ ] { name } ; break ; // keep original as-is
2025-10-24 09:25:29 +08:00
}
foreach ( var candidate in candidates )
{
if ( mat . HasProperty ( candidate ) ) return candidate ;
}
return name ; // fall back to original
}
foreach ( var prop in properties . Properties ( ) )
{
if ( reservedKeys . Contains ( prop . Name ) ) continue ;
string shaderProp = ResolvePropertyName ( prop . Name ) ;
JToken v = prop . Value ;
// Color: numeric array [r,g,b,(a)]
if ( v is JArray arr & & arr . Count > = 3 & & arr . All ( t = > t . Type = = JTokenType . Float | | t . Type = = JTokenType . Integer ) )
{
if ( mat . HasProperty ( shaderProp ) )
{
try
{
var c = new Color (
arr [ 0 ] . ToObject < float > ( ) ,
arr [ 1 ] . ToObject < float > ( ) ,
arr [ 2 ] . ToObject < float > ( ) ,
arr . Count > 3 ? arr [ 3 ] . ToObject < float > ( ) : 1f
) ;
if ( mat . GetColor ( shaderProp ) ! = c )
{
mat . SetColor ( shaderProp , c ) ;
modified = true ;
}
}
catch ( Exception ex )
{
Debug . LogWarning ( $"Error setting color '{shaderProp}': {ex.Message}" ) ;
}
}
continue ;
}
// Float: single number
if ( v . Type = = JTokenType . Float | | v . Type = = JTokenType . Integer )
{
if ( mat . HasProperty ( shaderProp ) )
{
try
{
float f = v . ToObject < float > ( ) ;
if ( ! Mathf . Approximately ( mat . GetFloat ( shaderProp ) , f ) )
{
mat . SetFloat ( shaderProp , f ) ;
modified = true ;
}
}
catch ( Exception ex )
{
Debug . LogWarning ( $"Error setting float '{shaderProp}': {ex.Message}" ) ;
}
}
continue ;
}
// Texture: string path
if ( v . Type = = JTokenType . String )
{
string texPath = v . ToString ( ) ;
if ( ! string . IsNullOrEmpty ( texPath ) & & mat . HasProperty ( shaderProp ) )
{
var tex = AssetDatabase . LoadAssetAtPath < Texture > ( AssetPathUtility . SanitizeAssetPath ( texPath ) ) ;
if ( tex ! = null & & mat . GetTexture ( shaderProp ) ! = tex )
{
mat . SetTexture ( shaderProp , tex ) ;
modified = true ;
}
}
continue ;
}
}
// TODO: Add handlers for other property types (Vectors, Ints, Keywords, RenderQueue, etc.)
2025-10-04 08:23:28 +08:00
return modified ;
}
Feature/session based instance routing (#369)
* Add support for multiple Unity instances
* fix port detection
* add missing unity_instance parameter
* add instance params for resources
* Fix CodeRabbit review feedback
- Fix partial framed response handling in port discovery
Add _recv_exact() helper to ensure complete frame reading
Prevents healthy Unity instances from being misidentified as offline
- Remove unused default_conn variables in server.py (2 files)
Fixes Ruff F841 lint error that would block CI/CD
- Preserve sync/async nature of resources in wrapper
Check if original function is coroutine before wrapping
Prevents 'dict object is not awaitable' runtime errors
- Fix reconnection to preserve instance_id
Add instance_id tracking to UnityConnection dataclass
Reconnection now targets the same Unity instance instead of any available one
Prevents operations from being applied to wrong project
- Add instance logging to manage_asset for debugging
Helps troubleshoot multi-instance scenarios
🤖 Generated with Claude Code
Co-Authored-By: Claude <noreply@anthropic.com>
* Fix CodeRabbit feedback: reconnection fallback and annotations safety
Address 3 CodeRabbit review comments:
1. Critical: Guard reconnection fallback to prevent wrong instance routing
- When instance_id is set but rediscovery fails, now raises ConnectionError
- Added 'from e' to preserve exception chain for better debugging
- Prevents silently connecting to different Unity instance
- Ensures multi-instance routing integrity
2. Minor: Guard __annotations__ access in resource registration
- Use getattr(func, '__annotations__', {}) instead of direct access
- Prevents AttributeError for functions without type hints
3. Minor: Remove unused get_type_hints import
- Clean up unused import in resources/__init__.py
All changes applied to both Server/ and MCPForUnity/ directories.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* Fix instance sorting and logging issues
- Fix sorting logic for instances without heartbeat data: use epoch timestamp instead of current time to properly deprioritize instances with None last_heartbeat
- Use logger.exception() instead of logger.error() in disconnect_all() to include stack traces for better debugging
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* update uv.lock to prepare for merging into main
* Restore Python 3.10 lockfiles and package list_unity_instances tool
* Deduplicate Unity instance discovery by port
* Scope status-file reload checks to the active instance
* refactor: implement FastMCP middleware for session-based instance routing
Replaces module-level session_state.py with UnityInstanceMiddleware class
that follows FastMCP best practices. Middleware intercepts all tool calls
via on_call_tool hook and injects active Unity instance into request state.
Key changes:
- Add UnityInstanceMiddleware class with on_call_tool hook
- Tools now use ctx.get_state("unity_instance") instead of direct session_state calls
- Remove unity_instance parameter from all tool schemas to prevent LLM hallucination
- Convert list_unity_instances tool to unity_instances resource (read-only data)
- Update error messages to reference unity://instances resource
- Add set_state/get_state methods to DummyContext test helper
- All 67 tests passing (55 passed, 5 skipped, 7 xpassed)
Architecture benefits:
- Centralized session management in middleware
- Standard FastMCP patterns (middleware + request state)
- Cleaner separation of concerns
- Prevents AI hallucination of invalid instance IDs
* fix: convert resource templates to static resources for discoverability
Convert MCP resources from URI templates with query parameters to static
resources to fix discoverability in MCP clients like Claude Code.
Changes:
- Remove {?force_refresh} from unity://instances
- Remove {?unity_instance} from mcpforunity://menu-items
- Remove {?unity_instance} from mcpforunity://tests
- Keep {mode} path parameter in mcpforunity://tests/{mode} (legitimate)
Root cause: Query parameters {?param} trigger ResourceTemplate registration,
which are listed via resources/templates/list instead of resources/list.
Claude Code's ListMcpResourcesTool only queries resources/list, making
templates undiscoverable.
Solution: Remove optional query parameters from URIs. Instance routing is
handled by middleware/context, and force_refresh was cache control that
doesn't belong in resource identity.
Impact: Resources now discoverable via standard resources/list endpoint and
work with all MCP clients including Claude Code and Cursor.
Requires FastMCP >=2.13.0 for proper RFC 6570 query parameter support.
* feat: improve material properties and sync Server resources
Material Property Improvements (ManageAsset.cs):
- Add GetMainColorPropertyName() helper that auto-detects shader color properties
- Tries _BaseColor (URP), _Color (Standard), _MainColor, _Tint, _TintColor
- Update both named and array color property handling to use auto-detection
- Add warning messages when color properties don't exist on materials
- Split HasProperty check from SetColor to enable error reporting
This fixes the issue where simple color array format [r,g,b,a] defaulted to
_Color property, causing silent failures with URP Lit shader which uses _BaseColor.
Server Resource Sync:
- Sync Server/resources with MCPForUnity/UnityMcpServer~/src/resources
- Remove query parameters from resource URIs for discoverability
- Use session-based instance routing via get_unity_instance_from_context()
* fix: repair instance routing and simplify get_unity_instance_from_context
PROBLEM:
Instance routing was failing - scripts went to wrong Unity instances.
Script1 (intended: ramble) -> went to UnityMCPTests ❌
Script2 (intended: UnityMCPTests) -> went to ramble ❌
ROOT CAUSE:
Two incompatible approaches for accessing active instance:
1. Middleware: ctx.set_state() / ctx.get_state() - used by most tools
2. Legacy: ctx.request_context.meta - used by script tools
Script tools were reading from wrong location, middleware had no effect.
FIX:
1. Updated get_unity_instance_from_context() to read from ctx.get_state()
2. Removed legacy request_context.meta code path (98 lines removed)
3. Single source of truth: middleware state only
TESTING:
- Added comprehensive test suite (21 tests) covering all scenarios
- Tests middleware state management, session isolation, race conditions
- Tests reproduce exact 4-script failure scenario
- All 88 tests pass (76 passed + 5 skipped + 7 xpassed)
- Verified fix with live 4-script test: 100% success rate
Files changed:
- Server/tools/__init__.py: Simplified from 75 lines to 15 lines
- MCPForUnity/UnityMcpServer~/src/tools/__init__.py: Same simplification
- tests/test_instance_routing_comprehensive.py: New comprehensive test suite
* refactor: standardize instance extraction and remove dead imports
- Standardize all 18 tools to use get_unity_instance_from_context() helper
instead of direct ctx.get_state() calls for consistency
- Remove dead session_state imports from with_unity_instance decorator
that would cause ModuleNotFoundError at runtime
- Update README.md with concise instance routing documentation
* fix: critical timezone and import bugs from code review
- Remove incorrect port safety check that treated reclaimed ports as errors
(GetPortWithFallback may legitimately return same port if it became available)
- Fix timezone-aware vs naive datetime mixing in unity_connection.py sorting
(use timestamp() for comparison to avoid TypeError)
- Normalize all datetime comparisons in port_discovery.py to UTC
(file_mtime and last_heartbeat now consistently timezone-aware)
- Add missing send_with_unity_instance import in Server/tools/manage_script.py
(was causing NameError at runtime on lines 108 and 488)
All 88 tests pass (76 passed + 5 skipped + 7 xpassed)
---------
Co-authored-by: Sakura <sakurachan@qq.com>
Co-authored-by: Claude <noreply@anthropic.com>
2025-11-06 01:43:36 +08:00
/// <summary>
/// Auto-detects the main color property name for a material's shader.
/// Tries common color property names in order: _BaseColor (URP), _Color (Standard), etc.
/// </summary>
private static string GetMainColorPropertyName ( Material mat )
{
if ( mat = = null | | mat . shader = = null )
return "_Color" ;
// Try common color property names in order of likelihood
string [ ] commonColorProps = { "_BaseColor" , "_Color" , "_MainColor" , "_Tint" , "_TintColor" } ;
foreach ( var prop in commonColorProps )
{
if ( mat . HasProperty ( prop ) )
return prop ;
}
// Fallback to _Color if none found
return "_Color" ;
}
2025-10-04 08:23:28 +08:00
/// <summary>
/// Applies properties from JObject to a PhysicsMaterial.
/// </summary>
private static bool ApplyPhysicsMaterialProperties ( PhysicsMaterialType pmat , JObject properties )
{
if ( pmat = = null | | properties = = null )
return false ;
bool modified = false ;
// Example: Set dynamic friction
if ( properties [ "dynamicFriction" ] ? . Type = = JTokenType . Float )
{
float dynamicFriction = properties [ "dynamicFriction" ] . ToObject < float > ( ) ;
pmat . dynamicFriction = dynamicFriction ;
modified = true ;
}
// Example: Set static friction
if ( properties [ "staticFriction" ] ? . Type = = JTokenType . Float )
{
float staticFriction = properties [ "staticFriction" ] . ToObject < float > ( ) ;
pmat . staticFriction = staticFriction ;
modified = true ;
}
// Example: Set bounciness
if ( properties [ "bounciness" ] ? . Type = = JTokenType . Float )
{
float bounciness = properties [ "bounciness" ] . ToObject < float > ( ) ;
pmat . bounciness = bounciness ;
modified = true ;
}
List < String > averageList = new List < String > { "ave" , "Ave" , "average" , "Average" } ;
List < String > multiplyList = new List < String > { "mul" , "Mul" , "mult" , "Mult" , "multiply" , "Multiply" } ;
List < String > minimumList = new List < String > { "min" , "Min" , "minimum" , "Minimum" } ;
List < String > maximumList = new List < String > { "max" , "Max" , "maximum" , "Maximum" } ;
// Example: Set friction combine
if ( properties [ "frictionCombine" ] ? . Type = = JTokenType . String )
{
string frictionCombine = properties [ "frictionCombine" ] . ToString ( ) ;
if ( averageList . Contains ( frictionCombine ) )
pmat . frictionCombine = PhysicsMaterialCombine . Average ;
else if ( multiplyList . Contains ( frictionCombine ) )
pmat . frictionCombine = PhysicsMaterialCombine . Multiply ;
else if ( minimumList . Contains ( frictionCombine ) )
pmat . frictionCombine = PhysicsMaterialCombine . Minimum ;
else if ( maximumList . Contains ( frictionCombine ) )
pmat . frictionCombine = PhysicsMaterialCombine . Maximum ;
modified = true ;
}
// Example: Set bounce combine
if ( properties [ "bounceCombine" ] ? . Type = = JTokenType . String )
{
string bounceCombine = properties [ "bounceCombine" ] . ToString ( ) ;
if ( averageList . Contains ( bounceCombine ) )
pmat . bounceCombine = PhysicsMaterialCombine . Average ;
else if ( multiplyList . Contains ( bounceCombine ) )
pmat . bounceCombine = PhysicsMaterialCombine . Multiply ;
else if ( minimumList . Contains ( bounceCombine ) )
pmat . bounceCombine = PhysicsMaterialCombine . Minimum ;
else if ( maximumList . Contains ( bounceCombine ) )
pmat . bounceCombine = PhysicsMaterialCombine . Maximum ;
modified = true ;
}
return modified ;
}
/// <summary>
/// Generic helper to set properties on any UnityEngine.Object using reflection.
/// </summary>
private static bool ApplyObjectProperties ( UnityEngine . Object target , JObject properties )
{
if ( target = = null | | properties = = null )
return false ;
bool modified = false ;
Type type = target . GetType ( ) ;
foreach ( var prop in properties . Properties ( ) )
{
string propName = prop . Name ;
JToken propValue = prop . Value ;
if ( SetPropertyOrField ( target , propName , propValue , type ) )
{
modified = true ;
}
}
return modified ;
}
/// <summary>
/// Helper to set a property or field via reflection, handling basic types and Unity objects.
/// </summary>
private static bool SetPropertyOrField (
object target ,
string memberName ,
JToken value ,
Type type = null
)
{
type = type ? ? target . GetType ( ) ;
System . Reflection . BindingFlags flags =
System . Reflection . BindingFlags . Public
| System . Reflection . BindingFlags . Instance
| System . Reflection . BindingFlags . IgnoreCase ;
try
{
System . Reflection . PropertyInfo propInfo = type . GetProperty ( memberName , flags ) ;
if ( propInfo ! = null & & propInfo . CanWrite )
{
object convertedValue = ConvertJTokenToType ( value , propInfo . PropertyType ) ;
if (
convertedValue ! = null
& & ! object . Equals ( propInfo . GetValue ( target ) , convertedValue )
)
{
propInfo . SetValue ( target , convertedValue ) ;
return true ;
}
}
else
{
System . Reflection . FieldInfo fieldInfo = type . GetField ( memberName , flags ) ;
if ( fieldInfo ! = null )
{
object convertedValue = ConvertJTokenToType ( value , fieldInfo . FieldType ) ;
if (
convertedValue ! = null
& & ! object . Equals ( fieldInfo . GetValue ( target ) , convertedValue )
)
{
fieldInfo . SetValue ( target , convertedValue ) ;
return true ;
}
}
}
}
catch ( Exception ex )
{
Debug . LogWarning (
$"[SetPropertyOrField] Failed to set '{memberName}' on {type.Name}: {ex.Message}"
) ;
}
return false ;
}
/// <summary>
/// Simple JToken to Type conversion for common Unity types and primitives.
/// </summary>
private static object ConvertJTokenToType ( JToken token , Type targetType )
{
try
{
if ( token = = null | | token . Type = = JTokenType . Null )
return null ;
if ( targetType = = typeof ( string ) )
return token . ToObject < string > ( ) ;
if ( targetType = = typeof ( int ) )
return token . ToObject < int > ( ) ;
if ( targetType = = typeof ( float ) )
return token . ToObject < float > ( ) ;
if ( targetType = = typeof ( bool ) )
return token . ToObject < bool > ( ) ;
if ( targetType = = typeof ( Vector2 ) & & token is JArray arrV2 & & arrV2 . Count = = 2 )
return new Vector2 ( arrV2 [ 0 ] . ToObject < float > ( ) , arrV2 [ 1 ] . ToObject < float > ( ) ) ;
if ( targetType = = typeof ( Vector3 ) & & token is JArray arrV3 & & arrV3 . Count = = 3 )
return new Vector3 (
arrV3 [ 0 ] . ToObject < float > ( ) ,
arrV3 [ 1 ] . ToObject < float > ( ) ,
arrV3 [ 2 ] . ToObject < float > ( )
) ;
if ( targetType = = typeof ( Vector4 ) & & token is JArray arrV4 & & arrV4 . Count = = 4 )
return new Vector4 (
arrV4 [ 0 ] . ToObject < float > ( ) ,
arrV4 [ 1 ] . ToObject < float > ( ) ,
arrV4 [ 2 ] . ToObject < float > ( ) ,
arrV4 [ 3 ] . ToObject < float > ( )
) ;
if ( targetType = = typeof ( Quaternion ) & & token is JArray arrQ & & arrQ . Count = = 4 )
return new Quaternion (
arrQ [ 0 ] . ToObject < float > ( ) ,
arrQ [ 1 ] . ToObject < float > ( ) ,
arrQ [ 2 ] . ToObject < float > ( ) ,
arrQ [ 3 ] . ToObject < float > ( )
) ;
if ( targetType = = typeof ( Color ) & & token is JArray arrC & & arrC . Count > = 3 ) // Allow RGB or RGBA
return new Color (
arrC [ 0 ] . ToObject < float > ( ) ,
arrC [ 1 ] . ToObject < float > ( ) ,
arrC [ 2 ] . ToObject < float > ( ) ,
arrC . Count > 3 ? arrC [ 3 ] . ToObject < float > ( ) : 1.0f
) ;
if ( targetType . IsEnum )
return Enum . Parse ( targetType , token . ToString ( ) , true ) ; // Case-insensitive enum parsing
// Handle loading Unity Objects (Materials, Textures, etc.) by path
if (
typeof ( UnityEngine . Object ) . IsAssignableFrom ( targetType )
& & token . Type = = JTokenType . String
)
{
string assetPath = AssetPathUtility . SanitizeAssetPath ( token . ToString ( ) ) ;
UnityEngine . Object loadedAsset = AssetDatabase . LoadAssetAtPath (
assetPath ,
targetType
) ;
if ( loadedAsset = = null )
{
Debug . LogWarning (
$"[ConvertJTokenToType] Could not load asset of type {targetType.Name} from path: {assetPath}"
) ;
}
return loadedAsset ;
}
// Fallback: Try direct conversion (might work for other simple value types)
return token . ToObject ( targetType ) ;
}
catch ( Exception ex )
{
Debug . LogWarning (
$"[ConvertJTokenToType] Could not convert JToken '{token}' (type {token.Type}) to type '{targetType.Name}': {ex.Message}"
) ;
return null ;
}
}
// --- Data Serialization ---
/// <summary>
/// Creates a serializable representation of an asset.
/// </summary>
private static object GetAssetData ( string path , bool generatePreview = false )
{
if ( string . IsNullOrEmpty ( path ) | | ! AssetExists ( path ) )
return null ;
string guid = AssetDatabase . AssetPathToGUID ( path ) ;
Type assetType = AssetDatabase . GetMainAssetTypeAtPath ( path ) ;
UnityEngine . Object asset = AssetDatabase . LoadAssetAtPath < UnityEngine . Object > ( path ) ;
string previewBase64 = null ;
int previewWidth = 0 ;
int previewHeight = 0 ;
if ( generatePreview & & asset ! = null )
{
Texture2D preview = AssetPreview . GetAssetPreview ( asset ) ;
if ( preview ! = null )
{
try
{
// Ensure texture is readable for EncodeToPNG
// Creating a temporary readable copy is safer
RenderTexture rt = null ;
Texture2D readablePreview = null ;
RenderTexture previous = RenderTexture . active ;
try
{
rt = RenderTexture . GetTemporary ( preview . width , preview . height ) ;
Graphics . Blit ( preview , rt ) ;
RenderTexture . active = rt ;
readablePreview = new Texture2D ( preview . width , preview . height , TextureFormat . RGB24 , false ) ;
readablePreview . ReadPixels ( new Rect ( 0 , 0 , rt . width , rt . height ) , 0 , 0 ) ;
readablePreview . Apply ( ) ;
var pngData = readablePreview . EncodeToPNG ( ) ;
if ( pngData ! = null & & pngData . Length > 0 )
{
previewBase64 = Convert . ToBase64String ( pngData ) ;
previewWidth = readablePreview . width ;
previewHeight = readablePreview . height ;
}
}
finally
{
RenderTexture . active = previous ;
if ( rt ! = null ) RenderTexture . ReleaseTemporary ( rt ) ;
if ( readablePreview ! = null ) UnityEngine . Object . DestroyImmediate ( readablePreview ) ;
}
}
catch ( Exception ex )
{
Debug . LogWarning (
$"Failed to generate readable preview for '{path}': {ex.Message}. Preview might not be readable."
) ;
// Fallback: Try getting static preview if available?
// Texture2D staticPreview = AssetPreview.GetMiniThumbnail(asset);
}
}
else
{
Debug . LogWarning (
$"Could not get asset preview for {path} (Type: {assetType?.Name}). Is it supported?"
) ;
}
}
return new
{
path = path ,
guid = guid ,
assetType = assetType ? . FullName ? ? "Unknown" ,
name = Path . GetFileNameWithoutExtension ( path ) ,
fileName = Path . GetFileName ( path ) ,
isFolder = AssetDatabase . IsValidFolder ( path ) ,
instanceID = asset ? . GetInstanceID ( ) ? ? 0 ,
lastWriteTimeUtc = File . GetLastWriteTimeUtc (
Path . Combine ( Directory . GetCurrentDirectory ( ) , path )
)
. ToString ( "o" ) , // ISO 8601
// --- Preview Data ---
previewBase64 = previewBase64 , // PNG data as Base64 string
previewWidth = previewWidth ,
previewHeight = previewHeight ,
// TODO: Add more metadata? Importer settings? Dependencies?
} ;
}
}
}