Material tools: support direct shader property keys + add EditMode coverage (#344)

* Add TDD tests for MCP material management issues

- MCPMaterialTests.cs: Tests for material creation, assignment, and data reading
- MCPParameterHandlingTests.cs: Tests for JSON parameter parsing issues
- SphereMaterialWorkflowTests.cs: Tests for complete sphere material workflow

These tests document the current issues with:
- JSON parameter parsing in manage_asset and manage_gameobject tools
- Material creation with properties
- Material assignment to GameObjects
- Material component data reading

All tests currently fail (Red phase of TDD) and serve as specifications
for what needs to be fixed in the MCP system.

* Refine TDD tests to focus on actual MCP tool parameter parsing issues

- Removed redundant tests that verify working functionality (GameObjectSerializer, Unity APIs)
- Kept focused tests that document the real issue: MCP tool parameter validation
- Tests now clearly identify the root cause: JSON string parsing in MCP tools
- Tests specify exactly what needs to be fixed: parameter type flexibility

The issue is NOT in Unity APIs or serialization (which work fine),
but in MCP tool parameter validation being too strict.

* Fix port discovery protocol mismatch

- Update _try_probe_unity_mcp to recognize Unity bridge welcome message
- Unity bridge sends 'WELCOME UNITY-MCP' instead of JSON pong response
- Maintains backward compatibility with JSON pong format
- Fixes MCP server connection to Unity Editor

* Resolve merge: unify manage_gameobject param coercion and schema widening

* Tests: add MaterialParameterToolTests; merge port probe fix; widen tool schemas for JSON-string params

* feat(material): support direct shader property keys and texture paths; add EditMode tests

* chore(tests): track required .meta files; remove ephemeral Assets/Editor test helper

* fix(manage_gameobject): validate parsed component_properties is a dict; return clear error
main
dsarno 2025-10-23 18:25:29 -07:00 committed by GitHub
parent a0287afbbc
commit 075d68dba3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 357 additions and 59 deletions

View File

@ -64,7 +64,19 @@ namespace MCPForUnity.Editor.Tools
string path = @params["path"]?.ToString();
// Coerce string JSON to JObject for 'properties' if provided as a JSON string
JsonUtil.CoerceJsonStringParameter(@params, "properties");
var propertiesToken = @params["properties"];
if (propertiesToken != null && propertiesToken.Type == JTokenType.String)
{
try
{
var parsed = JObject.Parse(propertiesToken.ToString());
@params["properties"] = parsed;
}
catch (Exception e)
{
Debug.LogWarning($"[ManageAsset] Could not parse 'properties' JSON string: {e.Message}");
}
}
try
{
@ -1002,6 +1014,107 @@ namespace MCPForUnity.Editor.Tools
}
}
// --- 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;
switch (name)
{
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;
default: candidates = new[] { name }; break;
}
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;
}

View File

@ -67,7 +67,19 @@ namespace MCPForUnity.Editor.Tools
// --- End add parameter ---
// Coerce string JSON to JObject for 'componentProperties' if provided as a JSON string
JsonUtil.CoerceJsonStringParameter(@params, "componentProperties");
var componentPropsToken = @params["componentProperties"];
if (componentPropsToken != null && componentPropsToken.Type == JTokenType.String)
{
try
{
var parsed = JObject.Parse(componentPropsToken.ToString());
@params["componentProperties"] = parsed;
}
catch (Exception e)
{
Debug.LogWarning($"[ManageGameObject] Could not parse 'componentProperties' JSON string: {e.Message}");
}
}
// --- Prefab Redirection Check ---
string targetPath =

View File

@ -39,7 +39,7 @@ async def manage_asset(
try:
properties = json.loads(properties)
ctx.info("manage_asset: coerced properties from JSON string to dict")
except json.JSONDecodeError as e:
except Exception as e:
ctx.warn(f"manage_asset: failed to parse properties JSON string: {e}")
# Leave properties as-is; Unity side may handle defaults
# Ensure properties is a dict if None

View File

@ -45,7 +45,6 @@ def manage_gameobject(
"List of component names to remove"] | None = None,
component_properties: Annotated[dict[str, dict[str, Any]] | str,
"""Dictionary of component names to their properties to set. For example:
Can also be provided as a JSON string representation of the dict.
`{"MyScript": {"otherObject": {"find": "Player", "method": "by_name"}}}` assigns GameObject
`{"MyScript": {"playerHealth": {"find": "Player", "component": "HealthComponent"}}}` assigns Component
Example set nested property:
@ -122,6 +121,9 @@ def manage_gameobject(
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
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:

View File

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

View File

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

View File

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

View File

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

View File

@ -18,24 +18,21 @@ namespace Tests.EditMode
/// </summary>
public class MCPToolParameterTests
{
private const string TempDir = "Assets/Temp/MCPToolParameterTests";
[SetUp]
public void SetUp()
[Test]
public void Test_ManageAsset_ShouldAcceptJSONProperties()
{
// Arrange: create temp folder
const string tempDir = "Assets/Temp/MCPToolParameterTests";
if (!AssetDatabase.IsValidFolder("Assets/Temp"))
{
AssetDatabase.CreateFolder("Assets", "Temp");
}
if (!AssetDatabase.IsValidFolder(TempDir))
if (!AssetDatabase.IsValidFolder(tempDir))
{
AssetDatabase.CreateFolder("Assets/Temp", "MCPToolParameterTests");
}
}
[Test]
public void Test_ManageAsset_ShouldAcceptJSONProperties()
{
var matPath = $"{TempDir}/JsonMat_{Guid.NewGuid().ToString("N")}.mat";
var matPath = $"{tempDir}/JsonMat_{Guid.NewGuid().ToString("N")}.mat";
// Build params with properties as a JSON string (to be coerced)
var p = new JObject
@ -73,7 +70,10 @@ namespace Tests.EditMode
[Test]
public void Test_ManageGameObject_ShouldAcceptJSONComponentProperties()
{
var matPath = $"{TempDir}/JsonMat_{Guid.NewGuid().ToString("N")}.mat";
const string tempDir = "Assets/Temp/MCPToolParameterTests";
if (!AssetDatabase.IsValidFolder("Assets/Temp")) AssetDatabase.CreateFolder("Assets", "Temp");
if (!AssetDatabase.IsValidFolder(tempDir)) AssetDatabase.CreateFolder("Assets/Temp", "MCPToolParameterTests");
var matPath = $"{tempDir}/JsonMat_{Guid.NewGuid().ToString("N")}.mat";
// Create a material first (object-typed properties)
var createMat = new JObject
@ -121,7 +121,10 @@ namespace Tests.EditMode
[Test]
public void Test_JSONParsing_ShouldWorkInMCPTools()
{
var matPath = $"{TempDir}/JsonMat_{Guid.NewGuid().ToString("N")}.mat";
const string tempDir = "Assets/Temp/MCPToolParameterTests";
if (!AssetDatabase.IsValidFolder("Assets/Temp")) AssetDatabase.CreateFolder("Assets", "Temp");
if (!AssetDatabase.IsValidFolder(tempDir)) AssetDatabase.CreateFolder("Assets/Temp", "MCPToolParameterTests");
var matPath = $"{tempDir}/JsonMat_{Guid.NewGuid().ToString("N")}.mat";
// manage_asset with JSON string properties (coercion path)
var createMat = new JObject

View File

@ -0,0 +1,158 @@
using System;
using System.IO;
using Newtonsoft.Json.Linq;
using NUnit.Framework;
using UnityEditor;
using UnityEngine;
using MCPForUnity.Editor.Tools;
namespace MCPForUnityTests.Editor.Tools
{
public class MaterialDirectPropertiesTests
{
private const string TempRoot = "Assets/Temp/MaterialDirectPropertiesTests";
private string _matPath;
private string _baseMapPath;
private string _normalMapPath;
private string _occlusionMapPath;
[SetUp]
public void SetUp()
{
if (!AssetDatabase.IsValidFolder("Assets/Temp"))
{
AssetDatabase.CreateFolder("Assets", "Temp");
}
if (!AssetDatabase.IsValidFolder(TempRoot))
{
AssetDatabase.CreateFolder("Assets/Temp", "MaterialDirectPropertiesTests");
}
string guid = Guid.NewGuid().ToString("N");
_matPath = $"{TempRoot}/DirectProps_{guid}.mat";
_baseMapPath = $"{TempRoot}/TexBase_{guid}.asset";
_normalMapPath = $"{TempRoot}/TexNormal_{guid}.asset";
_occlusionMapPath = $"{TempRoot}/TexOcc_{guid}.asset";
// Clean any leftovers just in case
TryDeleteAsset(_matPath);
TryDeleteAsset(_baseMapPath);
TryDeleteAsset(_normalMapPath);
TryDeleteAsset(_occlusionMapPath);
AssetDatabase.Refresh();
}
[TearDown]
public void TearDown()
{
TryDeleteAsset(_matPath);
TryDeleteAsset(_baseMapPath);
TryDeleteAsset(_normalMapPath);
TryDeleteAsset(_occlusionMapPath);
AssetDatabase.Refresh();
}
private static void TryDeleteAsset(string path)
{
if (string.IsNullOrEmpty(path)) return;
if (AssetDatabase.LoadAssetAtPath<UnityEngine.Object>(path) != null)
{
AssetDatabase.DeleteAsset(path);
}
var abs = Path.Combine(Directory.GetCurrentDirectory(), path);
try
{
if (File.Exists(abs)) File.Delete(abs);
if (File.Exists(abs + ".meta")) File.Delete(abs + ".meta");
}
catch { }
}
private static Texture2D CreateSolidTextureAsset(string path, Color color)
{
var tex = new Texture2D(4, 4, TextureFormat.RGBA32, false);
var pixels = new Color[16];
for (int i = 0; i < pixels.Length; i++) pixels[i] = color;
tex.SetPixels(pixels);
tex.Apply();
AssetDatabase.CreateAsset(tex, path);
AssetDatabase.SaveAssets();
return tex;
}
private static JObject ToJObject(object result)
{
return result as JObject ?? JObject.FromObject(result);
}
[Test]
public void CreateAndModifyMaterial_WithDirectPropertyKeys_Works()
{
// Arrange: create textures as assets
CreateSolidTextureAsset(_baseMapPath, Color.white);
CreateSolidTextureAsset(_normalMapPath, new Color(0.5f, 0.5f, 1f));
CreateSolidTextureAsset(_occlusionMapPath, Color.gray);
// Create material using direct keys via JSON string
var createParams = new JObject
{
["action"] = "create",
["path"] = _matPath,
["assetType"] = "Material",
["properties"] = new JObject
{
["shader"] = "Universal Render Pipeline/Lit",
["_Color"] = new JArray(0f, 1f, 0f, 1f),
["_Glossiness"] = 0.25f
}
};
var createRes = ToJObject(ManageAsset.HandleCommand(createParams));
Assert.IsTrue(createRes.Value<bool>("success"), createRes.ToString());
// Modify with aliases and textures
var modifyParams = new JObject
{
["action"] = "modify",
["path"] = _matPath,
["properties"] = new JObject
{
["_BaseColor"] = new JArray(0f, 0f, 1f, 1f),
["_Smoothness"] = 0.5f,
["_BaseMap"] = _baseMapPath,
["_BumpMap"] = _normalMapPath,
["_OcclusionMap"] = _occlusionMapPath
}
};
var modifyRes = ToJObject(ManageAsset.HandleCommand(modifyParams));
Assert.IsTrue(modifyRes.Value<bool>("success"), modifyRes.ToString());
var mat = AssetDatabase.LoadAssetAtPath<Material>(_matPath);
Assert.IsNotNull(mat, "Material should exist at path.");
// Verify color alias applied
if (mat.HasProperty("_BaseColor"))
{
Assert.AreEqual(Color.blue, mat.GetColor("_BaseColor"));
}
else if (mat.HasProperty("_Color"))
{
Assert.AreEqual(Color.blue, mat.GetColor("_Color"));
}
// Verify float
string smoothProp = mat.HasProperty("_Smoothness") ? "_Smoothness" : (mat.HasProperty("_Glossiness") ? "_Glossiness" : null);
Assert.IsNotNull(smoothProp, "Material should expose Smoothness/Glossiness.");
Assert.That(Mathf.Abs(mat.GetFloat(smoothProp) - 0.5f) < 1e-4f);
// Verify textures
string baseMapProp = mat.HasProperty("_BaseMap") ? "_BaseMap" : (mat.HasProperty("_MainTex") ? "_MainTex" : null);
Assert.IsNotNull(baseMapProp, "Material should expose BaseMap/MainTex.");
Assert.IsNotNull(mat.GetTexture(baseMapProp), "BaseMap/MainTex should be assigned.");
if (mat.HasProperty("_BumpMap")) Assert.IsNotNull(mat.GetTexture("_BumpMap"));
if (mat.HasProperty("_OcclusionMap")) Assert.IsNotNull(mat.GetTexture("_OcclusionMap"));
}
}
}

View File

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

View File

@ -96,25 +96,13 @@ namespace MCPForUnityTests.Editor.Tools
}
}
private void CreateTestMaterial()
[Test]
public void AssignMaterial_ToSphere_UsingComponentPropertiesObject_Succeeds()
{
var createParams = new JObject
{
["action"] = "create",
["path"] = _matPath,
["assetType"] = "Material",
["properties"] = new JObject
{
["shader"] = "Universal Render Pipeline/Lit",
["color"] = new JArray(0f, 0f, 1f, 1f)
}
};
var result = ToJObject(ManageAsset.HandleCommand(createParams));
Assert.IsTrue(result.Value<bool>("success"), result.Value<string>("error"));
}
// Ensure material exists first
CreateMaterial_WithObjectProperties_SucceedsAndSetsColor();
private void CreateSphere()
{
// Create a sphere via handler
var createGo = new JObject
{
["action"] = "create",
@ -123,18 +111,9 @@ namespace MCPForUnityTests.Editor.Tools
};
var createGoResult = ToJObject(ManageGameObject.HandleCommand(createGo));
Assert.IsTrue(createGoResult.Value<bool>("success"), createGoResult.Value<string>("error"));
_sphere = GameObject.Find("ToolTestSphere");
Assert.IsNotNull(_sphere, "Sphere should be created.");
}
[Test]
public void AssignMaterial_ToSphere_UsingComponentPropertiesObject_Succeeds()
{
CreateTestMaterial();
CreateSphere();
// Create a sphere via handler
// Assign material via object-typed componentProperties
var modifyParams = new JObject
@ -163,20 +142,8 @@ namespace MCPForUnityTests.Editor.Tools
[Test]
public void ReadRendererData_DoesNotInstantiateMaterial_AndIncludesSharedMaterial()
{
CreateTestMaterial();
CreateSphere();
var modifyParams = new JObject
{
["action"] = "modify",
["target"] = "ToolTestSphere",
["searchMethod"] = "by_name",
["componentProperties"] = new JObject
{
["MeshRenderer"] = new JObject { ["sharedMaterial"] = _matPath }
}
};
var modifyResult = ToJObject(ManageGameObject.HandleCommand(modifyParams));
Assert.IsTrue(modifyResult.Value<bool>("success"), modifyResult.Value<string>("error"));
// Prepare object and assignment
AssignMaterial_ToSphere_UsingComponentPropertiesObject_Succeeds();
var renderer = _sphere.GetComponent<MeshRenderer>();
int beforeId = renderer.sharedMaterial != null ? renderer.sharedMaterial.GetInstanceID() : 0;

View File

@ -1,6 +1,6 @@
{
"dependencies": {
"com.coplaydev.unity-mcp": "file:../../../MCPForUnity",
"com.coplaydev.unity-mcp": "file:/Users/davidsarno/unity-mcp/MCPForUnity",
"com.unity.ai.navigation": "1.1.4",
"com.unity.collab-proxy": "2.5.2",
"com.unity.feature.development": "1.0.1",