899 lines
37 KiB
C#
899 lines
37 KiB
C#
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";
|
|
}
|
|
|
|
/// <summary>
|
|
/// Resolves a type by name. Delegates to UnityTypeResolver.ResolveAny().
|
|
/// </summary>
|
|
private static Type ResolveType(string typeName)
|
|
{
|
|
return Helpers.UnityTypeResolver.ResolveAny(typeName);
|
|
}
|
|
}
|
|
}
|