unity-mcp/MCPForUnity/Editor/Tools/ManageScriptableObject.cs

899 lines
37 KiB
C#
Raw Normal View History

using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Text.RegularExpressions;
using MCPForUnity.Editor.Helpers;
using Newtonsoft.Json.Linq;
using UnityEditor;
using UnityEngine;
namespace MCPForUnity.Editor.Tools
{
/// <summary>
/// Single tool for ScriptableObject workflows:
/// - action=create: create a ScriptableObject asset (and optionally apply patches)
/// - action=modify: apply serialized property patches to an existing asset
///
/// Patching is performed via SerializedObject/SerializedProperty paths (Unity-native), not reflection.
/// </summary>
[McpForUnityTool("manage_scriptable_object", AutoRegister = false)]
public static class ManageScriptableObject
{
private const string CodeCompilingOrReloading = "compiling_or_reloading";
private const string CodeInvalidParams = "invalid_params";
private const string CodeTypeNotFound = "type_not_found";
private const string CodeInvalidFolderPath = "invalid_folder_path";
private const string CodeTargetNotFound = "target_not_found";
private const string CodeAssetCreateFailed = "asset_create_failed";
private static readonly HashSet<string> ValidActions = new(StringComparer.OrdinalIgnoreCase)
{
// NOTE: Action strings are normalized by NormalizeAction() (lowercased, '_'/'-' removed),
// so we only need the canonical normalized forms here.
"create",
"createso",
"modify",
"modifyso",
};
public static object HandleCommand(JObject @params)
{
if (@params == null)
{
return new ErrorResponse(CodeInvalidParams);
}
if (EditorApplication.isCompiling || EditorApplication.isUpdating)
{
// Unity is transient; treat as retryable on the client side.
return new ErrorResponse(CodeCompilingOrReloading, new { hint = "retry" });
}
// Allow JSON-string parameters for objects/arrays.
JsonUtil.CoerceJsonStringParameter(@params, "target");
CoerceJsonStringArrayParameter(@params, "patches");
string actionRaw = @params["action"]?.ToString();
if (string.IsNullOrWhiteSpace(actionRaw))
{
return new ErrorResponse(CodeInvalidParams, new { message = "'action' is required.", validActions = ValidActions.ToArray() });
}
string action = NormalizeAction(actionRaw);
if (!ValidActions.Contains(action))
{
return new ErrorResponse(CodeInvalidParams, new { message = $"Unknown action: '{actionRaw}'.", validActions = ValidActions.ToArray() });
}
if (IsCreateAction(action))
{
return HandleCreate(@params);
}
return HandleModify(@params);
}
private static object HandleCreate(JObject @params)
{
string typeName = @params["typeName"]?.ToString() ?? @params["type_name"]?.ToString();
string folderPath = @params["folderPath"]?.ToString() ?? @params["folder_path"]?.ToString();
string assetName = @params["assetName"]?.ToString() ?? @params["asset_name"]?.ToString();
bool overwrite = @params["overwrite"]?.ToObject<bool?>() ?? false;
if (string.IsNullOrWhiteSpace(typeName))
{
return new ErrorResponse(CodeInvalidParams, new { message = "'typeName' is required." });
}
if (string.IsNullOrWhiteSpace(folderPath))
{
return new ErrorResponse(CodeInvalidParams, new { message = "'folderPath' is required." });
}
if (string.IsNullOrWhiteSpace(assetName))
{
return new ErrorResponse(CodeInvalidParams, new { message = "'assetName' is required." });
}
if (assetName.Contains("/") || assetName.Contains("\\"))
{
return new ErrorResponse(CodeInvalidParams, new { message = "'assetName' must not contain path separators." });
}
if (!TryNormalizeFolderPath(folderPath, out var normalizedFolder, out var folderNormalizeError))
{
return new ErrorResponse(CodeInvalidFolderPath, new { message = folderNormalizeError, folderPath });
}
if (!EnsureFolderExists(normalizedFolder, out var folderError))
{
return new ErrorResponse(CodeInvalidFolderPath, new { message = folderError, folderPath = normalizedFolder });
}
var resolvedType = ResolveType(typeName);
if (resolvedType == null || !typeof(ScriptableObject).IsAssignableFrom(resolvedType))
{
return new ErrorResponse(CodeTypeNotFound, new { message = $"ScriptableObject type not found: '{typeName}'", typeName });
}
string fileName = assetName.EndsWith(".asset", StringComparison.OrdinalIgnoreCase)
? assetName
: assetName + ".asset";
string desiredPath = $"{normalizedFolder.TrimEnd('/')}/{fileName}";
string finalPath = overwrite ? desiredPath : AssetDatabase.GenerateUniqueAssetPath(desiredPath);
ScriptableObject instance;
try
{
instance = ScriptableObject.CreateInstance(resolvedType);
if (instance == null)
{
return new ErrorResponse(CodeAssetCreateFailed, new { message = "CreateInstance returned null.", typeName = resolvedType.FullName });
}
}
catch (Exception ex)
{
return new ErrorResponse(CodeAssetCreateFailed, new { message = ex.Message, typeName = resolvedType.FullName });
}
// GUID-preserving overwrite logic
bool isNewAsset = true;
try
{
if (overwrite)
{
var existingAsset = AssetDatabase.LoadAssetAtPath<ScriptableObject>(finalPath);
if (existingAsset != null && existingAsset.GetType() == resolvedType)
{
// Preserve GUID by overwriting existing asset data in-place
EditorUtility.CopySerialized(instance, existingAsset);
// Fix for "Main Object Name does not match filename" warning:
// CopySerialized overwrites the name with the (empty) name of the new instance.
// We must restore the correct name to match the filename.
existingAsset.name = Path.GetFileNameWithoutExtension(finalPath);
UnityEngine.Object.DestroyImmediate(instance); // Destroy temporary instance
instance = existingAsset; // Proceed with patching the existing asset
isNewAsset = false;
// Mark dirty to ensure changes are picked up
EditorUtility.SetDirty(instance);
}
else if (existingAsset != null)
{
// Type mismatch or not a ScriptableObject - must delete and recreate to change type, losing GUID
// (Or we could warn, but overwrite usually implies replacing)
AssetDatabase.DeleteAsset(finalPath);
}
}
if (isNewAsset)
{
// Ensure the new instance has the correct name before creating asset to avoid warnings
instance.name = Path.GetFileNameWithoutExtension(finalPath);
AssetDatabase.CreateAsset(instance, finalPath);
}
}
catch (Exception ex)
{
return new ErrorResponse(CodeAssetCreateFailed, new { message = ex.Message, path = finalPath });
}
string guid = AssetDatabase.AssetPathToGUID(finalPath);
var patchesToken = @params["patches"];
object patchResults = null;
var warnings = new List<string>();
if (patchesToken is JArray patches && patches.Count > 0)
{
var patchApply = ApplyPatches(instance, patches);
patchResults = patchApply.results;
warnings.AddRange(patchApply.warnings);
}
EditorUtility.SetDirty(instance);
AssetDatabase.SaveAssets();
return new SuccessResponse(
"ScriptableObject created.",
new
{
guid,
path = finalPath,
typeNameResolved = resolvedType.FullName,
patchResults,
warnings = warnings.Count > 0 ? warnings : null
}
);
}
private static object HandleModify(JObject @params)
{
if (!TryResolveTarget(@params["target"], out var target, out var targetPath, out var targetGuid, out var err))
{
return err;
}
var patchesToken = @params["patches"];
if (patchesToken == null || patchesToken.Type == JTokenType.Null)
{
return new ErrorResponse(CodeInvalidParams, new { message = "'patches' is required.", targetPath, targetGuid });
}
if (patchesToken is not JArray patches)
{
return new ErrorResponse(CodeInvalidParams, new { message = "'patches' must be an array.", targetPath, targetGuid });
}
var (results, warnings) = ApplyPatches(target, patches);
return new SuccessResponse(
"Serialized properties patched.",
new
{
targetGuid,
targetPath,
targetTypeName = target.GetType().FullName,
results,
warnings = warnings.Count > 0 ? warnings : null
}
);
}
private static (List<object> results, List<string> warnings) ApplyPatches(UnityEngine.Object target, JArray patches)
{
var warnings = new List<string>();
var results = new List<object>(patches.Count);
bool anyChanged = false;
var so = new SerializedObject(target);
so.Update();
for (int i = 0; i < patches.Count; i++)
{
if (patches[i] is not JObject patchObj)
{
results.Add(new { propertyPath = "", op = "", ok = false, message = $"Patch at index {i} must be an object." });
continue;
}
string propertyPath = patchObj["propertyPath"]?.ToString()
?? patchObj["property_path"]?.ToString()
?? patchObj["path"]?.ToString();
string op = (patchObj["op"]?.ToString() ?? "set").Trim();
if (string.IsNullOrWhiteSpace(propertyPath))
{
results.Add(new { propertyPath = propertyPath ?? "", op, ok = false, message = "Missing required field: propertyPath" });
continue;
}
if (string.IsNullOrWhiteSpace(op))
{
op = "set";
}
var patchResult = ApplyPatch(so, propertyPath, op, patchObj, out bool changed);
anyChanged |= changed;
results.Add(patchResult);
// Array resize should be applied immediately so later paths resolve.
if (string.Equals(op, "array_resize", StringComparison.OrdinalIgnoreCase) && changed)
{
so.ApplyModifiedProperties();
so.Update();
}
}
if (anyChanged)
{
so.ApplyModifiedProperties();
EditorUtility.SetDirty(target);
AssetDatabase.SaveAssets();
}
return (results, warnings);
}
private static object ApplyPatch(SerializedObject so, string propertyPath, string op, JObject patchObj, out bool changed)
{
changed = false;
try
{
string normalizedOp = op.Trim().ToLowerInvariant();
switch (normalizedOp)
{
case "array_resize":
return ApplyArrayResize(so, propertyPath, patchObj, out changed);
case "set":
default:
return ApplySet(so, propertyPath, patchObj, out changed);
}
}
catch (Exception ex)
{
return new { propertyPath, op, ok = false, message = ex.Message };
}
}
private static object ApplyArrayResize(SerializedObject so, string propertyPath, JObject patchObj, out bool changed)
{
changed = false;
if (!TryGetInt(patchObj["value"], out int newSize))
{
return new { propertyPath, op = "array_resize", ok = false, message = "array_resize requires integer 'value'." };
}
newSize = Math.Max(0, newSize);
// Unity supports resizing either:
// - the array/list property itself (prop.isArray -> prop.arraySize)
// - the synthetic leaf property "<array>.Array.size" (prop.intValue)
//
// Different Unity versions/serialization edge cases can fail to resolve the synthetic leaf via FindProperty
// (or can return different property types), so we keep a "best-effort" fallback:
// - Prefer acting on the requested path if it resolves.
// - If the requested path doesn't resolve, try to resolve the *array property* and set arraySize directly.
SerializedProperty prop = so.FindProperty(propertyPath);
SerializedProperty arrayProp = null;
if (propertyPath.EndsWith(".Array.size", StringComparison.Ordinal))
{
// Caller explicitly targeted the synthetic leaf. Resolve the parent array property as a fallback
// (Unity sometimes fails to resolve the synthetic leaf in certain serialization contexts).
var arrayPath = propertyPath.Substring(0, propertyPath.Length - ".Array.size".Length);
arrayProp = so.FindProperty(arrayPath);
}
else
{
// Caller targeted either the array property itself (e.g., "items") or some other property.
// If it's already an array, we can resize it directly. Otherwise, we attempt to resolve
// a synthetic ".Array.size" leaf as a convenience, which some clients may pass.
arrayProp = prop != null && prop.isArray ? prop : so.FindProperty(propertyPath + ".Array.size");
}
if (prop == null)
{
// If we failed to find the direct property but we *can* find the array property, use that.
if (arrayProp != null && arrayProp.isArray)
{
if (arrayProp.arraySize != newSize)
{
arrayProp.arraySize = newSize;
changed = true;
}
return new
{
propertyPath,
op = "array_resize",
ok = true,
resolvedPropertyType = "Array",
message = $"Set array size to {newSize}."
};
}
return new { propertyPath, op = "array_resize", ok = false, message = $"Property not found: {propertyPath}" };
}
// Unity may represent ".Array.size" as either Integer or ArraySize depending on version.
if ((prop.propertyType == SerializedPropertyType.Integer || prop.propertyType == SerializedPropertyType.ArraySize)
&& propertyPath.EndsWith(".Array.size", StringComparison.Ordinal))
{
// We successfully resolved the synthetic leaf; write the size through its intValue.
if (prop.intValue != newSize)
{
prop.intValue = newSize;
changed = true;
}
return new { propertyPath, op = "array_resize", ok = true, resolvedPropertyType = prop.propertyType.ToString(), message = $"Set array size to {newSize}." };
}
if (prop.isArray)
{
// We resolved the array property itself; write through arraySize.
if (prop.arraySize != newSize)
{
prop.arraySize = newSize;
changed = true;
}
return new { propertyPath, op = "array_resize", ok = true, resolvedPropertyType = "Array", message = $"Set array size to {newSize}." };
}
return new { propertyPath, op = "array_resize", ok = false, resolvedPropertyType = prop.propertyType.ToString(), message = $"Property is not an array or array-size field: {propertyPath}" };
}
private static object ApplySet(SerializedObject so, string propertyPath, JObject patchObj, out bool changed)
{
changed = false;
var prop = so.FindProperty(propertyPath);
if (prop == null)
{
return new { propertyPath, op = "set", ok = false, message = $"Property not found: {propertyPath}" };
}
if (prop.propertyType == SerializedPropertyType.ObjectReference)
{
var refObj = patchObj["ref"] as JObject;
UnityEngine.Object newRef = null;
string refGuid = refObj?["guid"]?.ToString();
string refPath = refObj?["path"]?.ToString();
if (refObj == null && patchObj["value"]?.Type == JTokenType.Null)
{
newRef = null;
}
else if (!string.IsNullOrEmpty(refGuid) || !string.IsNullOrEmpty(refPath))
{
string resolvedPath = !string.IsNullOrEmpty(refGuid)
? AssetDatabase.GUIDToAssetPath(refGuid)
: AssetPathUtility.SanitizeAssetPath(refPath);
if (!string.IsNullOrEmpty(resolvedPath))
{
newRef = AssetDatabase.LoadAssetAtPath<UnityEngine.Object>(resolvedPath);
}
}
if (prop.objectReferenceValue != newRef)
{
prop.objectReferenceValue = newRef;
changed = true;
}
return new { propertyPath, op = "set", ok = true, resolvedPropertyType = prop.propertyType.ToString(), message = newRef == null ? "Cleared reference." : "Set reference." };
}
var valueToken = patchObj["value"];
if (valueToken == null)
{
return new { propertyPath, op = "set", ok = false, resolvedPropertyType = prop.propertyType.ToString(), message = "Missing required field: value" };
}
bool ok = TrySetValue(prop, valueToken, out string message);
changed = ok;
return new { propertyPath, op = "set", ok, resolvedPropertyType = prop.propertyType.ToString(), message };
}
private static bool TrySetValue(SerializedProperty prop, JToken valueToken, out string message)
{
message = null;
try
{
// Supported Types: Integer, Boolean, Float, String, Enum, Vector2, Vector3, Vector4, Color
switch (prop.propertyType)
{
case SerializedPropertyType.Integer:
if (!TryGetInt(valueToken, out var intVal)) { message = "Expected integer value."; return false; }
prop.intValue = intVal; message = "Set int."; return true;
case SerializedPropertyType.Boolean:
if (!TryGetBool(valueToken, out var boolVal)) { message = "Expected boolean value."; return false; }
prop.boolValue = boolVal; message = "Set bool."; return true;
case SerializedPropertyType.Float:
if (!TryGetFloat(valueToken, out var floatVal)) { message = "Expected float value."; return false; }
prop.floatValue = floatVal; message = "Set float."; return true;
case SerializedPropertyType.String:
prop.stringValue = valueToken.Type == JTokenType.Null ? null : valueToken.ToString();
message = "Set string."; return true;
case SerializedPropertyType.Enum:
return TrySetEnum(prop, valueToken, out message);
case SerializedPropertyType.Vector2:
if (!TryGetVector2(valueToken, out var v2)) { message = "Expected Vector2 (array or object)."; return false; }
prop.vector2Value = v2; message = "Set Vector2."; return true;
case SerializedPropertyType.Vector3:
if (!TryGetVector3(valueToken, out var v3)) { message = "Expected Vector3 (array or object)."; return false; }
prop.vector3Value = v3; message = "Set Vector3."; return true;
case SerializedPropertyType.Vector4:
if (!TryGetVector4(valueToken, out var v4)) { message = "Expected Vector4 (array or object)."; return false; }
prop.vector4Value = v4; message = "Set Vector4."; return true;
case SerializedPropertyType.Color:
if (!TryGetColor(valueToken, out var col)) { message = "Expected Color (array or object)."; return false; }
prop.colorValue = col; message = "Set Color."; return true;
default:
message = $"Unsupported SerializedPropertyType: {prop.propertyType}";
return false;
}
}
catch (Exception ex)
{
message = ex.Message;
return false;
}
}
private static bool TrySetEnum(SerializedProperty prop, JToken valueToken, out string message)
{
message = null;
var names = prop.enumNames;
if (names == null || names.Length == 0) { message = "Enum has no names."; return false; }
if (valueToken.Type == JTokenType.Integer)
{
int idx = valueToken.Value<int>();
if (idx < 0 || idx >= names.Length) { message = $"Enum index out of range: {idx}"; return false; }
prop.enumValueIndex = idx; message = "Set enum."; return true;
}
string s = valueToken.ToString();
for (int i = 0; i < names.Length; i++)
{
if (string.Equals(names[i], s, StringComparison.OrdinalIgnoreCase))
{
prop.enumValueIndex = i; message = "Set enum."; return true;
}
}
message = $"Unknown enum name '{s}'.";
return false;
}
private static bool TryResolveTarget(JToken targetToken, out UnityEngine.Object target, out string targetPath, out string targetGuid, out object error)
{
target = null;
targetPath = null;
targetGuid = null;
error = null;
if (targetToken is not JObject targetObj)
{
error = new ErrorResponse(CodeInvalidParams, new { message = "'target' must be an object with {guid|path}." });
return false;
}
string guid = targetObj["guid"]?.ToString();
string path = targetObj["path"]?.ToString();
if (string.IsNullOrWhiteSpace(guid) && string.IsNullOrWhiteSpace(path))
{
error = new ErrorResponse(CodeInvalidParams, new { message = "'target' must include 'guid' or 'path'." });
return false;
}
string resolvedPath = !string.IsNullOrWhiteSpace(guid)
? AssetDatabase.GUIDToAssetPath(guid)
: AssetPathUtility.SanitizeAssetPath(path);
if (string.IsNullOrWhiteSpace(resolvedPath))
{
error = new ErrorResponse(CodeTargetNotFound, new { message = "Could not resolve target path.", guid, path });
return false;
}
var obj = AssetDatabase.LoadAssetAtPath<UnityEngine.Object>(resolvedPath);
if (obj == null)
{
error = new ErrorResponse(CodeTargetNotFound, new { message = "Target asset not found.", targetPath = resolvedPath, targetGuid = guid });
return false;
}
target = obj;
targetPath = resolvedPath;
targetGuid = string.IsNullOrWhiteSpace(guid) ? AssetDatabase.AssetPathToGUID(resolvedPath) : guid;
return true;
}
private static void CoerceJsonStringArrayParameter(JObject @params, string paramName)
{
var token = @params?[paramName];
if (token != null && token.Type == JTokenType.String)
{
try
{
var parsed = JToken.Parse(token.ToString());
if (parsed is JArray arr)
{
@params[paramName] = arr;
}
}
catch (Exception e)
{
McpLog.Warn($"[MCP] Could not parse '{paramName}' JSON string: {e.Message}");
}
}
}
private static bool EnsureFolderExists(string folderPath, out string error)
{
error = null;
if (string.IsNullOrWhiteSpace(folderPath))
{
error = "Folder path is empty.";
return false;
}
// Expect normalized input here (Assets/... or Assets).
string sanitized = SanitizeSlashes(folderPath);
if (!sanitized.StartsWith("Assets/", StringComparison.OrdinalIgnoreCase)
&& !string.Equals(sanitized, "Assets", StringComparison.OrdinalIgnoreCase))
{
error = "Folder path must be under Assets/.";
return false;
}
if (string.Equals(sanitized, "Assets", StringComparison.OrdinalIgnoreCase))
{
return true;
}
sanitized = sanitized.TrimEnd('/');
if (AssetDatabase.IsValidFolder(sanitized))
{
return true;
}
// Create recursively from Assets/
var parts = sanitized.Split(new[] { '/' }, StringSplitOptions.RemoveEmptyEntries);
if (parts.Length == 0 || !string.Equals(parts[0], "Assets", StringComparison.OrdinalIgnoreCase))
{
error = "Folder path must start with Assets/";
return false;
}
string current = "Assets";
for (int i = 1; i < parts.Length; i++)
{
string next = current + "/" + parts[i];
if (!AssetDatabase.IsValidFolder(next))
{
string guid = AssetDatabase.CreateFolder(current, parts[i]);
if (string.IsNullOrEmpty(guid))
{
error = $"Failed to create folder: {next}";
return false;
}
}
current = next;
}
return AssetDatabase.IsValidFolder(sanitized);
}
private static string SanitizeSlashes(string path)
{
if (string.IsNullOrWhiteSpace(path))
{
return path;
}
var s = path.Replace('\\', '/');
while (s.IndexOf("//", StringComparison.Ordinal) >= 0)
{
s = s.Replace("//", "/", StringComparison.Ordinal);
}
return s;
}
private static bool TryNormalizeFolderPath(string folderPath, out string normalized, out string error)
{
normalized = null;
error = null;
if (string.IsNullOrWhiteSpace(folderPath))
{
error = "Folder path is empty.";
return false;
}
var s = SanitizeSlashes(folderPath.Trim());
// Reject obvious non-project/invalid roots. We only support Assets/ (and relative paths that will be rooted under Assets/).
if (s.StartsWith("/", StringComparison.Ordinal)
|| s.StartsWith("file:", StringComparison.OrdinalIgnoreCase)
|| Regex.IsMatch(s, @"^[a-zA-Z]:"))
{
error = "Folder path must be a project-relative path under Assets/.";
return false;
}
if (s.StartsWith("Packages/", StringComparison.OrdinalIgnoreCase)
|| s.StartsWith("ProjectSettings/", StringComparison.OrdinalIgnoreCase)
|| s.StartsWith("Library/", StringComparison.OrdinalIgnoreCase))
{
error = "Folder path must be under Assets/.";
return false;
}
if (string.Equals(s, "Assets", StringComparison.OrdinalIgnoreCase))
{
normalized = "Assets";
return true;
}
if (s.StartsWith("Assets/", StringComparison.OrdinalIgnoreCase))
{
normalized = s.TrimEnd('/');
return true;
}
// Allow relative paths like "Temp/MyFolder" and root them under Assets/.
normalized = ("Assets/" + s.TrimStart('/')).TrimEnd('/');
return true;
}
private static bool TryGetInt(JToken token, out int value)
{
value = default;
if (token == null || token.Type == JTokenType.Null) return false;
try
{
if (token.Type == JTokenType.Integer) { value = token.Value<int>(); return true; }
if (token.Type == JTokenType.Float) { value = Convert.ToInt32(token.Value<double>()); return true; }
var s = token.ToString().Trim();
return int.TryParse(s, NumberStyles.Integer, CultureInfo.InvariantCulture, out value);
}
catch { return false; }
}
private static bool TryGetFloat(JToken token, out float value)
{
value = default;
if (token == null || token.Type == JTokenType.Null) return false;
try
{
if (token.Type == JTokenType.Float || token.Type == JTokenType.Integer) { value = token.Value<float>(); return true; }
var s = token.ToString().Trim();
return float.TryParse(s, NumberStyles.Float, CultureInfo.InvariantCulture, out value);
}
catch { return false; }
}
private static bool TryGetBool(JToken token, out bool value)
{
value = default;
if (token == null || token.Type == JTokenType.Null) return false;
try
{
if (token.Type == JTokenType.Boolean) { value = token.Value<bool>(); return true; }
var s = token.ToString().Trim();
return bool.TryParse(s, out value);
}
catch { return false; }
}
// --- Vector/Color Parsing Helpers ---
private static bool TryGetVector2(JToken token, out Vector2 value)
{
value = default;
if (token == null || token.Type == JTokenType.Null) return false;
// Handle [x, y]
if (token is JArray arr && arr.Count >= 2)
{
if (TryGetFloat(arr[0], out float x) && TryGetFloat(arr[1], out float y))
{
value = new Vector2(x, y);
return true;
}
}
// Handle { "x": ..., "y": ... }
if (token is JObject obj)
{
if (TryGetFloat(obj["x"], out float x) && TryGetFloat(obj["y"], out float y))
{
value = new Vector2(x, y);
return true;
}
}
return false;
}
private static bool TryGetVector3(JToken token, out Vector3 value)
{
value = default;
if (token == null || token.Type == JTokenType.Null) return false;
// Handle [x, y, z]
if (token is JArray arr && arr.Count >= 3)
{
if (TryGetFloat(arr[0], out float x) && TryGetFloat(arr[1], out float y) && TryGetFloat(arr[2], out float z))
{
value = new Vector3(x, y, z);
return true;
}
}
// Handle { "x": ..., "y": ..., "z": ... }
if (token is JObject obj)
{
if (TryGetFloat(obj["x"], out float x) && TryGetFloat(obj["y"], out float y) && TryGetFloat(obj["z"], out float z))
{
value = new Vector3(x, y, z);
return true;
}
}
return false;
}
private static bool TryGetVector4(JToken token, out Vector4 value)
{
value = default;
if (token == null || token.Type == JTokenType.Null) return false;
// Handle [x, y, z, w]
if (token is JArray arr && arr.Count >= 4)
{
if (TryGetFloat(arr[0], out float x) && TryGetFloat(arr[1], out float y)
&& TryGetFloat(arr[2], out float z) && TryGetFloat(arr[3], out float w))
{
value = new Vector4(x, y, z, w);
return true;
}
}
// Handle { "x": ..., "y": ..., "z": ..., "w": ... }
if (token is JObject obj)
{
if (TryGetFloat(obj["x"], out float x) && TryGetFloat(obj["y"], out float y)
&& TryGetFloat(obj["z"], out float z) && TryGetFloat(obj["w"], out float w))
{
value = new Vector4(x, y, z, w);
return true;
}
}
return false;
}
private static bool TryGetColor(JToken token, out Color value)
{
value = default;
if (token == null || token.Type == JTokenType.Null) return false;
// Handle [r, g, b, a]
if (token is JArray arr && arr.Count >= 3)
{
float r = 0, g = 0, b = 0, a = 1;
bool ok = TryGetFloat(arr[0], out r) && TryGetFloat(arr[1], out g) && TryGetFloat(arr[2], out b);
if (arr.Count > 3) TryGetFloat(arr[3], out a);
if (ok)
{
value = new Color(r, g, b, a);
return true;
}
}
// Handle { "r": ..., "g": ..., "b": ..., "a": ... }
if (token is JObject obj)
{
if (TryGetFloat(obj["r"], out float r) && TryGetFloat(obj["g"], out float g) && TryGetFloat(obj["b"], out float b))
{
// Alpha is optional, defaults to 1.0
float a = 1.0f;
TryGetFloat(obj["a"], out a);
value = new Color(r, g, b, a);
return true;
}
}
return false;
}
private static string NormalizeAction(string raw)
{
var s = raw.Trim();
s = s.Replace("-", "").Replace("_", "");
return s.ToLowerInvariant();
}
private static bool IsCreateAction(string normalized)
{
return normalized == "create" || normalized == "createso";
}
🔧 Clean up & Consolidate Shared Services Across MCP Tools (#519) * feat: Redesign GameObject API for better LLM ergonomics - find_gameobjects: Search GameObjects, returns paginated instance IDs only - manage_components: Component lifecycle (add, remove, set_property) - unity://scene/gameobject/{id}: Single GameObject data (no component serialization) - unity://scene/gameobject/{id}/components: All components (paginated) - unity://scene/gameobject/{id}/component/{name}: Single component by type - manage_scene get_hierarchy: Now includes componentTypes array - manage_gameobject: Slimmed to lifecycle only (create, modify, delete) - Legacy actions (find, get_components, etc.) log deprecation warnings - ParamCoercion: Centralized int/bool/float/string coercion - VectorParsing: Vector3/Vector2/Quaternion/Color parsing - GameObjectLookup: Centralized GameObject search logic - 76 new Unity EditMode tests for ManageGameObject actions - 21 new pytest tests for Python tools/resources - New NL/T CI suite for GameObject API (GO-0 to GO-5) Addresses LLM confusion with parameter overload by splitting into focused tools and read-only resources. * feat: Add GameObject API stress tests and NL/T suite updates Stress Tests (12 new tests): - BulkCreate small/medium batches - FindGameObjects pagination with by_component search - AddComponents to single object - GetComponents with full serialization - SetComponentProperties (complex Rigidbody) - Deep hierarchy creation and path lookup - GetHierarchy with large scenes - Resource read performance tests - RapidFire create-modify-delete cycles NL/T Suite Updates: - Added GO-0..GO-10 tests in nl-gameobject-suite.md - Fixed tool naming: mcp__unity__ → mcp__UnityMCP__ Other: - Fixed LongUnityScriptClaudeTest.cs compilation errors - Added reports/, .claude/local/, scripts/local-test/ to .gitignore All 254 EditMode tests pass (250 run, 4 explicit skips) * fix: Address code review feedback - ParamCoercion: Use CultureInfo.InvariantCulture for float parsing - ManageComponents: Move Transform removal check before GetComponent - ManageGameObjectFindTests: Use try-finally for LogAssert.ignoreFailingMessages - VectorParsing: Document that quaternions are not auto-normalized - gameobject.py: Prefix unused ctx parameter with underscore * fix: Address more code review feedback NL/T Prompt Fixes: - nl-gameobject-suite.md: Remove non-existent list_resources/read_resource from AllowedTools - nl-gameobject-suite.md: Fix parameter names (component_type, properties) - nl-unity-suite-nl.md: Remove unused manage_editor from AllowedTools Test Fixes: - GameObjectAPIStressTests: Add null check to ToJObject helper - GameObjectAPIStressTests: Clarify AudioSource usage comment - ManageGameObjectFindTests: Use built-in 'UI' layer instead of 'Water' - LongUnityScriptClaudeTest: Clean up NL/T test artifacts (Counte42 typo, HasTarget) * docs: update README tools and resources lists - Add missing tools: manage_components, batch_execute, find_gameobjects, refresh_unity - Add missing resources: gameobject_api, editor_state_v2 - Make descriptions more concise across all tools and resources - Ensure documentation matches current MCP server functionality * chore: Remove accidentally committed test artifacts - Remove Materials folder (40 .mat files from interactive testing) - Remove Shaders folder (5 noise shaders from testing) - Remove test scripts (Bounce*, CylinderBounce* from testing) - Remove Temp.meta and commit.sh * refactor: remove deprecated manage_gameobject actions - Remove deprecated switch cases: find, get_components, get_component, add_component, remove_component, set_component_property - Remove deprecated wrapper methods (423 lines deleted from ManageGameObject.cs) - Delete ManageGameObjectFindTests.cs (tests deprecated 'find' action) - Remove deprecated test methods from ManageGameObjectTests.cs - Add GameObject resource URIs to README documentation - Add batch_execute performance tips to README, tool description, and gameobject_api resource - Enhance batch_execute description to emphasize 10-100x performance gains Total: ~1200 lines removed. New API (find_gameobjects, manage_components, resources) is the recommended path forward. * refactor: consolidate shared services across MCP tools Major architectural improvements: - Create UnityJsonSerializer for shared JSON/Unity type conversion - Create ObjectResolver for unified object resolution (GameObjects, Components, Assets) - Create UnityTypeResolver for consolidated type resolution with caching - Create PropertyConversion for unified JSON→Unity property conversion - Create ComponentOps for low-level component operations - Create Pagination helpers for standardized pagination across tools Tool simplifications: - ManageGameObject: Remove 68-line prefab redirect anti-pattern, delegate to helpers - ManageAsset: Remove ~80 lines duplicate ConvertJTokenToType - ManageScriptableObject: Remove ~40 lines duplicate ResolveType - ManageComponents: Use ComponentOps, UnityTypeResolver (~90 lines saved) - ManageMaterial: Standardize to SuccessResponse/ErrorResponse patterns - FindGameObjects: Use PaginationRequest/PaginationResponse - GameObjectLookup: FindComponentType delegates to UnityTypeResolver Tests: 242/246 passed, 4 skipped (expected) * Apply code review feedback: consolidate utilities and improve compatibility Python Server: - Extract normalize_properties() to shared utils.py (removes duplication) - Move search_term validation before preflight() for fail-fast - Fix manage_script.py documentation (remove incorrect 'update' reference) - Remove stale comments in execute_menu_item.py, manage_editor.py - Remove misleading destructiveHint from manage_shader.py C# Unity: - Add Vector4Converter (commonly used, was missing) - Fix Unity 2021 compatibility: replace FindObjectsByType with FindObjectsOfType - Add path normalization in ObjectResolver before StartsWith check - Improve ComponentOps.SetProperty conversion error detection - Add Undo.RecordObject in ManageComponents before property modifications - Improve error message clarity in ManageMaterial.cs - Add defensive error handling to stress test ToJObject helper - Increase CI timeout thresholds for test stability GitHub Workflows: - Fix GO test sorting in markdown output (GO-10 now sorts after GO-9) - Add warning logging for fragment parsing errors * Fix animator hash names in test fixture to match parameter names BlendXHash/BlendYHash now use 'reachX'/'reachY' to match the actual animator parameter names. * fix(windows): improve HTTP server detection and auto-start reliability - Fix netstat detection on Windows by running netstat.exe directly instead of piping through findstr (findstr returns exit code 1 when no matches, causing false detection failures) - Increase auto-start retry attempts (20→30) and delays (2s→3s) to handle slow server starts during first install, version upgrades, and dev mode - Only attempt blind connection after 20 failed detection attempts to reduce connection error spam during server startup - Remove verbose debug logs that were spamming the console every frame * fix: auto-create tags and remove deprecated manage_gameobject actions - ManageGameObject.cs: Check tag existence before setting; auto-create undefined tags using InternalEditorUtility.AddTag() instead of relying on exception handling (Unity logs warning, doesn't throw) - manage_gameobject.py: Remove deprecated actions (find, get_components, add_component, remove_component, set_component_property, get_component) from Literal type - these are now handled by find_gameobjects and manage_components tools - Update test suite and unit tests to reflect new auto-create behavior * fix: address code review feedback Bug fixes: - Fix searchInactive flag ignored in FindObjectsOfType (use includeInactive overload) - Fix property lookup to try both original and normalized names for backwards compat - Remove dead code for deprecated 'find' action validation - Update error message to list only valid actions Improvements: - Add destructiveHint=True to manage_shader tool - Limit fallback connection attempts (every 3rd attempt) to avoid spamming errors - Consolidate PropertyConversion exception handlers to single catch block - Add tag existence assertion and cleanup in tag auto-creation tests Test fixes: - Update SetComponentProperties_ContinuesAfterException log regex for new error format - Update test_manage_gameobject_param_coercion to test valid actions only
2026-01-07 04:58:17 +08:00
/// <summary>
/// Resolves a type by name. Delegates to UnityTypeResolver.ResolveAny().
/// </summary>
private static Type ResolveType(string typeName)
{
🔧 Clean up & Consolidate Shared Services Across MCP Tools (#519) * feat: Redesign GameObject API for better LLM ergonomics - find_gameobjects: Search GameObjects, returns paginated instance IDs only - manage_components: Component lifecycle (add, remove, set_property) - unity://scene/gameobject/{id}: Single GameObject data (no component serialization) - unity://scene/gameobject/{id}/components: All components (paginated) - unity://scene/gameobject/{id}/component/{name}: Single component by type - manage_scene get_hierarchy: Now includes componentTypes array - manage_gameobject: Slimmed to lifecycle only (create, modify, delete) - Legacy actions (find, get_components, etc.) log deprecation warnings - ParamCoercion: Centralized int/bool/float/string coercion - VectorParsing: Vector3/Vector2/Quaternion/Color parsing - GameObjectLookup: Centralized GameObject search logic - 76 new Unity EditMode tests for ManageGameObject actions - 21 new pytest tests for Python tools/resources - New NL/T CI suite for GameObject API (GO-0 to GO-5) Addresses LLM confusion with parameter overload by splitting into focused tools and read-only resources. * feat: Add GameObject API stress tests and NL/T suite updates Stress Tests (12 new tests): - BulkCreate small/medium batches - FindGameObjects pagination with by_component search - AddComponents to single object - GetComponents with full serialization - SetComponentProperties (complex Rigidbody) - Deep hierarchy creation and path lookup - GetHierarchy with large scenes - Resource read performance tests - RapidFire create-modify-delete cycles NL/T Suite Updates: - Added GO-0..GO-10 tests in nl-gameobject-suite.md - Fixed tool naming: mcp__unity__ → mcp__UnityMCP__ Other: - Fixed LongUnityScriptClaudeTest.cs compilation errors - Added reports/, .claude/local/, scripts/local-test/ to .gitignore All 254 EditMode tests pass (250 run, 4 explicit skips) * fix: Address code review feedback - ParamCoercion: Use CultureInfo.InvariantCulture for float parsing - ManageComponents: Move Transform removal check before GetComponent - ManageGameObjectFindTests: Use try-finally for LogAssert.ignoreFailingMessages - VectorParsing: Document that quaternions are not auto-normalized - gameobject.py: Prefix unused ctx parameter with underscore * fix: Address more code review feedback NL/T Prompt Fixes: - nl-gameobject-suite.md: Remove non-existent list_resources/read_resource from AllowedTools - nl-gameobject-suite.md: Fix parameter names (component_type, properties) - nl-unity-suite-nl.md: Remove unused manage_editor from AllowedTools Test Fixes: - GameObjectAPIStressTests: Add null check to ToJObject helper - GameObjectAPIStressTests: Clarify AudioSource usage comment - ManageGameObjectFindTests: Use built-in 'UI' layer instead of 'Water' - LongUnityScriptClaudeTest: Clean up NL/T test artifacts (Counte42 typo, HasTarget) * docs: update README tools and resources lists - Add missing tools: manage_components, batch_execute, find_gameobjects, refresh_unity - Add missing resources: gameobject_api, editor_state_v2 - Make descriptions more concise across all tools and resources - Ensure documentation matches current MCP server functionality * chore: Remove accidentally committed test artifacts - Remove Materials folder (40 .mat files from interactive testing) - Remove Shaders folder (5 noise shaders from testing) - Remove test scripts (Bounce*, CylinderBounce* from testing) - Remove Temp.meta and commit.sh * refactor: remove deprecated manage_gameobject actions - Remove deprecated switch cases: find, get_components, get_component, add_component, remove_component, set_component_property - Remove deprecated wrapper methods (423 lines deleted from ManageGameObject.cs) - Delete ManageGameObjectFindTests.cs (tests deprecated 'find' action) - Remove deprecated test methods from ManageGameObjectTests.cs - Add GameObject resource URIs to README documentation - Add batch_execute performance tips to README, tool description, and gameobject_api resource - Enhance batch_execute description to emphasize 10-100x performance gains Total: ~1200 lines removed. New API (find_gameobjects, manage_components, resources) is the recommended path forward. * refactor: consolidate shared services across MCP tools Major architectural improvements: - Create UnityJsonSerializer for shared JSON/Unity type conversion - Create ObjectResolver for unified object resolution (GameObjects, Components, Assets) - Create UnityTypeResolver for consolidated type resolution with caching - Create PropertyConversion for unified JSON→Unity property conversion - Create ComponentOps for low-level component operations - Create Pagination helpers for standardized pagination across tools Tool simplifications: - ManageGameObject: Remove 68-line prefab redirect anti-pattern, delegate to helpers - ManageAsset: Remove ~80 lines duplicate ConvertJTokenToType - ManageScriptableObject: Remove ~40 lines duplicate ResolveType - ManageComponents: Use ComponentOps, UnityTypeResolver (~90 lines saved) - ManageMaterial: Standardize to SuccessResponse/ErrorResponse patterns - FindGameObjects: Use PaginationRequest/PaginationResponse - GameObjectLookup: FindComponentType delegates to UnityTypeResolver Tests: 242/246 passed, 4 skipped (expected) * Apply code review feedback: consolidate utilities and improve compatibility Python Server: - Extract normalize_properties() to shared utils.py (removes duplication) - Move search_term validation before preflight() for fail-fast - Fix manage_script.py documentation (remove incorrect 'update' reference) - Remove stale comments in execute_menu_item.py, manage_editor.py - Remove misleading destructiveHint from manage_shader.py C# Unity: - Add Vector4Converter (commonly used, was missing) - Fix Unity 2021 compatibility: replace FindObjectsByType with FindObjectsOfType - Add path normalization in ObjectResolver before StartsWith check - Improve ComponentOps.SetProperty conversion error detection - Add Undo.RecordObject in ManageComponents before property modifications - Improve error message clarity in ManageMaterial.cs - Add defensive error handling to stress test ToJObject helper - Increase CI timeout thresholds for test stability GitHub Workflows: - Fix GO test sorting in markdown output (GO-10 now sorts after GO-9) - Add warning logging for fragment parsing errors * Fix animator hash names in test fixture to match parameter names BlendXHash/BlendYHash now use 'reachX'/'reachY' to match the actual animator parameter names. * fix(windows): improve HTTP server detection and auto-start reliability - Fix netstat detection on Windows by running netstat.exe directly instead of piping through findstr (findstr returns exit code 1 when no matches, causing false detection failures) - Increase auto-start retry attempts (20→30) and delays (2s→3s) to handle slow server starts during first install, version upgrades, and dev mode - Only attempt blind connection after 20 failed detection attempts to reduce connection error spam during server startup - Remove verbose debug logs that were spamming the console every frame * fix: auto-create tags and remove deprecated manage_gameobject actions - ManageGameObject.cs: Check tag existence before setting; auto-create undefined tags using InternalEditorUtility.AddTag() instead of relying on exception handling (Unity logs warning, doesn't throw) - manage_gameobject.py: Remove deprecated actions (find, get_components, add_component, remove_component, set_component_property, get_component) from Literal type - these are now handled by find_gameobjects and manage_components tools - Update test suite and unit tests to reflect new auto-create behavior * fix: address code review feedback Bug fixes: - Fix searchInactive flag ignored in FindObjectsOfType (use includeInactive overload) - Fix property lookup to try both original and normalized names for backwards compat - Remove dead code for deprecated 'find' action validation - Update error message to list only valid actions Improvements: - Add destructiveHint=True to manage_shader tool - Limit fallback connection attempts (every 3rd attempt) to avoid spamming errors - Consolidate PropertyConversion exception handlers to single catch block - Add tag existence assertion and cleanup in tag auto-creation tests Test fixes: - Update SetComponentProperties_ContinuesAfterException log regex for new error format - Update test_manage_gameobject_param_coercion to test valid actions only
2026-01-07 04:58:17 +08:00
return Helpers.UnityTypeResolver.ResolveAny(typeName);
}
}
}