feat: Add `manage_material` tool for dedicated material manipulation (#440)

* WIP: Material management tool implementation and tests

- Add ManageMaterial tool for creating and modifying materials
- Add MaterialOps helper for material property operations
- Add comprehensive test suite for material management
- Add string parameter parsing support for material properties
- Update related tools (ManageGameObject, manage_asset, etc.)
- Add test materials and scenes for material testing

* refactor: unify material property logic into MaterialOps

- Move  and  logic from  to
- Update  to delegate to
- Update  to use enhanced  for creation and property setting
- Add texture path loading support to

* Add parameter aliasing support: accept 'name' as alias for 'target' in manage_gameobject modify action

* Refactor ManageMaterial and fix code review issues

- Fix Python server tools (redundant imports, exception handling, string formatting)
- Clean up documentation and error reports
- Improve ManageMaterial.cs (overwrite checks, error handling)
- Enhance MaterialOps.cs (robustness, logging, dead code removal)
- Update tests (assertions, unused imports)
- Fix manifest.json relative path
- Remove temporary test artifacts and manual setup scripts

* Remove test scene

* remove extra mat

* Remove unnecessary SceneTemplateSettings.json

* Remove unnecessary SceneTemplateSettings.json

* Fix MaterialOps issues

* Fix: Case-insensitive material property lookup and missing HasProperty checks

* Rabbit fixes

* Improve material ops logging and test coverage

* Fix: NormalizePath now handles backslashes correctly using AssetPathUtility

* Fix: Address multiple nitpicks (test robustness, shader resolution, HasProperty checks)

* Add manage_material tool documentation and fix MaterialOps texture property checks

- Add comprehensive ManageMaterial tool documentation to MCPForUnity/README.md
- Add manage_material to tools list in README.md and README-zh.md
- Fix MaterialOps.cs to check HasProperty before SetTexture calls to prevent Unity warnings
- Ensures consistency with other property setters in MaterialOps

* Fix ManageMaterial shader reflection for Unity 6 and improve texture logging
main
dsarno 2025-12-07 19:39:52 -08:00 committed by GitHub
parent 7f8ca2a3bd
commit fe4cae7241
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
28 changed files with 2004 additions and 404 deletions

View File

@ -0,0 +1,397 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using UnityEngine;
using UnityEditor;
using MCPForUnity.Editor.Tools;
namespace MCPForUnity.Editor.Helpers
{
public static class MaterialOps
{
/// <summary>
/// Applies a set of properties (JObject) to a material, handling aliases and structured formats.
/// </summary>
public static bool ApplyProperties(Material mat, JObject properties, JsonSerializer serializer)
{
if (mat == null || properties == null)
return false;
bool modified = false;
// Helper for case-insensitive lookup
JToken GetValue(string key)
{
return properties.Properties()
.FirstOrDefault(p => string.Equals(p.Name, key, StringComparison.OrdinalIgnoreCase))?.Value;
}
// --- Structured / Legacy Format Handling ---
// Example: Set shader
var shaderToken = GetValue("shader");
if (shaderToken?.Type == JTokenType.String)
{
string shaderRequest = shaderToken.ToString();
// Set shader
Shader newShader = RenderPipelineUtility.ResolveShader(shaderRequest);
if (newShader != null && mat.shader != newShader)
{
mat.shader = newShader;
modified = true;
}
}
// Example: Set color property (structured)
var colorToken = GetValue("color");
if (colorToken is JObject colorProps)
{
string propName = colorProps["name"]?.ToString() ?? GetMainColorPropertyName(mat);
if (colorProps["value"] is JArray colArr && colArr.Count >= 3)
{
try
{
Color newColor = ParseColor(colArr, serializer);
if (mat.HasProperty(propName))
{
if (mat.GetColor(propName) != newColor)
{
mat.SetColor(propName, newColor);
modified = true;
}
}
}
catch (Exception ex)
{
Debug.LogWarning($"[MaterialOps] Failed to parse color for property '{propName}': {ex.Message}");
}
}
}
else if (colorToken is JArray colorArr) // Structured shorthand
{
string propName = GetMainColorPropertyName(mat);
try
{
Color newColor = ParseColor(colorArr, serializer);
if (mat.HasProperty(propName) && mat.GetColor(propName) != newColor)
{
mat.SetColor(propName, newColor);
modified = true;
}
}
catch (Exception ex)
{
Debug.LogWarning($"[MaterialOps] Failed to parse color array: {ex.Message}");
}
}
// Example: Set float property (structured)
var floatToken = GetValue("float");
if (floatToken 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($"[MaterialOps] Failed to set float property '{propName}': {ex.Message}");
}
}
}
// Example: Set texture property (structured)
{
var texToken = GetValue("texture");
if (texToken is JObject texProps)
{
string rawName = (texProps["name"] ?? texProps["Name"])?.ToString();
string texPath = (texProps["path"] ?? texProps["Path"])?.ToString();
if (!string.IsNullOrEmpty(texPath))
{
var sanitizedPath = AssetPathUtility.SanitizeAssetPath(texPath);
var newTex = AssetDatabase.LoadAssetAtPath<Texture>(sanitizedPath);
// Use ResolvePropertyName to handle aliases even for structured texture names
string candidateName = string.IsNullOrEmpty(rawName) ? "_BaseMap" : rawName;
string targetProp = ResolvePropertyName(mat, candidateName);
if (!string.IsNullOrEmpty(targetProp) && mat.HasProperty(targetProp))
{
if (mat.GetTexture(targetProp) != newTex)
{
mat.SetTexture(targetProp, newTex);
modified = true;
}
}
}
}
}
// --- Direct Property Assignment (Flexible) ---
var reservedKeys = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "shader", "color", "float", "texture" };
foreach (var prop in properties.Properties())
{
if (reservedKeys.Contains(prop.Name)) continue;
string shaderProp = ResolvePropertyName(mat, prop.Name);
JToken v = prop.Value;
if (TrySetShaderProperty(mat, shaderProp, v, serializer))
{
modified = true;
}
}
return modified;
}
/// <summary>
/// Resolves common property aliases (e.g. "metallic" -> "_Metallic").
/// </summary>
public static string ResolvePropertyName(Material mat, string name)
{
if (mat == null || string.IsNullOrEmpty(name)) return name;
string[] candidates;
var lower = name.ToLowerInvariant();
switch (lower)
{
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
}
foreach (var candidate in candidates)
{
if (mat.HasProperty(candidate)) return candidate;
}
return name;
}
/// <summary>
/// Auto-detects the main color property name for a material's shader.
/// </summary>
public static string GetMainColorPropertyName(Material mat)
{
if (mat == null || mat.shader == null)
return "_Color";
string[] commonColorProps = { "_BaseColor", "_Color", "_MainColor", "_Tint", "_TintColor" };
foreach (var prop in commonColorProps)
{
if (mat.HasProperty(prop))
return prop;
}
return "_Color";
}
/// <summary>
/// Tries to set a shader property on a material based on a JToken value.
/// Handles Colors, Vectors, Floats, Ints, Booleans, and Textures.
/// </summary>
public static bool TrySetShaderProperty(Material material, string propertyName, JToken value, JsonSerializer serializer)
{
if (material == null || string.IsNullOrEmpty(propertyName) || value == null)
return false;
// Handle stringified JSON
if (value.Type == JTokenType.String)
{
string s = value.ToString();
if (s.TrimStart().StartsWith("[") || s.TrimStart().StartsWith("{"))
{
try
{
JToken parsed = JToken.Parse(s);
return TrySetShaderProperty(material, propertyName, parsed, serializer);
}
catch { }
}
}
// Use the serializer to convert the JToken value first
if (value is JArray jArray)
{
if (jArray.Count == 4)
{
if (material.HasProperty(propertyName))
{
try { material.SetColor(propertyName, ParseColor(value, serializer)); return true; }
catch (Exception ex)
{
// Log at Debug level since we'll try other conversions
Debug.Log($"[MaterialOps] SetColor attempt for '{propertyName}' failed: {ex.Message}");
}
try { Vector4 vec = value.ToObject<Vector4>(serializer); material.SetVector(propertyName, vec); return true; }
catch (Exception ex)
{
Debug.Log($"[MaterialOps] SetVector (Vec4) attempt for '{propertyName}' failed: {ex.Message}");
}
}
}
else if (jArray.Count == 3)
{
if (material.HasProperty(propertyName))
{
try { material.SetColor(propertyName, ParseColor(value, serializer)); return true; }
catch (Exception ex)
{
Debug.Log($"[MaterialOps] SetColor (Vec3) attempt for '{propertyName}' failed: {ex.Message}");
}
}
}
else if (jArray.Count == 2)
{
if (material.HasProperty(propertyName))
{
try { Vector2 vec = value.ToObject<Vector2>(serializer); material.SetVector(propertyName, vec); return true; }
catch (Exception ex)
{
Debug.Log($"[MaterialOps] SetVector (Vec2) attempt for '{propertyName}' failed: {ex.Message}");
}
}
}
}
else if (value.Type == JTokenType.Float || value.Type == JTokenType.Integer)
{
if (!material.HasProperty(propertyName))
return false;
try { material.SetFloat(propertyName, value.ToObject<float>(serializer)); return true; }
catch (Exception ex)
{
Debug.Log($"[MaterialOps] SetFloat attempt for '{propertyName}' failed: {ex.Message}");
}
}
else if (value.Type == JTokenType.Boolean)
{
if (!material.HasProperty(propertyName))
return false;
try { material.SetFloat(propertyName, value.ToObject<bool>(serializer) ? 1f : 0f); return true; }
catch (Exception ex)
{
Debug.Log($"[MaterialOps] SetFloat (bool) attempt for '{propertyName}' failed: {ex.Message}");
}
}
else if (value.Type == JTokenType.String)
{
try
{
// Try loading as asset path first (most common case for strings in this context)
string path = value.ToString();
if (!string.IsNullOrEmpty(path) && path.Contains("/")) // Heuristic: paths usually have slashes
{
// We need to handle texture assignment here.
// Since we don't have easy access to AssetDatabase here directly without using UnityEditor namespace (which is imported),
// we can try to load it.
var sanitizedPath = AssetPathUtility.SanitizeAssetPath(path);
Texture tex = AssetDatabase.LoadAssetAtPath<Texture>(sanitizedPath);
if (tex != null && material.HasProperty(propertyName))
{
material.SetTexture(propertyName, tex);
return true;
}
}
}
catch (Exception ex)
{
McpLog.Warn($"SetTexture (string path) for '{propertyName}' failed: {ex.Message}");
}
}
if (value.Type == JTokenType.Object)
{
try
{
Texture texture = value.ToObject<Texture>(serializer);
if (texture != null && material.HasProperty(propertyName))
{
material.SetTexture(propertyName, texture);
return true;
}
}
catch (Exception ex)
{
McpLog.Warn($"SetTexture (object) for '{propertyName}' failed: {ex.Message}");
}
}
Debug.LogWarning(
$"[MaterialOps] Unsupported or failed conversion for material property '{propertyName}' from value: {value.ToString(Formatting.None)}"
);
return false;
}
/// <summary>
/// Helper to parse color from JToken (array or object).
/// </summary>
public static Color ParseColor(JToken token, JsonSerializer serializer)
{
if (token.Type == JTokenType.String)
{
string s = token.ToString();
if (s.TrimStart().StartsWith("[") || s.TrimStart().StartsWith("{"))
{
try
{
return ParseColor(JToken.Parse(s), serializer);
}
catch { }
}
}
if (token is JArray jArray)
{
if (jArray.Count == 4)
{
return new Color(
(float)jArray[0],
(float)jArray[1],
(float)jArray[2],
(float)jArray[3]
);
}
else if (jArray.Count == 3)
{
return new Color(
(float)jArray[0],
(float)jArray[1],
(float)jArray[2],
1f
);
}
else
{
throw new ArgumentException("Color array must have 3 or 4 elements.");
}
}
try
{
return token.ToObject<Color>(serializer);
}
catch (Exception ex)
{
Debug.LogWarning($"[MaterialOps] Failed to parse color from token: {ex.Message}");
throw;
}
}
}
}

View File

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

View File

@ -215,7 +215,7 @@ namespace MCPForUnity.Editor.Tools
if (propertiesForApply.HasValues) if (propertiesForApply.HasValues)
{ {
ApplyMaterialProperties(mat, propertiesForApply); MaterialOps.ApplyProperties(mat, propertiesForApply, ManageGameObject.InputSerializer);
} }
} }
AssetDatabase.CreateAsset(mat, fullPath); AssetDatabase.CreateAsset(mat, fullPath);
@ -443,7 +443,7 @@ namespace MCPForUnity.Editor.Tools
{ {
// Apply properties directly to the material. If this modifies, it sets modified=true. // Apply properties directly to the material. If this modifies, it sets modified=true.
// Use |= in case the asset was already marked modified by previous logic (though unlikely here) // Use |= in case the asset was already marked modified by previous logic (though unlikely here)
modified |= ApplyMaterialProperties(material, properties); modified |= MaterialOps.ApplyProperties(material, properties, ManageGameObject.InputSerializer);
} }
// Example: Modifying a ScriptableObject // Example: Modifying a ScriptableObject
else if (asset is ScriptableObject so) else if (asset is ScriptableObject so)
@ -897,299 +897,7 @@ namespace MCPForUnity.Editor.Tools
} }
} }
/// <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)
{
string shaderRequest = properties["shader"].ToString();
Shader newShader = RenderPipelineUtility.ResolveShader(shaderRequest);
if (newShader != null && mat.shader != newShader)
{
mat.shader = newShader;
modified = true;
}
}
// Example: Set color property
if (properties["color"] is JObject colorProps)
{
string propName = colorProps["name"]?.ToString() ?? GetMainColorPropertyName(mat); // Auto-detect if not specified
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
);
if (mat.HasProperty(propName))
{
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)"
);
}
}
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
{
// Auto-detect the main color property for the shader
string propName = GetMainColorPropertyName(mat);
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
);
if (mat.HasProperty(propName))
{
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)"
);
}
}
}
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}"
);
}
}
}
// Example: Set texture property (case-insensitive key and subkeys)
{
JObject texProps = null;
var direct = properties.Property("texture");
if (direct != null && direct.Value is JObject t0) texProps = t0;
if (texProps == null)
{
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))
{
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;
}
}
}
}
}
}
// --- 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;
var lower = name.ToLowerInvariant();
switch (lower)
{
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
}
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.)
return modified;
}
/// <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";
}
/// <summary> /// <summary>
/// Applies properties from JObject to a PhysicsMaterial. /// Applies properties from JObject to a PhysicsMaterial.

View File

@ -23,7 +23,7 @@ namespace MCPForUnity.Editor.Tools
public static class ManageGameObject public static class ManageGameObject
{ {
// Shared JsonSerializer to avoid per-call allocation overhead // Shared JsonSerializer to avoid per-call allocation overhead
private static readonly JsonSerializer InputSerializer = JsonSerializer.Create(new JsonSerializerSettings internal static readonly JsonSerializer InputSerializer = JsonSerializer.Create(new JsonSerializerSettings
{ {
Converters = new List<JsonConverter> Converters = new List<JsonConverter>
{ {
@ -54,10 +54,21 @@ namespace MCPForUnity.Editor.Tools
// Parameters used by various actions // Parameters used by various actions
JToken targetToken = @params["target"]; // Can be string (name/path) or int (instanceID) JToken targetToken = @params["target"]; // Can be string (name/path) or int (instanceID)
string searchMethod = @params["searchMethod"]?.ToString().ToLower();
// Get common parameters (consolidated)
string name = @params["name"]?.ToString(); string name = @params["name"]?.ToString();
// --- Usability Improvement: Alias 'name' to 'target' for modification actions ---
// If 'target' is missing but 'name' is provided, and we aren't creating a new object,
// assume the user meant "find object by name".
if (targetToken == null && !string.IsNullOrEmpty(name) && action != "create")
{
targetToken = name;
// We don't update @params["target"] because we use targetToken locally mostly,
// but some downstream methods might parse @params directly. Let's update @params too for safety.
@params["target"] = name;
}
// -------------------------------------------------------------------------------
string searchMethod = @params["searchMethod"]?.ToString().ToLower();
string tag = @params["tag"]?.ToString(); string tag = @params["tag"]?.ToString();
string layer = @params["layer"]?.ToString(); string layer = @params["layer"]?.ToString();
JToken parentToken = @params["parent"]; JToken parentToken = @params["parent"];
@ -2112,51 +2123,7 @@ namespace MCPForUnity.Editor.Tools
// Special handling for Material properties (shader properties) // Special handling for Material properties (shader properties)
if (currentObject is Material material && finalPart.StartsWith("_")) if (currentObject is Material material && finalPart.StartsWith("_"))
{ {
// Use the serializer to convert the JToken value first return MaterialOps.TrySetShaderProperty(material, finalPart, value, inputSerializer);
if (value is JArray jArray)
{
// Try converting to known types that SetColor/SetVector accept
if (jArray.Count == 4)
{
try { Color color = value.ToObject<Color>(inputSerializer); material.SetColor(finalPart, color); return true; } catch { }
try { Vector4 vec = value.ToObject<Vector4>(inputSerializer); material.SetVector(finalPart, vec); return true; } catch { }
}
else if (jArray.Count == 3)
{
try { Color color = value.ToObject<Color>(inputSerializer); material.SetColor(finalPart, color); return true; } catch { } // ToObject handles conversion to Color
}
else if (jArray.Count == 2)
{
try { Vector2 vec = value.ToObject<Vector2>(inputSerializer); material.SetVector(finalPart, vec); return true; } catch { }
}
}
else if (value.Type == JTokenType.Float || value.Type == JTokenType.Integer)
{
try { material.SetFloat(finalPart, value.ToObject<float>(inputSerializer)); return true; } catch { }
}
else if (value.Type == JTokenType.Boolean)
{
try { material.SetFloat(finalPart, value.ToObject<bool>(inputSerializer) ? 1f : 0f); return true; } catch { }
}
else if (value.Type == JTokenType.String)
{
// Try converting to Texture using the serializer/converter
try
{
Texture texture = value.ToObject<Texture>(inputSerializer);
if (texture != null)
{
material.SetTexture(finalPart, texture);
return true;
}
}
catch { }
}
Debug.LogWarning(
$"[SetNestedProperty] Unsupported or failed conversion for material property '{finalPart}' from value: {value.ToString(Formatting.None)}"
);
return false;
} }
// For standard properties (not shader specific) // For standard properties (not shader specific)

View File

@ -0,0 +1,518 @@
using System;
using System.Collections.Generic;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using MCPForUnity.Editor.Helpers;
using UnityEngine;
using UnityEditor;
namespace MCPForUnity.Editor.Tools
{
[McpForUnityTool("manage_material", AutoRegister = false)]
public static class ManageMaterial
{
public static object HandleCommand(JObject @params)
{
string action = @params["action"]?.ToString();
if (string.IsNullOrEmpty(action))
{
return new { status = "error", message = "Action is required" };
}
try
{
switch (action)
{
case "ping":
return new { status = "success", tool = "manage_material" };
case "create":
return CreateMaterial(@params);
case "set_material_shader_property":
return SetMaterialShaderProperty(@params);
case "set_material_color":
return SetMaterialColor(@params);
case "assign_material_to_renderer":
return AssignMaterialToRenderer(@params);
case "set_renderer_color":
return SetRendererColor(@params);
case "get_material_info":
return GetMaterialInfo(@params);
default:
return new { status = "error", message = $"Unknown action: {action}" };
}
}
catch (Exception ex)
{
return new { status = "error", message = ex.Message, stackTrace = ex.StackTrace };
}
}
private static string NormalizePath(string path)
{
if (string.IsNullOrEmpty(path)) return path;
// Normalize separators and ensure Assets/ root
path = AssetPathUtility.SanitizeAssetPath(path);
// Ensure .mat extension
if (!path.EndsWith(".mat", StringComparison.OrdinalIgnoreCase))
{
path += ".mat";
}
return path;
}
private static object SetMaterialShaderProperty(JObject @params)
{
string materialPath = NormalizePath(@params["materialPath"]?.ToString());
string property = @params["property"]?.ToString();
JToken value = @params["value"];
if (string.IsNullOrEmpty(materialPath) || string.IsNullOrEmpty(property) || value == null)
{
return new { status = "error", message = "materialPath, property, and value are required" };
}
// Find material
var findInstruction = new JObject { ["find"] = materialPath };
Material mat = ManageGameObject.FindObjectByInstruction(findInstruction, typeof(Material)) as Material;
if (mat == null)
{
return new { status = "error", message = $"Could not find material at path: {materialPath}" };
}
Undo.RecordObject(mat, "Set Material Property");
// Normalize alias/casing once for all code paths
property = MaterialOps.ResolvePropertyName(mat, property);
// 1. Try handling Texture instruction explicitly (ManageMaterial special feature)
if (value.Type == JTokenType.Object)
{
// Check if it looks like an instruction
if (value is JObject obj && (obj.ContainsKey("find") || obj.ContainsKey("method")))
{
Texture tex = ManageGameObject.FindObjectByInstruction(obj, typeof(Texture)) as Texture;
if (tex != null && mat.HasProperty(property))
{
mat.SetTexture(property, tex);
EditorUtility.SetDirty(mat);
return new { status = "success", message = $"Set texture property {property} on {mat.name}" };
}
}
}
// 2. Fallback to standard logic via MaterialOps (handles Colors, Floats, Strings->Path)
bool success = MaterialOps.TrySetShaderProperty(mat, property, value, ManageGameObject.InputSerializer);
if (success)
{
EditorUtility.SetDirty(mat);
return new { status = "success", message = $"Set property {property} on {mat.name}" };
}
else
{
return new { status = "error", message = $"Failed to set property {property}. Value format might be unsupported or texture not found." };
}
}
private static object SetMaterialColor(JObject @params)
{
string materialPath = NormalizePath(@params["materialPath"]?.ToString());
JToken colorToken = @params["color"];
string property = @params["property"]?.ToString();
if (string.IsNullOrEmpty(materialPath) || colorToken == null)
{
return new { status = "error", message = "materialPath and color are required" };
}
var findInstruction = new JObject { ["find"] = materialPath };
Material mat = ManageGameObject.FindObjectByInstruction(findInstruction, typeof(Material)) as Material;
if (mat == null)
{
return new { status = "error", message = $"Could not find material at path: {materialPath}" };
}
Color color;
try
{
color = MaterialOps.ParseColor(colorToken, ManageGameObject.InputSerializer);
}
catch (Exception e)
{
return new { status = "error", message = $"Invalid color format: {e.Message}" };
}
Undo.RecordObject(mat, "Set Material Color");
bool foundProp = false;
if (!string.IsNullOrEmpty(property))
{
if (mat.HasProperty(property))
{
mat.SetColor(property, color);
foundProp = true;
}
}
else
{
// Fallback logic: _BaseColor (URP/HDRP) then _Color (Built-in)
if (mat.HasProperty("_BaseColor"))
{
mat.SetColor("_BaseColor", color);
foundProp = true;
property = "_BaseColor";
}
else if (mat.HasProperty("_Color"))
{
mat.SetColor("_Color", color);
foundProp = true;
property = "_Color";
}
}
if (foundProp)
{
EditorUtility.SetDirty(mat);
return new { status = "success", message = $"Set color on {property}" };
}
else
{
return new { status = "error", message = "Could not find suitable color property (_BaseColor or _Color) or specified property does not exist." };
}
}
private static object AssignMaterialToRenderer(JObject @params)
{
string target = @params["target"]?.ToString();
string searchMethod = @params["searchMethod"]?.ToString();
string materialPath = NormalizePath(@params["materialPath"]?.ToString());
int slot = @params["slot"]?.ToObject<int>() ?? 0;
if (string.IsNullOrEmpty(target) || string.IsNullOrEmpty(materialPath))
{
return new { status = "error", message = "target and materialPath are required" };
}
var goInstruction = new JObject { ["find"] = target };
if (!string.IsNullOrEmpty(searchMethod)) goInstruction["method"] = searchMethod;
GameObject go = ManageGameObject.FindObjectByInstruction(goInstruction, typeof(GameObject)) as GameObject;
if (go == null)
{
return new { status = "error", message = $"Could not find target GameObject: {target}" };
}
Renderer renderer = go.GetComponent<Renderer>();
if (renderer == null)
{
return new { status = "error", message = $"GameObject {go.name} has no Renderer component" };
}
var matInstruction = new JObject { ["find"] = materialPath };
Material mat = ManageGameObject.FindObjectByInstruction(matInstruction, typeof(Material)) as Material;
if (mat == null)
{
return new { status = "error", message = $"Could not find material: {materialPath}" };
}
Undo.RecordObject(renderer, "Assign Material");
Material[] sharedMats = renderer.sharedMaterials;
if (slot < 0 || slot >= sharedMats.Length)
{
return new { status = "error", message = $"Slot {slot} out of bounds (count: {sharedMats.Length})" };
}
sharedMats[slot] = mat;
renderer.sharedMaterials = sharedMats;
EditorUtility.SetDirty(renderer);
return new { status = "success", message = $"Assigned material {mat.name} to {go.name} slot {slot}" };
}
private static object SetRendererColor(JObject @params)
{
string target = @params["target"]?.ToString();
string searchMethod = @params["searchMethod"]?.ToString();
JToken colorToken = @params["color"];
int slot = @params["slot"]?.ToObject<int>() ?? 0;
string mode = @params["mode"]?.ToString() ?? "property_block";
if (string.IsNullOrEmpty(target) || colorToken == null)
{
return new { status = "error", message = "target and color are required" };
}
Color color;
try
{
color = MaterialOps.ParseColor(colorToken, ManageGameObject.InputSerializer);
}
catch (Exception e)
{
return new { status = "error", message = $"Invalid color format: {e.Message}" };
}
var goInstruction = new JObject { ["find"] = target };
if (!string.IsNullOrEmpty(searchMethod)) goInstruction["method"] = searchMethod;
GameObject go = ManageGameObject.FindObjectByInstruction(goInstruction, typeof(GameObject)) as GameObject;
if (go == null)
{
return new { status = "error", message = $"Could not find target GameObject: {target}" };
}
Renderer renderer = go.GetComponent<Renderer>();
if (renderer == null)
{
return new { status = "error", message = $"GameObject {go.name} has no Renderer component" };
}
if (mode == "property_block")
{
if (slot < 0 || slot >= renderer.sharedMaterials.Length)
{
return new { status = "error", message = $"Slot {slot} out of bounds (count: {renderer.sharedMaterials.Length})" };
}
MaterialPropertyBlock block = new MaterialPropertyBlock();
renderer.GetPropertyBlock(block, slot);
if (renderer.sharedMaterials[slot] != null)
{
Material mat = renderer.sharedMaterials[slot];
if (mat.HasProperty("_BaseColor")) block.SetColor("_BaseColor", color);
else if (mat.HasProperty("_Color")) block.SetColor("_Color", color);
else block.SetColor("_Color", color);
}
else
{
block.SetColor("_Color", color);
}
renderer.SetPropertyBlock(block, slot);
EditorUtility.SetDirty(renderer);
return new { status = "success", message = $"Set renderer color (PropertyBlock) on slot {slot}" };
}
else if (mode == "shared")
{
if (slot >= 0 && slot < renderer.sharedMaterials.Length)
{
Material mat = renderer.sharedMaterials[slot];
if (mat == null)
{
return new { status = "error", message = $"No material in slot {slot}" };
}
Undo.RecordObject(mat, "Set Material Color");
if (mat.HasProperty("_BaseColor")) mat.SetColor("_BaseColor", color);
else mat.SetColor("_Color", color);
EditorUtility.SetDirty(mat);
return new { status = "success", message = "Set shared material color" };
}
return new { status = "error", message = "Invalid slot" };
}
else if (mode == "instance")
{
if (slot >= 0 && slot < renderer.materials.Length)
{
Material mat = renderer.materials[slot];
if (mat == null)
{
return new { status = "error", message = $"No material in slot {slot}" };
}
// Note: Undo cannot fully revert material instantiation
Undo.RecordObject(mat, "Set Instance Material Color");
if (mat.HasProperty("_BaseColor")) mat.SetColor("_BaseColor", color);
else mat.SetColor("_Color", color);
return new { status = "success", message = "Set instance material color", warning = "Material instance created; Undo cannot fully revert instantiation." };
}
return new { status = "error", message = "Invalid slot" };
}
return new { status = "error", message = $"Unknown mode: {mode}" };
}
private static object GetMaterialInfo(JObject @params)
{
string materialPath = NormalizePath(@params["materialPath"]?.ToString());
if (string.IsNullOrEmpty(materialPath))
{
return new { status = "error", message = "materialPath is required" };
}
var findInstruction = new JObject { ["find"] = materialPath };
Material mat = ManageGameObject.FindObjectByInstruction(findInstruction, typeof(Material)) as Material;
if (mat == null)
{
return new { status = "error", message = $"Could not find material at path: {materialPath}" };
}
Shader shader = mat.shader;
var properties = new List<object>();
#if UNITY_6000_0_OR_NEWER
int propertyCount = shader.GetPropertyCount();
for (int i = 0; i < propertyCount; i++)
{
string name = shader.GetPropertyName(i);
var type = shader.GetPropertyType(i);
string description = shader.GetPropertyDescription(i);
object currentValue = null;
try
{
if (mat.HasProperty(name))
{
switch (type)
{
case UnityEngine.Rendering.ShaderPropertyType.Color:
var c = mat.GetColor(name);
currentValue = new { r = c.r, g = c.g, b = c.b, a = c.a };
break;
case UnityEngine.Rendering.ShaderPropertyType.Vector:
var v = mat.GetVector(name);
currentValue = new { x = v.x, y = v.y, z = v.z, w = v.w };
break;
case UnityEngine.Rendering.ShaderPropertyType.Float:
case UnityEngine.Rendering.ShaderPropertyType.Range:
currentValue = mat.GetFloat(name);
break;
case UnityEngine.Rendering.ShaderPropertyType.Texture:
currentValue = mat.GetTexture(name)?.name ?? "null";
break;
}
}
}
catch (Exception ex)
{
currentValue = $"<error: {ex.Message}>";
}
properties.Add(new
{
name = name,
type = type.ToString(),
description = description,
value = currentValue
});
}
#else
int propertyCount = ShaderUtil.GetPropertyCount(shader);
for (int i = 0; i < propertyCount; i++)
{
string name = ShaderUtil.GetPropertyName(shader, i);
ShaderUtil.ShaderPropertyType type = ShaderUtil.GetPropertyType(shader, i);
string description = ShaderUtil.GetPropertyDescription(shader, i);
object currentValue = null;
try {
if (mat.HasProperty(name))
{
switch (type) {
case ShaderUtil.ShaderPropertyType.Color:
var c = mat.GetColor(name);
currentValue = new { r = c.r, g = c.g, b = c.b, a = c.a };
break;
case ShaderUtil.ShaderPropertyType.Vector:
var v = mat.GetVector(name);
currentValue = new { x = v.x, y = v.y, z = v.z, w = v.w };
break;
case ShaderUtil.ShaderPropertyType.Float: currentValue = mat.GetFloat(name); break;
case ShaderUtil.ShaderPropertyType.Range: currentValue = mat.GetFloat(name); break;
case ShaderUtil.ShaderPropertyType.TexEnv: currentValue = mat.GetTexture(name)?.name ?? "null"; break;
}
}
} catch (Exception ex) {
currentValue = $"<error: {ex.Message}>";
}
properties.Add(new {
name = name,
type = type.ToString(),
description = description,
value = currentValue
});
}
#endif
return new {
status = "success",
material = mat.name,
shader = shader.name,
properties = properties
};
}
private static object CreateMaterial(JObject @params)
{
string materialPath = NormalizePath(@params["materialPath"]?.ToString());
string shaderName = @params["shader"]?.ToString() ?? "Standard";
JObject properties = null;
JToken propsToken = @params["properties"];
if (propsToken != null)
{
if (propsToken.Type == JTokenType.String)
{
try { properties = JObject.Parse(propsToken.ToString()); }
catch (Exception ex) { return new { status = "error", message = $"Invalid JSON in properties: {ex.Message}" }; }
}
else if (propsToken is JObject obj)
{
properties = obj;
}
}
if (string.IsNullOrEmpty(materialPath))
{
return new { status = "error", message = "materialPath is required" };
}
// Path normalization handled by helper above, explicit check removed
// but we ensure it's valid for CreateAsset
if (!materialPath.StartsWith("Assets/"))
{
return new { status = "error", message = "Path must start with Assets/ (normalization failed)" };
}
Shader shader = RenderPipelineUtility.ResolveShader(shaderName);
if (shader == null)
{
return new { status = "error", message = $"Could not find shader: {shaderName}" };
}
Material material = new Material(shader);
// Check for existing asset to avoid silent overwrite
if (AssetDatabase.LoadAssetAtPath<Material>(materialPath) != null)
{
return new { status = "error", message = $"Material already exists at {materialPath}" };
}
AssetDatabase.CreateAsset(material, materialPath);
if (properties != null)
{
MaterialOps.ApplyProperties(material, properties, ManageGameObject.InputSerializer);
}
EditorUtility.SetDirty(material);
AssetDatabase.SaveAssets();
return new { status = "success", message = $"Created material at {materialPath} with shader {shaderName}" };
}
}
}

View File

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

View File

@ -44,6 +44,7 @@ MCP for Unity 作为桥梁,允许 AI 助手(如 Claude、Cursor通过本
* `manage_asset`: 执行资源操作(导入、创建、修改、删除等)。 * `manage_asset`: 执行资源操作(导入、创建、修改、删除等)。
* `manage_editor`: 控制和查询编辑器的状态和设置。 * `manage_editor`: 控制和查询编辑器的状态和设置。
* `manage_gameobject`: 管理游戏对象:创建、修改、删除、查找和组件操作。 * `manage_gameobject`: 管理游戏对象:创建、修改、删除、查找和组件操作。
* `manage_material`: 管理材质:创建、设置属性、分配给渲染器以及查询材质信息。
* `manage_prefabs`: 执行预制件操作(创建、修改、删除等)。 * `manage_prefabs`: 执行预制件操作(创建、修改、删除等)。
* `manage_scene`: 管理场景(加载、保存、创建、获取层次结构等)。 * `manage_scene`: 管理场景(加载、保存、创建、获取层次结构等)。
* `manage_script`: 传统脚本操作的兼容性路由器(创建、读取、删除)。建议使用 `apply_text_edits``script_apply_edits` 进行编辑。 * `manage_script`: 传统脚本操作的兼容性路由器(创建、读取、删除)。建议使用 `apply_text_edits``script_apply_edits` 进行编辑。

View File

@ -46,6 +46,7 @@ MCP for Unity acts as a bridge, allowing AI assistants (like Claude, Cursor) to
* `manage_asset`: Performs asset operations (import, create, modify, delete, etc.). * `manage_asset`: Performs asset operations (import, create, modify, delete, etc.).
* `manage_editor`: Controls and queries the editor's state and settings. * `manage_editor`: Controls and queries the editor's state and settings.
* `manage_gameobject`: Manages GameObjects: create, modify, delete, find, and component operations. * `manage_gameobject`: Manages GameObjects: create, modify, delete, find, and component operations.
* `manage_material`: Manages materials: create, set properties, colors, assign to renderers, and query material info.
* `manage_prefabs`: Performs prefab operations (create, modify, delete, etc.). * `manage_prefabs`: Performs prefab operations (create, modify, delete, etc.).
* `manage_scene`: Manages scenes (load, save, create, get hierarchy, etc.). * `manage_scene`: Manages scenes (load, save, create, get hierarchy, etc.).
* `manage_script`: Compatibility router for legacy script operations (create, read, delete). Prefer `apply_text_edits` or `script_apply_edits` for edits. * `manage_script`: Compatibility router for legacy script operations (create, read, delete). Prefer `apply_text_edits` or `script_apply_edits` for edits.

View File

@ -9,6 +9,7 @@ from typing import Annotated, Any, Literal
from fastmcp import Context from fastmcp import Context
from services.registry import mcp_for_unity_tool from services.registry import mcp_for_unity_tool
from services.tools import get_unity_instance_from_context from services.tools import get_unity_instance_from_context
from services.tools.utils import parse_json_payload
from transport.unity_transport import send_with_unity_instance from transport.unity_transport import send_with_unity_instance
from transport.legacy.unity_connection import async_send_command_with_retry from transport.legacy.unity_connection import async_send_command_with_retry
@ -63,6 +64,13 @@ async def manage_asset(
return raw, None return raw, None
if isinstance(raw, str): if isinstance(raw, str):
await ctx.info(f"manage_asset: received properties as string (first 100 chars): {raw[:100]}") await ctx.info(f"manage_asset: received properties as string (first 100 chars): {raw[:100]}")
# Try our robust centralized parser first, then fallback to ast.literal_eval specific to manage_asset if needed
parsed = parse_json_payload(raw)
if isinstance(parsed, dict):
await ctx.info("manage_asset: coerced properties using centralized parser")
return parsed, None
# Fallback to original logic for ast.literal_eval which parse_json_payload avoids for safety/simplicity
parsed, source = _parse_properties_string(raw) parsed, source = _parse_properties_string(raw)
if parsed is None: if parsed is None:
return None, source return None, source

View File

@ -1,12 +1,13 @@
import json import json
from typing import Annotated, Any, Literal import math
from typing import Annotated, Any, Literal, Union
from fastmcp import Context from fastmcp import Context
from services.registry import mcp_for_unity_tool from services.registry import mcp_for_unity_tool
from services.tools import get_unity_instance_from_context from services.tools import get_unity_instance_from_context
from transport.unity_transport import send_with_unity_instance from transport.unity_transport import send_with_unity_instance
from transport.legacy.unity_connection import async_send_command_with_retry from transport.legacy.unity_connection import async_send_command_with_retry
from services.tools.utils import coerce_bool from services.tools.utils import coerce_bool, parse_json_payload
@mcp_for_unity_tool( @mcp_for_unity_tool(
@ -14,7 +15,7 @@ from services.tools.utils import coerce_bool
) )
async def manage_gameobject( async def manage_gameobject(
ctx: Context, ctx: Context,
action: Annotated[Literal["create", "modify", "delete", "find", "add_component", "remove_component", "set_component_property", "get_components", "get_component", "duplicate", "move_relative"], "Perform CRUD operations on GameObjects and components."], action: Annotated[Literal["create", "modify", "delete", "find", "add_component", "remove_component", "set_component_property", "get_components", "get_component", "duplicate", "move_relative"], "Perform CRUD operations on GameObjects and components."] | None = None,
target: Annotated[str, target: Annotated[str,
"GameObject identifier by name or path for modify/delete/component actions"] | None = None, "GameObject identifier by name or path for modify/delete/component actions"] | None = None,
search_method: Annotated[Literal["by_id", "by_name", "by_path", "by_tag", "by_layer", "by_component"], search_method: Annotated[Literal["by_id", "by_name", "by_path", "by_tag", "by_layer", "by_component"],
@ -25,11 +26,11 @@ async def manage_gameobject(
"Tag name - used for both 'create' (initial tag) and 'modify' (change tag)"] | None = None, "Tag name - used for both 'create' (initial tag) and 'modify' (change tag)"] | None = None,
parent: Annotated[str, parent: Annotated[str,
"Parent GameObject reference - used for both 'create' (initial parent) and 'modify' (change parent)"] | None = None, "Parent GameObject reference - used for both 'create' (initial parent) and 'modify' (change parent)"] | None = None,
position: Annotated[list[float] | str, position: Annotated[Union[list[float], str],
"Position - [x,y,z] or string '[x,y,z]' for client compatibility"] | None = None, "Position - [x,y,z] or string '[x,y,z]' for client compatibility"] | None = None,
rotation: Annotated[list[float] | str, rotation: Annotated[Union[list[float], str],
"Rotation - [x,y,z] or string '[x,y,z]' for client compatibility"] | None = None, "Rotation - [x,y,z] or string '[x,y,z]' for client compatibility"] | None = None,
scale: Annotated[list[float] | str, scale: Annotated[Union[list[float], str],
"Scale - [x,y,z] or string '[x,y,z]' for client compatibility"] | None = None, "Scale - [x,y,z] or string '[x,y,z]' for client compatibility"] | None = None,
components_to_add: Annotated[list[str], components_to_add: Annotated[list[str],
"List of component names to add"] | None = None, "List of component names to add"] | None = None,
@ -46,7 +47,7 @@ async def manage_gameobject(
layer: Annotated[str, "Layer name"] | None = None, layer: Annotated[str, "Layer name"] | None = None,
components_to_remove: Annotated[list[str], components_to_remove: Annotated[list[str],
"List of component names to remove"] | None = None, "List of component names to remove"] | None = None,
component_properties: Annotated[dict[str, dict[str, Any]] | str, component_properties: Annotated[Union[dict[str, dict[str, Any]], str],
"""Dictionary of component names to their properties to set. For example: """Dictionary of component names to their properties to set. For example:
`{"MyScript": {"otherObject": {"find": "Player", "method": "by_name"}}}` assigns GameObject `{"MyScript": {"otherObject": {"find": "Player", "method": "by_name"}}}` assigns GameObject
`{"MyScript": {"playerHealth": {"find": "Player", "component": "HealthComponent"}}}` assigns Component `{"MyScript": {"playerHealth": {"find": "Player", "component": "HealthComponent"}}}` assigns Component
@ -70,7 +71,7 @@ async def manage_gameobject(
# --- Parameters for 'duplicate' --- # --- Parameters for 'duplicate' ---
new_name: Annotated[str, new_name: Annotated[str,
"New name for the duplicated object (default: SourceName_Copy)"] | None = None, "New name for the duplicated object (default: SourceName_Copy)"] | None = None,
offset: Annotated[list[float] | str, offset: Annotated[Union[list[float], str],
"Offset from original/reference position - [x,y,z] or string '[x,y,z]'"] | None = None, "Offset from original/reference position - [x,y,z] or string '[x,y,z]'"] | None = None,
# --- Parameters for 'move_relative' --- # --- Parameters for 'move_relative' ---
reference_object: Annotated[str, reference_object: Annotated[str,
@ -86,22 +87,33 @@ async def manage_gameobject(
# Removed session_state import # Removed session_state import
unity_instance = get_unity_instance_from_context(ctx) unity_instance = get_unity_instance_from_context(ctx)
if action is None:
return {
"success": False,
"message": "Missing required parameter 'action'. Valid actions: create, modify, delete, find, add_component, remove_component, set_component_property, get_components, get_component, duplicate, move_relative"
}
# Coercers to tolerate stringified booleans and vectors # Coercers to tolerate stringified booleans and vectors
def _coerce_vec(value, default=None): def _coerce_vec(value, default=None):
if value is None: if value is None:
return default return default
import math
# First try to parse if it's a string
val = parse_json_payload(value)
def _to_vec3(parts): def _to_vec3(parts):
try: try:
vec = [float(parts[0]), float(parts[1]), float(parts[2])] vec = [float(parts[0]), float(parts[1]), float(parts[2])]
except (ValueError, TypeError): except (ValueError, TypeError):
return default return default
return vec if all(math.isfinite(n) for n in vec) else default return vec if all(math.isfinite(n) for n in vec) else default
if isinstance(value, list) and len(value) == 3:
return _to_vec3(value) if isinstance(val, list) and len(val) == 3:
if isinstance(value, str): return _to_vec3(val)
s = value.strip()
# Handle legacy comma-separated strings "1,2,3" that parse_json_payload doesn't handle (since they aren't JSON arrays)
if isinstance(val, str):
s = val.strip()
# minimal tolerant parse for "[x,y,z]" or "x,y,z" # minimal tolerant parse for "[x,y,z]" or "x,y,z"
if s.startswith("[") and s.endswith("]"): if s.startswith("[") and s.endswith("]"):
s = s[1:-1] s = s[1:-1]
@ -125,16 +137,12 @@ async def manage_gameobject(
world_space = coerce_bool(world_space, default=True) world_space = coerce_bool(world_space, default=True)
# Coerce 'component_properties' from JSON string to dict for client compatibility # Coerce 'component_properties' from JSON string to dict for client compatibility
if isinstance(component_properties, str): component_properties = parse_json_payload(component_properties)
try:
component_properties = json.loads(component_properties)
await ctx.info(
"manage_gameobject: coerced component_properties from JSON string to dict")
except json.JSONDecodeError as e:
return {"success": False, "message": f"Invalid JSON in component_properties: {e}"}
# Ensure final type is a dict (object) if provided # Ensure final type is a dict (object) if provided
if component_properties is not None and not isinstance(component_properties, dict): if component_properties is not None and not isinstance(component_properties, dict):
return {"success": False, "message": "component_properties must be a JSON object (dict)."} return {"success": False, "message": "component_properties must be a JSON object (dict)."}
try: try:
# Map tag to search_term when search_method is by_tag for backward compatibility # Map tag to search_term when search_method is by_tag for backward compatibility
if action == "find" and search_method == "by_tag" and tag is not None and search_term is None: if action == "find" and search_method == "by_tag" and tag is not None and search_term is None:
@ -229,4 +237,4 @@ async def manage_gameobject(
return response if isinstance(response, dict) else {"success": False, "message": str(response)} return response if isinstance(response, dict) else {"success": False, "message": str(response)}
except Exception as e: except Exception as e:
return {"success": False, "message": f"Python error managing GameObject: {str(e)}"} return {"success": False, "message": f"Python error managing GameObject: {e!s}"}

View File

@ -0,0 +1,95 @@
"""
Defines the manage_material tool for interacting with Unity materials.
"""
import json
from typing import Annotated, Any, Literal, Union
from fastmcp import Context
from services.registry import mcp_for_unity_tool
from services.tools import get_unity_instance_from_context
from services.tools.utils import parse_json_payload
from transport.unity_transport import send_with_unity_instance
from transport.legacy.unity_connection import async_send_command_with_retry
@mcp_for_unity_tool(
description="Manages Unity materials (set properties, colors, shaders, etc)."
)
async def manage_material(
ctx: Context,
action: Annotated[Literal[
"ping",
"create",
"set_material_shader_property",
"set_material_color",
"assign_material_to_renderer",
"set_renderer_color",
"get_material_info"
], "Action to perform."],
# Common / Shared
material_path: Annotated[str, "Path to material asset (Assets/...)"] | None = None,
property: Annotated[str, "Shader property name (e.g., _BaseColor, _MainTex)"] | None = None,
# create
shader: Annotated[str, "Shader name (default: Standard)"] | None = None,
properties: Annotated[Union[dict[str, Any], str], "Initial properties to set {name: value}."] | None = None,
# set_material_shader_property
value: Annotated[Union[list, float, int, str, bool, None], "Value to set (color array, float, texture path/instruction)"] | None = None,
# set_material_color / set_renderer_color
color: Annotated[Union[list[float], list[int], str], "Color as [r,g,b] or [r,g,b,a]."] | None = None,
# assign_material_to_renderer / set_renderer_color
target: Annotated[str, "Target GameObject (name, path, or find instruction)"] | None = None,
search_method: Annotated[Literal["by_name", "by_path", "by_tag", "by_layer", "by_component"], "Search method for target"] | None = None,
slot: Annotated[int | str, "Material slot index"] | None = None,
mode: Annotated[Literal["shared", "instance", "property_block"], "Assignment/modification mode"] | None = None,
) -> dict[str, Any]:
unity_instance = get_unity_instance_from_context(ctx)
# Parse inputs that might be stringified JSON
color = parse_json_payload(color)
properties = parse_json_payload(properties)
value = parse_json_payload(value)
# Coerce slot to int if it's a string
if slot is not None:
if isinstance(slot, str):
try:
slot = int(slot)
except ValueError:
return {
"success": False,
"message": f"Invalid slot value: '{slot}' must be a valid integer"
}
# Prepare parameters for the C# handler
params_dict = {
"action": action.lower(),
"materialPath": material_path,
"shader": shader,
"properties": properties,
"property": property,
"value": value,
"color": color,
"target": target,
"searchMethod": search_method,
"slot": slot,
"mode": mode
}
# Remove None values
params_dict = {k: v for k, v in params_dict.items() if v is not None}
# Use centralized async retry helper with instance routing
result = await send_with_unity_instance(
async_send_command_with_retry,
unity_instance,
"manage_material",
params_dict,
)
return result if isinstance(result, dict) else {"success": False, "message": str(result)}

View File

@ -16,7 +16,7 @@ from transport.legacy.unity_connection import async_send_command_with_retry
async def read_console( async def read_console(
ctx: Context, ctx: Context,
action: Annotated[Literal['get', 'clear'], action: Annotated[Literal['get', 'clear'],
"Get or clear the Unity Editor console."] | None = None, "Get or clear the Unity Editor console. Defaults to 'get' if omitted."] | None = None,
types: Annotated[list[Literal['error', 'warning', types: Annotated[list[Literal['error', 'warning',
'log', 'all']], "Message types to get"] | None = None, 'log', 'all']], "Message types to get"] | None = None,
count: Annotated[int | str, count: Annotated[int | str,
@ -99,10 +99,17 @@ async def read_console(
if isinstance(resp, dict) and resp.get("success") and not include_stacktrace: if isinstance(resp, dict) and resp.get("success") and not include_stacktrace:
# Strip stacktrace fields from returned lines if present # Strip stacktrace fields from returned lines if present
try: try:
lines = resp.get("data", {}).get("lines", []) data = resp.get("data")
for line in lines: # Handle standard format: {"data": {"lines": [...]}}
if isinstance(line, dict) and "stacktrace" in line: if isinstance(data, dict) and "lines" in data and isinstance(data["lines"], list):
line.pop("stacktrace", None) for line in data["lines"]:
if isinstance(line, dict) and "stacktrace" in line:
line.pop("stacktrace", None)
# Handle legacy/direct list format if any
elif isinstance(data, list):
for line in data:
if isinstance(line, dict) and "stacktrace" in line:
line.pop("stacktrace", None)
except Exception: except Exception:
pass pass
return resp if isinstance(resp, dict) else {"success": False, "message": str(resp)} return resp if isinstance(resp, dict) else {"success": False, "message": str(resp)}

View File

@ -1,12 +1,13 @@
import base64 import base64
import hashlib import hashlib
import re import re
from typing import Annotated, Any from typing import Annotated, Any, Union
from fastmcp import Context from fastmcp import Context
from services.registry import mcp_for_unity_tool from services.registry import mcp_for_unity_tool
from services.tools import get_unity_instance_from_context from services.tools import get_unity_instance_from_context
from services.tools.utils import parse_json_payload
from transport.unity_transport import send_with_unity_instance from transport.unity_transport import send_with_unity_instance
from transport.legacy.unity_connection import async_send_command_with_retry from transport.legacy.unity_connection import async_send_command_with_retry
@ -360,7 +361,7 @@ async def script_apply_edits(
ctx: Context, ctx: Context,
name: Annotated[str, "Name of the script to edit"], name: Annotated[str, "Name of the script to edit"],
path: Annotated[str, "Path to the script to edit under Assets/ directory"], path: Annotated[str, "Path to the script to edit under Assets/ directory"],
edits: Annotated[list[dict[str, Any]], "List of edits to apply to the script"], edits: Annotated[Union[list[dict[str, Any]], str], "List of edits to apply to the script (JSON list or stringified JSON)"],
options: Annotated[dict[str, Any], options: Annotated[dict[str, Any],
"Options for the script edit"] | None = None, "Options for the script edit"] | None = None,
script_type: Annotated[str, script_type: Annotated[str,
@ -371,6 +372,12 @@ async def script_apply_edits(
unity_instance = get_unity_instance_from_context(ctx) unity_instance = get_unity_instance_from_context(ctx)
await ctx.info( await ctx.info(
f"Processing script_apply_edits: {name} (unity_instance={unity_instance or 'default'})") f"Processing script_apply_edits: {name} (unity_instance={unity_instance or 'default'})")
# Parse edits if they came as a stringified JSON
edits = parse_json_payload(edits)
if not isinstance(edits, list):
return {"success": False, "message": f"Edits must be a list or JSON string of a list, got {type(edits)}"}
# Normalize locator first so downstream calls target the correct script file. # Normalize locator first so downstream calls target the correct script file.
name, path = _normalize_script_locator(name, path) name, path = _normalize_script_locator(name, path)
# Normalize unsupported or aliased ops to known structured/text paths # Normalize unsupported or aliased ops to known structured/text paths
@ -895,10 +902,10 @@ async def script_apply_edits(
if isinstance(resp, dict) and resp.get("success"): if isinstance(resp, dict) and resp.get("success"):
pass # Optional sentinel reload removed (deprecated) pass # Optional sentinel reload removed (deprecated)
return _with_norm( return _with_norm(
resp if isinstance(resp, dict) else { resp if isinstance(resp, dict)
"success": False, "message": str(resp)}, else {"success": False, "message": str(resp)},
normalized_for_echo, normalized_for_echo,
routing="text" routing="text",
) )
except Exception as e: except Exception as e:
return _with_norm({"success": False, "code": "conversion_failed", "message": f"Edit conversion failed: {e}"}, normalized_for_echo, routing="text") return _with_norm({"success": False, "code": "conversion_failed", "message": f"Edit conversion failed: {e}"}, normalized_for_echo, routing="text")

View File

@ -2,13 +2,12 @@
from __future__ import annotations from __future__ import annotations
import json
from typing import Any from typing import Any
_TRUTHY = {"true", "1", "yes", "on"} _TRUTHY = {"true", "1", "yes", "on"}
_FALSY = {"false", "0", "no", "off"} _FALSY = {"false", "0", "no", "off"}
def coerce_bool(value: Any, default: bool | None = None) -> bool | None: def coerce_bool(value: Any, default: bool | None = None) -> bool | None:
"""Attempt to coerce a loosely-typed value to a boolean.""" """Attempt to coerce a loosely-typed value to a boolean."""
if value is None: if value is None:
@ -23,3 +22,39 @@ def coerce_bool(value: Any, default: bool | None = None) -> bool | None:
return False return False
return default return default
return bool(value) return bool(value)
def parse_json_payload(value: Any) -> Any:
"""
Attempt to parse a value that might be a JSON string into its native object.
This is a tolerant parser used to handle cases where MCP clients or LLMs
serialize complex objects (lists, dicts) into strings. It also handles
scalar values like numbers, booleans, and null.
Args:
value: The input value (can be str, list, dict, etc.)
Returns:
The parsed JSON object/list if the input was a valid JSON string,
or the original value if parsing failed or wasn't necessary.
"""
if not isinstance(value, str):
return value
val_trimmed = value.strip()
# Fast path: if it doesn't look like JSON structure, return as is
if not (
(val_trimmed.startswith("{") and val_trimmed.endswith("}")) or
(val_trimmed.startswith("[") and val_trimmed.endswith("]")) or
val_trimmed in ("true", "false", "null") or
(val_trimmed.replace(".", "", 1).replace("-", "", 1).isdigit())
):
return value
try:
return json.loads(value)
except (json.JSONDecodeError, ValueError):
# If parsing fails, assume it was meant to be a literal string
return value

View File

@ -33,7 +33,10 @@ class TestManageAssetJsonParsing:
) )
# Verify JSON parsing was logged # Verify JSON parsing was logged
assert "manage_asset: coerced properties from JSON string to dict" in ctx.log_info assert any(
"manage_asset: coerced properties using centralized parser" in msg
for msg in ctx.log_info
)
# Verify the result # Verify the result
assert result["success"] is True assert result["success"] is True
@ -117,12 +120,12 @@ class TestManageGameObjectJsonParsing:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_component_properties_json_string_parsing(self, monkeypatch): async def test_component_properties_json_string_parsing(self, monkeypatch):
"""Test that JSON string component_properties are correctly parsed.""" """Test that JSON string component_properties result in successful operation."""
from services.tools.manage_gameobject import manage_gameobject from services.tools.manage_gameobject import manage_gameobject
ctx = DummyContext() ctx = DummyContext()
async def fake_send(cmd, params, **kwargs): async def fake_send(_cmd, params, **_kwargs):
return {"success": True, "message": "GameObject created successfully"} return {"success": True, "message": "GameObject created successfully"}
monkeypatch.setattr( monkeypatch.setattr(
"services.tools.manage_gameobject.async_send_command_with_retry", "services.tools.manage_gameobject.async_send_command_with_retry",
@ -137,8 +140,31 @@ class TestManageGameObjectJsonParsing:
component_properties='{"MeshRenderer": {"material": "Assets/Materials/BlueMaterial.mat"}}' component_properties='{"MeshRenderer": {"material": "Assets/Materials/BlueMaterial.mat"}}'
) )
# Verify JSON parsing was logged
assert "manage_gameobject: coerced component_properties from JSON string to dict" in ctx.log_info
# Verify the result # Verify the result
assert result["success"] is True assert result["success"] is True
@pytest.mark.asyncio
async def test_component_properties_parsing_verification(self, monkeypatch):
"""Test that component_properties are actually parsed to dict before sending."""
from services.tools.manage_gameobject import manage_gameobject
ctx = DummyContext()
captured_params = {}
async def fake_send(_cmd, params, **_kwargs):
captured_params.update(params)
return {"success": True, "message": "GameObject created successfully"}
monkeypatch.setattr(
"services.tools.manage_gameobject.async_send_command_with_retry",
fake_send,
)
await manage_gameobject(
ctx=ctx,
action="create",
name="TestObject",
component_properties='{"MeshRenderer": {"material": "Assets/Materials/BlueMaterial.mat"}}'
)
assert isinstance(captured_params.get("componentProperties"), dict)

View File

@ -641,7 +641,7 @@ wheels = [
[[package]] [[package]]
name = "keyring" name = "keyring"
version = "25.6.0" version = "25.7.0"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "importlib-metadata", marker = "python_full_version < '3.12'" }, { name = "importlib-metadata", marker = "python_full_version < '3.12'" },
@ -652,9 +652,9 @@ dependencies = [
{ name = "pywin32-ctypes", marker = "sys_platform == 'win32'" }, { name = "pywin32-ctypes", marker = "sys_platform == 'win32'" },
{ name = "secretstorage", marker = "sys_platform == 'linux'" }, { name = "secretstorage", marker = "sys_platform == 'linux'" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/70/09/d904a6e96f76ff214be59e7aa6ef7190008f52a0ab6689760a98de0bf37d/keyring-25.6.0.tar.gz", hash = "sha256:0b39998aa941431eb3d9b0d4b2460bc773b9df6fed7621c2dfb291a7e0187a66", size = 62750, upload-time = "2024-12-25T15:26:45.782Z" } sdist = { url = "https://files.pythonhosted.org/packages/43/4b/674af6ef2f97d56f0ab5153bf0bfa28ccb6c3ed4d1babf4305449668807b/keyring-25.7.0.tar.gz", hash = "sha256:fe01bd85eb3f8fb3dd0405defdeac9a5b4f6f0439edbb3149577f244a2e8245b", size = 63516, upload-time = "2025-11-16T16:26:09.482Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/d3/32/da7f44bcb1105d3e88a0b74ebdca50c59121d2ddf71c9e34ba47df7f3a56/keyring-25.6.0-py3-none-any.whl", hash = "sha256:552a3f7af126ece7ed5c89753650eec89c7eaae8617d0aa4d9ad2b75111266bd", size = 39085, upload-time = "2024-12-25T15:26:44.377Z" }, { url = "https://files.pythonhosted.org/packages/81/db/e655086b7f3a705df045bf0933bdd9c2f79bb3c97bfef1384598bb79a217/keyring-25.7.0-py3-none-any.whl", hash = "sha256:be4a0b195f149690c166e850609a477c532ddbfbaed96a404d4e43f8d5e2689f", size = 39160, upload-time = "2025-11-16T16:26:08.402Z" },
] ]
[[package]] [[package]]
@ -694,7 +694,7 @@ wheels = [
[[package]] [[package]]
name = "mcpforunityserver" name = "mcpforunityserver"
version = "8.1.4" version = "8.1.6"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "fastapi" }, { name = "fastapi" },
@ -1361,15 +1361,15 @@ wheels = [
[[package]] [[package]]
name = "secretstorage" name = "secretstorage"
version = "3.4.0" version = "3.5.0"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "cryptography" }, { name = "cryptography" },
{ name = "jeepney" }, { name = "jeepney" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/31/9f/11ef35cf1027c1339552ea7bfe6aaa74a8516d8b5caf6e7d338daf54fd80/secretstorage-3.4.0.tar.gz", hash = "sha256:c46e216d6815aff8a8a18706a2fbfd8d53fcbb0dce99301881687a1b0289ef7c", size = 19748, upload-time = "2025-09-09T16:42:13.859Z" } sdist = { url = "https://files.pythonhosted.org/packages/1c/03/e834bcd866f2f8a49a85eaff47340affa3bfa391ee9912a952a1faa68c7b/secretstorage-3.5.0.tar.gz", hash = "sha256:f04b8e4689cbce351744d5537bf6b1329c6fc68f91fa666f60a380edddcd11be", size = 19884, upload-time = "2025-11-23T19:02:53.191Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/91/ff/2e2eed29e02c14a5cb6c57f09b2d5b40e65d6cc71f45b52e0be295ccbc2f/secretstorage-3.4.0-py3-none-any.whl", hash = "sha256:0e3b6265c2c63509fb7415717607e4b2c9ab767b7f344a57473b779ca13bd02e", size = 15272, upload-time = "2025-09-09T16:42:12.744Z" }, { url = "https://files.pythonhosted.org/packages/b7/46/f5af3402b579fd5e11573ce652019a67074317e18c1935cc0b4ba9b35552/secretstorage-3.5.0-py3-none-any.whl", hash = "sha256:0ce65888c0725fcb2c5bc0fdb8e5438eece02c523557ea40ce0703c266248137", size = 15554, upload-time = "2025-11-23T19:02:51.545Z" },
] ]
[[package]] [[package]]

View File

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

View File

@ -0,0 +1,150 @@
using System;
using Newtonsoft.Json.Linq;
using NUnit.Framework;
using UnityEditor;
using UnityEngine;
using MCPForUnity.Editor.Tools;
namespace MCPForUnityTests.Editor.Tools
{
public class ManageMaterialPropertiesTests
{
private const string TempRoot = "Assets/Temp/ManageMaterialPropertiesTests";
private string _matPath;
[SetUp]
public void SetUp()
{
if (!AssetDatabase.IsValidFolder("Assets/Temp"))
{
AssetDatabase.CreateFolder("Assets", "Temp");
}
if (!AssetDatabase.IsValidFolder(TempRoot))
{
AssetDatabase.CreateFolder("Assets/Temp", "ManageMaterialPropertiesTests");
}
_matPath = $"{TempRoot}/PropTest.mat";
}
[TearDown]
public void TearDown()
{
if (AssetDatabase.IsValidFolder(TempRoot))
{
AssetDatabase.DeleteAsset(TempRoot);
}
}
private static JObject ToJObject(object result)
{
return result as JObject ?? JObject.FromObject(result);
}
[Test]
public void CreateMaterial_WithValidJsonStringArray_SetsProperty()
{
string jsonProps = "{\"_Color\": [1.0, 0.0, 0.0, 1.0]}";
var paramsObj = new JObject
{
["action"] = "create",
["materialPath"] = _matPath,
["shader"] = "Standard",
["properties"] = jsonProps
};
var result = ToJObject(ManageMaterial.HandleCommand(paramsObj));
Assert.AreEqual("success", result.Value<string>("status"), result.ToString());
var mat = AssetDatabase.LoadAssetAtPath<Material>(_matPath);
Assert.AreEqual(Color.red, mat.color);
}
[Test]
public void CreateMaterial_WithJObjectArray_SetsProperty()
{
var props = new JObject();
props["_Color"] = new JArray(0.0f, 1.0f, 0.0f, 1.0f);
var paramsObj = new JObject
{
["action"] = "create",
["materialPath"] = _matPath,
["shader"] = "Standard",
["properties"] = props
};
var result = ToJObject(ManageMaterial.HandleCommand(paramsObj));
Assert.AreEqual("success", result.Value<string>("status"), result.ToString());
var mat = AssetDatabase.LoadAssetAtPath<Material>(_matPath);
Assert.AreEqual(Color.green, mat.color);
}
[Test]
public void CreateMaterial_WithEmptyProperties_Succeeds()
{
var paramsObj = new JObject
{
["action"] = "create",
["materialPath"] = _matPath,
["shader"] = "Standard",
["properties"] = new JObject()
};
var result = ToJObject(ManageMaterial.HandleCommand(paramsObj));
Assert.AreEqual("success", result.Value<string>("status"));
}
[Test]
public void CreateMaterial_WithInvalidJsonSyntax_ReturnsDetailedError()
{
// Missing closing brace
string invalidJson = "{\"_Color\": [1,0,0,1]";
var paramsObj = new JObject
{
["action"] = "create",
["materialPath"] = _matPath,
["shader"] = "Standard",
["properties"] = invalidJson
};
var result = ToJObject(ManageMaterial.HandleCommand(paramsObj));
Assert.AreEqual("error", result.Value<string>("status"));
string msg = result.Value<string>("message");
// Verify we get exception details
Assert.IsTrue(msg.Contains("Invalid JSON"), "Should mention Invalid JSON");
// Verify the message contains more than just the prefix (has exception details)
Assert.IsTrue(msg.Length > "Invalid JSON".Length,
$"Message should contain exception details. Got: {msg}");
}
[Test]
public void CreateMaterial_WithNullProperty_HandlesGracefully()
{
var props = new JObject();
props["_Color"] = null;
var paramsObj = new JObject
{
["action"] = "create",
["materialPath"] = _matPath,
["shader"] = "Standard",
["properties"] = props
};
// Should probably succeed but warn or ignore, or fail gracefully
var result = ToJObject(ManageMaterial.HandleCommand(paramsObj));
// We accept either success (ignored) or specific error, but not crash
// Assert.AreNotEqual("internal_error", result.Value<string>("status"));
var status = result.Value<string>("status");
Assert.IsTrue(status == "success" || status == "error", $"Status should be success or error, got {status}");
}
}
}

View File

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

View File

@ -0,0 +1,73 @@
using System;
using Newtonsoft.Json.Linq;
using NUnit.Framework;
using UnityEditor;
using UnityEngine;
using MCPForUnity.Editor.Tools;
namespace MCPForUnityTests.Editor.Tools
{
public class ManageMaterialReproTests
{
private const string TempRoot = "Assets/Temp/ManageMaterialReproTests";
private string _matPath;
[SetUp]
public void SetUp()
{
if (!AssetDatabase.IsValidFolder("Assets/Temp"))
{
AssetDatabase.CreateFolder("Assets", "Temp");
}
if (!AssetDatabase.IsValidFolder(TempRoot))
{
AssetDatabase.CreateFolder("Assets/Temp", "ManageMaterialReproTests");
}
string guid = Guid.NewGuid().ToString("N");
_matPath = $"{TempRoot}/ReproMat_{guid}.mat";
}
[TearDown]
public void TearDown()
{
if (AssetDatabase.IsValidFolder(TempRoot))
{
AssetDatabase.DeleteAsset(TempRoot);
}
}
private static JObject ToJObject(object result)
{
return result as JObject ?? JObject.FromObject(result);
}
[Test]
public void CreateMaterial_WithInvalidJsonString_ReturnsGenericError()
{
// Arrange
// Malformed JSON string (missing closing brace)
string invalidJson = "{\"_Color\": [1,0,0,1]";
var paramsObj = new JObject
{
["action"] = "create",
["materialPath"] = _matPath,
["shader"] = "Standard",
["properties"] = invalidJson
};
// Act
var result = ToJObject(ManageMaterial.HandleCommand(paramsObj));
// Assert
Assert.AreEqual("error", result.Value<string>("status"));
// We expect more detailed error message after fix
var message = result.Value<string>("message");
Assert.IsTrue(message.StartsWith("Invalid JSON in properties"), "Message should start with prefix");
Assert.AreNotEqual("Invalid JSON in properties", message, "Message should contain exception details");
}
}
}

View File

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

View File

@ -0,0 +1,194 @@
using System;
using System.IO;
using Newtonsoft.Json.Linq;
using NUnit.Framework;
using UnityEditor;
using UnityEngine;
using MCPForUnity.Editor.Tools;
using MCPForUnity.Editor.Helpers;
namespace MCPForUnityTests.Editor.Tools
{
public class ManageMaterialStressTests
{
private const string TempRoot = "Assets/Temp/ManageMaterialStressTests";
private string _matPath;
private GameObject _cube;
[SetUp]
public void SetUp()
{
if (!AssetDatabase.IsValidFolder("Assets/Temp"))
{
AssetDatabase.CreateFolder("Assets", "Temp");
}
if (!AssetDatabase.IsValidFolder(TempRoot))
{
AssetDatabase.CreateFolder("Assets/Temp", "ManageMaterialStressTests");
}
string guid = Guid.NewGuid().ToString("N");
_matPath = $"{TempRoot}/StressMat_{guid}.mat";
var material = new Material(Shader.Find("Universal Render Pipeline/Lit") ?? Shader.Find("Standard"));
material.color = Color.white;
AssetDatabase.CreateAsset(material, _matPath);
AssetDatabase.SaveAssets();
_cube = GameObject.CreatePrimitive(PrimitiveType.Cube);
_cube.name = "StressCube";
}
[TearDown]
public void TearDown()
{
if (_cube != null)
{
UnityEngine.Object.DestroyImmediate(_cube);
}
if (AssetDatabase.IsValidFolder(TempRoot))
{
AssetDatabase.DeleteAsset(TempRoot);
}
// Clean up parent Temp folder if it's empty
if (AssetDatabase.IsValidFolder("Assets/Temp"))
{
var remainingDirs = Directory.GetDirectories("Assets/Temp");
var remainingFiles = Directory.GetFiles("Assets/Temp");
if (remainingDirs.Length == 0 && remainingFiles.Length == 0)
{
AssetDatabase.DeleteAsset("Assets/Temp");
}
}
}
private static JObject ToJObject(object result)
{
return result as JObject ?? JObject.FromObject(result);
}
[Test]
public void HandleInvalidInputs_ReturnsError_NotException()
{
// 1. Bad path
var paramsBadPath = new JObject
{
["action"] = "set_material_color",
["materialPath"] = "Assets/NonExistent/Ghost.mat",
["color"] = new JArray(1f, 0f, 0f, 1f)
};
var resultBadPath = ToJObject(ManageMaterial.HandleCommand(paramsBadPath));
Assert.AreEqual("error", resultBadPath.Value<string>("status"));
StringAssert.Contains("Could not find material", resultBadPath.Value<string>("message"));
// 2. Bad color array (too short)
var paramsBadColor = new JObject
{
["action"] = "set_material_color",
["materialPath"] = _matPath,
["color"] = new JArray(1f) // Invalid
};
var resultBadColor = ToJObject(ManageMaterial.HandleCommand(paramsBadColor));
Assert.AreEqual("error", resultBadColor.Value<string>("status"));
StringAssert.Contains("Invalid color format", resultBadColor.Value<string>("message"));
// 3. Bad slot index
// Assign material first
var renderer = _cube.GetComponent<Renderer>();
renderer.sharedMaterial = AssetDatabase.LoadAssetAtPath<Material>(_matPath);
var paramsBadSlot = new JObject
{
["action"] = "assign_material_to_renderer",
["target"] = "StressCube",
["searchMethod"] = "by_name",
["materialPath"] = _matPath,
["slot"] = 99
};
var resultBadSlot = ToJObject(ManageMaterial.HandleCommand(paramsBadSlot));
Assert.AreEqual("error", resultBadSlot.Value<string>("status"));
StringAssert.Contains("out of bounds", resultBadSlot.Value<string>("message"));
}
[Test]
public void StateIsolation_PropertyBlockDoesNotLeakToSharedMaterial()
{
// Arrange
var renderer = _cube.GetComponent<Renderer>();
var sharedMat = AssetDatabase.LoadAssetAtPath<Material>(_matPath);
renderer.sharedMaterial = sharedMat;
// Initial color
var initialColor = Color.white;
if (sharedMat.HasProperty("_BaseColor")) sharedMat.SetColor("_BaseColor", initialColor);
else if (sharedMat.HasProperty("_Color")) sharedMat.SetColor("_Color", initialColor);
// Act - Set Property Block Color
var blockColor = Color.red;
var paramsObj = new JObject
{
["action"] = "set_renderer_color",
["target"] = "StressCube",
["searchMethod"] = "by_name",
["color"] = new JArray(blockColor.r, blockColor.g, blockColor.b, blockColor.a),
["mode"] = "property_block"
};
var result = ToJObject(ManageMaterial.HandleCommand(paramsObj));
Assert.AreEqual("success", result.Value<string>("status"));
// Assert
// 1. Renderer has property block with Red
var block = new MaterialPropertyBlock();
renderer.GetPropertyBlock(block, 0);
var propName = sharedMat.HasProperty("_BaseColor") ? "_BaseColor" : "_Color";
Assert.AreEqual(blockColor, block.GetColor(propName));
// 2. Shared material remains White
var sharedColor = sharedMat.GetColor(propName);
Assert.AreEqual(initialColor, sharedColor, "Shared material color should NOT change when using PropertyBlock");
}
[Test]
public void Integration_PureManageMaterial_AssignsMaterialAndModifies()
{
// This simulates a workflow where we create a GO, assign a mat, then tweak it.
// 1. Create GO (already done in Setup, but let's verify)
Assert.IsNotNull(_cube);
// 2. Assign Material using ManageMaterial
var assignParams = new JObject
{
["action"] = "assign_material_to_renderer",
["target"] = "StressCube",
["searchMethod"] = "by_name",
["materialPath"] = _matPath
};
var assignResult = ToJObject(ManageMaterial.HandleCommand(assignParams));
Assert.AreEqual("success", assignResult.Value<string>("status"));
// Verify assignment
var renderer = _cube.GetComponent<Renderer>();
Assert.AreEqual(Path.GetFileNameWithoutExtension(_matPath), renderer.sharedMaterial.name);
// 3. Modify Shared Material Color using ManageMaterial
var newColor = Color.blue;
var colorParams = new JObject
{
["action"] = "set_material_color",
["materialPath"] = _matPath,
["color"] = new JArray(newColor.r, newColor.g, newColor.b, newColor.a)
};
var colorResult = ToJObject(ManageMaterial.HandleCommand(colorParams));
Assert.AreEqual("success", colorResult.Value<string>("status"));
// Verify color changed on renderer (because it's shared)
var propName = renderer.sharedMaterial.HasProperty("_BaseColor") ? "_BaseColor" : "_Color";
Assert.AreEqual(newColor, renderer.sharedMaterial.GetColor(propName));
}
}
}

View File

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

View File

@ -0,0 +1,234 @@
using System;
using System.IO;
using Newtonsoft.Json.Linq;
using NUnit.Framework;
using UnityEditor;
using UnityEngine;
using MCPForUnity.Editor.Tools;
using MCPForUnity.Editor.Helpers;
namespace MCPForUnityTests.Editor.Tools
{
public class ManageMaterialTests
{
private const string TempRoot = "Assets/Temp/ManageMaterialTests";
private string _matPath;
[SetUp]
public void SetUp()
{
if (!AssetDatabase.IsValidFolder("Assets/Temp"))
{
AssetDatabase.CreateFolder("Assets", "Temp");
}
if (!AssetDatabase.IsValidFolder(TempRoot))
{
AssetDatabase.CreateFolder("Assets/Temp", "ManageMaterialTests");
}
string guid = Guid.NewGuid().ToString("N");
_matPath = $"{TempRoot}/TestMat_{guid}.mat";
// Create a basic material
var material = new Material(Shader.Find("Universal Render Pipeline/Lit") ?? Shader.Find("Standard"));
AssetDatabase.CreateAsset(material, _matPath);
AssetDatabase.SaveAssets();
}
[TearDown]
public void TearDown()
{
if (AssetDatabase.IsValidFolder(TempRoot))
{
AssetDatabase.DeleteAsset(TempRoot);
}
// Clean up parent Temp folder if it's empty
if (AssetDatabase.IsValidFolder("Assets/Temp"))
{
// Only delete if empty
var subFolders = AssetDatabase.GetSubFolders("Assets/Temp");
if (subFolders.Length == 0)
{
AssetDatabase.DeleteAsset("Assets/Temp");
}
}
}
private static JObject ToJObject(object result)
{
return result as JObject ?? JObject.FromObject(result);
}
[Test]
public void SetMaterialShaderProperty_SetsColor()
{
// Arrange
var color = new Color(1f, 1f, 0f, 1f); // Yellow
var paramsObj = new JObject
{
["action"] = "set_material_shader_property",
["materialPath"] = _matPath,
["property"] = "_BaseColor", // URP
["value"] = new JArray(color.r, color.g, color.b, color.a)
};
// Check if using Standard shader (fallback)
var mat = AssetDatabase.LoadAssetAtPath<Material>(_matPath);
if (mat.shader.name == "Standard")
{
paramsObj["property"] = "_Color";
}
// Act
var result = ToJObject(ManageMaterial.HandleCommand(paramsObj));
// Assert
Assert.AreEqual("success", result.Value<string>("status"), result.ToString());
mat = AssetDatabase.LoadAssetAtPath<Material>(_matPath); // Reload
var prop = mat.shader.name == "Standard" ? "_Color" : "_BaseColor";
Assert.IsTrue(mat.HasProperty(prop), $"Material should have property {prop}");
Assert.AreEqual(color, mat.GetColor(prop));
}
[Test]
public void SetMaterialColor_SetsColorWithFallback()
{
// Arrange
var color = new Color(0f, 1f, 0f, 1f); // Green
var paramsObj = new JObject
{
["action"] = "set_material_color",
["materialPath"] = _matPath,
["color"] = new JArray(color.r, color.g, color.b, color.a)
};
// Act
var result = ToJObject(ManageMaterial.HandleCommand(paramsObj));
// Assert
Assert.AreEqual("success", result.Value<string>("status"), result.ToString());
var mat = AssetDatabase.LoadAssetAtPath<Material>(_matPath);
var prop = mat.HasProperty("_BaseColor") ? "_BaseColor" : "_Color";
Assert.IsTrue(mat.HasProperty(prop), $"Material should have property {prop}");
Assert.AreEqual(color, mat.GetColor(prop));
}
[Test]
public void AssignMaterialToRenderer_Works()
{
// Arrange
var go = GameObject.CreatePrimitive(PrimitiveType.Cube);
go.name = "AssignTestCube";
try
{
var paramsObj = new JObject
{
["action"] = "assign_material_to_renderer",
["target"] = "AssignTestCube",
["searchMethod"] = "by_name",
["materialPath"] = _matPath,
["slot"] = 0
};
// Act
var result = ToJObject(ManageMaterial.HandleCommand(paramsObj));
// Assert
Assert.AreEqual("success", result.Value<string>("status"), result.ToString());
var renderer = go.GetComponent<Renderer>();
Assert.IsNotNull(renderer.sharedMaterial);
// Compare names because objects might be different instances (loaded vs scene)
var matName = Path.GetFileNameWithoutExtension(_matPath);
Assert.AreEqual(matName, renderer.sharedMaterial.name);
}
finally
{
UnityEngine.Object.DestroyImmediate(go);
}
}
[Test]
public void SetRendererColor_PropertyBlock_Works()
{
// Arrange
var go = GameObject.CreatePrimitive(PrimitiveType.Cube);
go.name = "BlockTestCube";
// Assign the material first so we have something valid
var mat = AssetDatabase.LoadAssetAtPath<Material>(_matPath);
go.GetComponent<Renderer>().sharedMaterial = mat;
try
{
var color = new Color(1f, 0f, 0f, 1f); // Red
var paramsObj = new JObject
{
["action"] = "set_renderer_color",
["target"] = "BlockTestCube",
["searchMethod"] = "by_name",
["color"] = new JArray(color.r, color.g, color.b, color.a),
["mode"] = "property_block"
};
// Act
var result = ToJObject(ManageMaterial.HandleCommand(paramsObj));
// Assert
Assert.AreEqual("success", result.Value<string>("status"), result.ToString());
var renderer = go.GetComponent<Renderer>();
var block = new MaterialPropertyBlock();
renderer.GetPropertyBlock(block, 0);
var prop = mat.HasProperty("_BaseColor") ? "_BaseColor" : "_Color";
Assert.AreEqual(color, block.GetColor(prop));
// Verify material asset didn't change (it was originally white/gray from setup?)
// We didn't check original color, but property block shouldn't affect shared material
// We can check that sharedMaterial color is NOT red if we set it to something else first
// But assuming test isolation, we can just verify the block is set.
}
finally
{
UnityEngine.Object.DestroyImmediate(go);
}
}
[Test]
public void GetMaterialInfo_ReturnsProperties()
{
// Arrange
var paramsObj = new JObject
{
["action"] = "get_material_info",
["materialPath"] = _matPath
};
// Act
var result = ToJObject(ManageMaterial.HandleCommand(paramsObj));
// Assert
Assert.AreEqual("success", result.Value<string>("status"), result.ToString());
Assert.IsNotNull(result["properties"]);
Assert.IsInstanceOf<JArray>(result["properties"]);
var props = result["properties"] as JArray;
Assert.IsTrue(props.Count > 0);
// Check for standard properties
bool foundColor = false;
foreach(var p in props)
{
var name = p["name"]?.ToString();
if (name == "_Color" || name == "_BaseColor") foundColor = true;
}
Assert.IsTrue(foundColor, "Should find color property");
}
}
}

View File

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

View File

@ -115,7 +115,7 @@ namespace MCPForUnityTests.Editor.Tools
} }
[Test] [Test]
public void AssignMaterial_ToSphere_UsingComponentPropertiesObject_Succeeds() public void AssignMaterial_ToSphere_UsingManageMaterial_Succeeds()
{ {
// Ensure material exists first // Ensure material exists first
CreateMaterial_WithObjectProperties_SucceedsAndSetsColor(); CreateMaterial_WithObjectProperties_SucceedsAndSetsColor();
@ -133,23 +133,18 @@ namespace MCPForUnityTests.Editor.Tools
_sphere = GameObject.Find("ToolTestSphere"); _sphere = GameObject.Find("ToolTestSphere");
Assert.IsNotNull(_sphere, "Sphere should be created."); Assert.IsNotNull(_sphere, "Sphere should be created.");
// Assign material via object-typed componentProperties // Assign material via ManageMaterial tool
var modifyParams = new JObject var assignParams = new JObject
{ {
["action"] = "modify", ["action"] = "assign_material_to_renderer",
["target"] = "ToolTestSphere", ["target"] = "ToolTestSphere",
["searchMethod"] = "by_name", ["searchMethod"] = "by_name",
["componentProperties"] = new JObject ["materialPath"] = _matPath,
{ ["slot"] = 0
["MeshRenderer"] = new JObject
{
["sharedMaterial"] = _matPath
}
}
}; };
var modifyResult = ToJObject(ManageGameObject.HandleCommand(modifyParams)); var assignResult = ToJObject(ManageMaterial.HandleCommand(assignParams));
Assert.IsTrue(modifyResult.Value<bool>("success"), modifyResult.Value<string>("error")); Assert.AreEqual("success", assignResult.Value<string>("status"), assignResult.ToString());
var renderer = _sphere.GetComponent<MeshRenderer>(); var renderer = _sphere.GetComponent<MeshRenderer>();
Assert.IsNotNull(renderer, "Sphere should have MeshRenderer."); Assert.IsNotNull(renderer, "Sphere should have MeshRenderer.");
@ -161,7 +156,7 @@ namespace MCPForUnityTests.Editor.Tools
public void ReadRendererData_DoesNotInstantiateMaterial_AndIncludesSharedMaterial() public void ReadRendererData_DoesNotInstantiateMaterial_AndIncludesSharedMaterial()
{ {
// Prepare object and assignment // Prepare object and assignment
AssignMaterial_ToSphere_UsingComponentPropertiesObject_Succeeds(); AssignMaterial_ToSphere_UsingManageMaterial_Succeeds();
var renderer = _sphere.GetComponent<MeshRenderer>(); var renderer = _sphere.GetComponent<MeshRenderer>();
int beforeId = renderer.sharedMaterial != null ? renderer.sharedMaterial.GetInstanceID() : 0; int beforeId = renderer.sharedMaterial != null ? renderer.sharedMaterial.GetInstanceID() : 0;

View File

@ -0,0 +1,91 @@
using System;
using System.Collections.Generic;
using Newtonsoft.Json.Linq;
using NUnit.Framework;
using UnityEditor;
using UnityEngine;
using MCPForUnity.Editor.Tools;
using MCPForUnity.Editor.Helpers;
namespace MCPForUnityTests.Editor.Tools
{
public class ReadConsoleTests
{
[Test]
public void HandleCommand_Clear_Works()
{
// Arrange
// Ensure there's something to clear
Debug.Log("Log to clear");
// Verify content exists before clear
var getBefore = ToJObject(ReadConsole.HandleCommand(new JObject { ["action"] = "get", ["count"] = 10 }));
Assert.IsTrue(getBefore.Value<bool>("success"), getBefore.ToString());
var entriesBefore = getBefore["data"] as JArray;
// Ideally we'd assert count > 0, but other tests/system logs might affect this.
// Just ensuring the call doesn't fail is a baseline, but let's try to be stricter if possible.
// Since we just logged, there should be at least one entry.
Assert.IsTrue(entriesBefore != null && entriesBefore.Count > 0, "Setup failed: console should have logs.");
// Act
var result = ToJObject(ReadConsole.HandleCommand(new JObject { ["action"] = "clear" }));
// Assert
Assert.IsTrue(result.Value<bool>("success"), result.ToString());
// Verify clear effect
var getAfter = ToJObject(ReadConsole.HandleCommand(new JObject { ["action"] = "get", ["count"] = 10 }));
Assert.IsTrue(getAfter.Value<bool>("success"), getAfter.ToString());
var entriesAfter = getAfter["data"] as JArray;
Assert.IsTrue(entriesAfter == null || entriesAfter.Count == 0, "Console should be empty after clear.");
}
[Test]
public void HandleCommand_Get_Works()
{
// Arrange
string uniqueMessage = $"Test Log Message {Guid.NewGuid()}";
Debug.Log(uniqueMessage);
var paramsObj = new JObject
{
["action"] = "get",
["count"] = 1000 // Fetch enough to likely catch our message
};
// Act
var result = ToJObject(ReadConsole.HandleCommand(paramsObj));
// Assert
Assert.IsTrue(result.Value<bool>("success"), result.ToString());
var data = result["data"] as JArray;
Assert.IsNotNull(data, "Data array should not be null.");
Assert.IsTrue(data.Count > 0, "Should retrieve at least one log entry.");
// Verify content
bool found = false;
foreach (var entry in data)
{
if (entry["message"]?.ToString().Contains(uniqueMessage) == true)
{
found = true;
break;
}
}
Assert.IsTrue(found, $"The unique log message '{uniqueMessage}' was not found in retrieved logs.");
}
private static JObject ToJObject(object result)
{
if (result == null)
{
Assert.Fail("ReadConsole.HandleCommand returned null.");
return new JObject(); // Unreachable, but satisfies return type.
}
return result as JObject ?? JObject.FromObject(result);
}
}
}

View File

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