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)
{
ApplyMaterialProperties(mat, propertiesForApply);
MaterialOps.ApplyProperties(mat, propertiesForApply, ManageGameObject.InputSerializer);
}
}
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.
// 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
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>
/// Applies properties from JObject to a PhysicsMaterial.

View File

@ -23,7 +23,7 @@ namespace MCPForUnity.Editor.Tools
public static class ManageGameObject
{
// 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>
{
@ -54,10 +54,21 @@ namespace MCPForUnity.Editor.Tools
// Parameters used by various actions
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();
// --- 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 layer = @params["layer"]?.ToString();
JToken parentToken = @params["parent"];
@ -2112,51 +2123,7 @@ namespace MCPForUnity.Editor.Tools
// Special handling for Material properties (shader properties)
if (currentObject is Material material && finalPart.StartsWith("_"))
{
// Use the serializer to convert the JToken value first
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;
return MaterialOps.TrySetShaderProperty(material, finalPart, value, inputSerializer);
}
// 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_editor`: 控制和查询编辑器的状态和设置。
* `manage_gameobject`: 管理游戏对象:创建、修改、删除、查找和组件操作。
* `manage_material`: 管理材质:创建、设置属性、分配给渲染器以及查询材质信息。
* `manage_prefabs`: 执行预制件操作(创建、修改、删除等)。
* `manage_scene`: 管理场景(加载、保存、创建、获取层次结构等)。
* `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_editor`: Controls and queries the editor's state and settings.
* `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_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.

View File

@ -9,6 +9,7 @@ from typing import Annotated, Any, Literal
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
@ -63,6 +64,13 @@ async def manage_asset(
return raw, None
if isinstance(raw, str):
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)
if parsed is None:
return None, source

View File

@ -1,12 +1,13 @@
import json
from typing import Annotated, Any, Literal
import math
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 transport.unity_transport import send_with_unity_instance
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(
@ -14,7 +15,7 @@ from services.tools.utils import coerce_bool
)
async def manage_gameobject(
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,
"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"],
@ -25,11 +26,11 @@ async def manage_gameobject(
"Tag name - used for both 'create' (initial tag) and 'modify' (change tag)"] | None = None,
parent: Annotated[str,
"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,
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,
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,
components_to_add: Annotated[list[str],
"List of component names to add"] | None = None,
@ -46,7 +47,7 @@ async def manage_gameobject(
layer: Annotated[str, "Layer name"] | None = None,
components_to_remove: Annotated[list[str],
"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:
`{"MyScript": {"otherObject": {"find": "Player", "method": "by_name"}}}` assigns GameObject
`{"MyScript": {"playerHealth": {"find": "Player", "component": "HealthComponent"}}}` assigns Component
@ -70,7 +71,7 @@ async def manage_gameobject(
# --- Parameters for 'duplicate' ---
new_name: Annotated[str,
"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,
# --- Parameters for 'move_relative' ---
reference_object: Annotated[str,
@ -86,11 +87,19 @@ async def manage_gameobject(
# Removed session_state import
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
def _coerce_vec(value, default=None):
if value is None:
return default
import math
# First try to parse if it's a string
val = parse_json_payload(value)
def _to_vec3(parts):
try:
@ -98,10 +107,13 @@ async def manage_gameobject(
except (ValueError, TypeError):
return 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(value, str):
s = value.strip()
if isinstance(val, list) and len(val) == 3:
return _to_vec3(val)
# 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"
if s.startswith("[") and s.endswith("]"):
s = s[1:-1]
@ -125,16 +137,12 @@ async def manage_gameobject(
world_space = coerce_bool(world_space, default=True)
# Coerce 'component_properties' from JSON string to dict for client compatibility
if isinstance(component_properties, str):
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}"}
component_properties = parse_json_payload(component_properties)
# Ensure final type is a dict (object) if provided
if component_properties is not None and not isinstance(component_properties, dict):
return {"success": False, "message": "component_properties must be a JSON object (dict)."}
try:
# Map tag to search_term when search_method is by_tag for backward compatibility
if action == "find" and search_method == "by_tag" and tag is not None and search_term is None:
@ -229,4 +237,4 @@ async def manage_gameobject(
return response if isinstance(response, dict) else {"success": False, "message": str(response)}
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(
ctx: Context,
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',
'log', 'all']], "Message types to get"] | None = None,
count: Annotated[int | str,
@ -99,8 +99,15 @@ async def read_console(
if isinstance(resp, dict) and resp.get("success") and not include_stacktrace:
# Strip stacktrace fields from returned lines if present
try:
lines = resp.get("data", {}).get("lines", [])
for line in lines:
data = resp.get("data")
# Handle standard format: {"data": {"lines": [...]}}
if isinstance(data, dict) and "lines" in data and isinstance(data["lines"], list):
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:

View File

@ -1,12 +1,13 @@
import base64
import hashlib
import re
from typing import Annotated, Any
from typing import Annotated, Any, 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
@ -360,7 +361,7 @@ async def script_apply_edits(
ctx: Context,
name: Annotated[str, "Name of the script to edit"],
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 for the script edit"] | None = None,
script_type: Annotated[str,
@ -371,6 +372,12 @@ async def script_apply_edits(
unity_instance = get_unity_instance_from_context(ctx)
await ctx.info(
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.
name, path = _normalize_script_locator(name, path)
# 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"):
pass # Optional sentinel reload removed (deprecated)
return _with_norm(
resp if isinstance(resp, dict) else {
"success": False, "message": str(resp)},
resp if isinstance(resp, dict)
else {"success": False, "message": str(resp)},
normalized_for_echo,
routing="text"
routing="text",
)
except Exception as e:
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
import json
from typing import Any
_TRUTHY = {"true", "1", "yes", "on"}
_FALSY = {"false", "0", "no", "off"}
def coerce_bool(value: Any, default: bool | None = None) -> bool | None:
"""Attempt to coerce a loosely-typed value to a boolean."""
if value is None:
@ -23,3 +22,39 @@ def coerce_bool(value: Any, default: bool | None = None) -> bool | None:
return False
return default
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
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
assert result["success"] is True
@ -117,12 +120,12 @@ class TestManageGameObjectJsonParsing:
@pytest.mark.asyncio
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
ctx = DummyContext()
async def fake_send(cmd, params, **kwargs):
async def fake_send(_cmd, params, **_kwargs):
return {"success": True, "message": "GameObject created successfully"}
monkeypatch.setattr(
"services.tools.manage_gameobject.async_send_command_with_retry",
@ -137,8 +140,31 @@ class TestManageGameObjectJsonParsing:
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
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]]
name = "keyring"
version = "25.6.0"
version = "25.7.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "importlib-metadata", marker = "python_full_version < '3.12'" },
@ -652,9 +652,9 @@ dependencies = [
{ name = "pywin32-ctypes", marker = "sys_platform == 'win32'" },
{ 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 = [
{ 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]]
@ -694,7 +694,7 @@ wheels = [
[[package]]
name = "mcpforunityserver"
version = "8.1.4"
version = "8.1.6"
source = { editable = "." }
dependencies = [
{ name = "fastapi" },
@ -1361,15 +1361,15 @@ wheels = [
[[package]]
name = "secretstorage"
version = "3.4.0"
version = "3.5.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cryptography" },
{ 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 = [
{ 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]]

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