diff --git a/MCPForUnity/Editor/Tools/ManageAsset.cs b/MCPForUnity/Editor/Tools/ManageAsset.cs index dbf49f2a..770fd243 100644 --- a/MCPForUnity/Editor/Tools/ManageAsset.cs +++ b/MCPForUnity/Editor/Tools/ManageAsset.cs @@ -64,7 +64,19 @@ public static object HandleCommand(JObject @params) 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,7 +1014,108 @@ private static bool ApplyMaterialProperties(Material mat, JObject properties) } } - // TODO: Add handlers for other property types (Vectors, Ints, Keywords, RenderQueue, etc.) + // --- 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(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(), + arr[1].ToObject(), + arr[2].ToObject(), + arr.Count > 3 ? arr[3].ToObject() : 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(); + 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(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; } diff --git a/MCPForUnity/Editor/Tools/ManageGameObject.cs b/MCPForUnity/Editor/Tools/ManageGameObject.cs index b6219ec1..1ad4107e 100644 --- a/MCPForUnity/Editor/Tools/ManageGameObject.cs +++ b/MCPForUnity/Editor/Tools/ManageGameObject.cs @@ -67,7 +67,19 @@ public static object HandleCommand(JObject @params) // --- 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 = diff --git a/MCPForUnity/UnityMcpServer~/src/tools/manage_asset.py b/MCPForUnity/UnityMcpServer~/src/tools/manage_asset.py index 4810daba..a577e94d 100644 --- a/MCPForUnity/UnityMcpServer~/src/tools/manage_asset.py +++ b/MCPForUnity/UnityMcpServer~/src/tools/manage_asset.py @@ -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 diff --git a/MCPForUnity/UnityMcpServer~/src/tools/manage_gameobject.py b/MCPForUnity/UnityMcpServer~/src/tools/manage_gameobject.py index f74d142b..794013b9 100644 --- a/MCPForUnity/UnityMcpServer~/src/tools/manage_gameobject.py +++ b/MCPForUnity/UnityMcpServer~/src/tools/manage_gameobject.py @@ -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 _to_vec3(parts): 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: diff --git a/TestProjects/UnityMCPTests/Assets/Temp/LiveTests.meta b/TestProjects/UnityMCPTests/Assets/Temp/LiveTests.meta new file mode 100644 index 00000000..16c8bb61 --- /dev/null +++ b/TestProjects/UnityMCPTests/Assets/Temp/LiveTests.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 0c392d9059b864f608a4d32e4347c3d6 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/TestProjects/UnityMCPTests/Assets/Temp/MCPToolParameterTests.meta b/TestProjects/UnityMCPTests/Assets/Temp/MCPToolParameterTests.meta new file mode 100644 index 00000000..fd2be787 --- /dev/null +++ b/TestProjects/UnityMCPTests/Assets/Temp/MCPToolParameterTests.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: d5876265244e44b0dbea3a1351bf24be +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/TestProjects/UnityMCPTests/Assets/Temp/MaterialDirectPropertiesTests.meta b/TestProjects/UnityMCPTests/Assets/Temp/MaterialDirectPropertiesTests.meta new file mode 100644 index 00000000..baddc2b1 --- /dev/null +++ b/TestProjects/UnityMCPTests/Assets/Temp/MaterialDirectPropertiesTests.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: e6bcc993a43284ece98cc3f36f2dc8ab +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/TestProjects/UnityMCPTests/Assets/Temp/MaterialParameterToolTests.meta b/TestProjects/UnityMCPTests/Assets/Temp/MaterialParameterToolTests.meta new file mode 100644 index 00000000..f0a87d3a --- /dev/null +++ b/TestProjects/UnityMCPTests/Assets/Temp/MaterialParameterToolTests.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 91e200a37b70f45b28121bae6a351cf6 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/MCPToolParameterTests.cs b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/MCPToolParameterTests.cs index a5d3c208..325e200c 100644 --- a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/MCPToolParameterTests.cs +++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/MCPToolParameterTests.cs @@ -18,24 +18,21 @@ namespace Tests.EditMode /// 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 @@ public void Test_ManageAsset_ShouldAcceptJSONProperties() [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 @@ public void Test_ManageGameObject_ShouldAcceptJSONComponentProperties() [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 diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/MaterialDirectPropertiesTests.cs b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/MaterialDirectPropertiesTests.cs new file mode 100644 index 00000000..d92f0f8e --- /dev/null +++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/MaterialDirectPropertiesTests.cs @@ -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(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("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("success"), modifyRes.ToString()); + + var mat = AssetDatabase.LoadAssetAtPath(_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")); + } + } +} + + diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/MaterialDirectPropertiesTests.cs.meta b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/MaterialDirectPropertiesTests.cs.meta new file mode 100644 index 00000000..9c806e8c --- /dev/null +++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/MaterialDirectPropertiesTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 3623619548eef40568ac5e3cef4c22a5 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/MaterialParameterToolTests.cs b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/MaterialParameterToolTests.cs index f37cbf90..1ae73e0b 100644 --- a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/MaterialParameterToolTests.cs +++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/MaterialParameterToolTests.cs @@ -96,25 +96,13 @@ public void CreateMaterial_WithObjectProperties_SucceedsAndSetsColor() } } - 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("success"), result.Value("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 @@ private void CreateSphere() }; var createGoResult = ToJObject(ManageGameObject.HandleCommand(createGo)); Assert.IsTrue(createGoResult.Value("success"), createGoResult.Value("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 @@ public void AssignMaterial_ToSphere_UsingComponentPropertiesObject_Succeeds() [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("success"), modifyResult.Value("error")); + // Prepare object and assignment + AssignMaterial_ToSphere_UsingComponentPropertiesObject_Succeeds(); var renderer = _sphere.GetComponent(); int beforeId = renderer.sharedMaterial != null ? renderer.sharedMaterial.GetInstanceID() : 0; diff --git a/TestProjects/UnityMCPTests/Packages/manifest.json b/TestProjects/UnityMCPTests/Packages/manifest.json index 0bd78a67..f7361652 100644 --- a/TestProjects/UnityMCPTests/Packages/manifest.json +++ b/TestProjects/UnityMCPTests/Packages/manifest.json @@ -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", diff --git a/TestProjects/UnityMCPTests/ProjectSettings/boot.config b/TestProjects/UnityMCPTests/ProjectSettings/boot.config deleted file mode 100644 index e69de29b..00000000