chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 12:49:17 +08:00
commit 7243d5823b
2201 changed files with 257291 additions and 0 deletions
+8
View File
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 76a782e424904c8686863ade93091b77
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,224 @@
using System;
using Newtonsoft.Json.Linq;
using MCPForUnity.Editor.Helpers;
using UnityEditor;
using UnityEditor.Animations;
using UnityEngine;
namespace MCPForUnity.Editor.Tools.Animation
{
internal static class AnimatorControl
{
public static object Play(JObject @params)
{
var go = ObjectResolver.ResolveGameObject(@params["target"], @params["searchMethod"]?.ToString());
if (go == null)
return new { success = false, message = "Target GameObject not found" };
var animator = go.GetComponent<Animator>();
if (animator == null)
return new { success = false, message = $"No Animator component on '{go.name}'" };
string stateName = @params["stateName"]?.ToString();
if (string.IsNullOrEmpty(stateName))
return new { success = false, message = "'stateName' is required" };
int layer = @params["layer"]?.ToObject<int>() ?? -1;
Undo.RecordObject(animator, "Play Animation State");
animator.Play(stateName, layer);
return new { success = true, message = $"Playing state '{stateName}' on '{go.name}'" };
}
public static object Crossfade(JObject @params)
{
var go = ObjectResolver.ResolveGameObject(@params["target"], @params["searchMethod"]?.ToString());
if (go == null)
return new { success = false, message = "Target GameObject not found" };
var animator = go.GetComponent<Animator>();
if (animator == null)
return new { success = false, message = $"No Animator component on '{go.name}'" };
string stateName = @params["stateName"]?.ToString();
if (string.IsNullOrEmpty(stateName))
return new { success = false, message = "'stateName' is required" };
float duration = @params["duration"]?.ToObject<float>() ?? 0.25f;
int layer = @params["layer"]?.ToObject<int>() ?? -1;
Undo.RecordObject(animator, "Crossfade Animation State");
animator.CrossFade(stateName, duration, layer);
return new { success = true, message = $"Crossfading to '{stateName}' over {duration}s on '{go.name}'" };
}
public static object SetParameter(JObject @params)
{
var go = ObjectResolver.ResolveGameObject(@params["target"], @params["searchMethod"]?.ToString());
if (go == null)
return new { success = false, message = "Target GameObject not found" };
var animator = go.GetComponent<Animator>();
if (animator == null)
return new { success = false, message = $"No Animator component on '{go.name}'" };
string paramName = @params["parameterName"]?.ToString();
if (string.IsNullOrEmpty(paramName))
return new { success = false, message = "'parameterName' is required" };
string paramType = @params["parameterType"]?.ToString()?.ToLowerInvariant();
// Auto-detect type if not specified
if (string.IsNullOrEmpty(paramType))
{
for (int i = 0; i < animator.parameterCount; i++)
{
var p = animator.GetParameter(i);
if (p.name == paramName)
{
paramType = p.type.ToString().ToLowerInvariant();
break;
}
}
if (string.IsNullOrEmpty(paramType))
return new { success = false, message = $"Parameter '{paramName}' not found. Specify 'parameterType' explicitly or check the parameter name." };
}
JToken valueToken = @params["value"];
// In Edit mode, runtime Animator.SetFloat/SetInteger/SetBool are no-ops because
// the Animator graph isn't active. Instead, modify the controller asset's default
// parameter values so changes actually persist.
bool isPlaying = Application.isPlaying;
if (isPlaying)
{
Undo.RecordObject(animator, $"Set Animator Parameter {paramName}");
switch (paramType)
{
case "float":
float fVal = valueToken?.ToObject<float>() ?? 0f;
animator.SetFloat(paramName, fVal);
return new { success = true, message = $"Set float '{paramName}' = {fVal}" };
case "int":
case "integer":
int iVal = valueToken?.ToObject<int>() ?? 0;
animator.SetInteger(paramName, iVal);
return new { success = true, message = $"Set int '{paramName}' = {iVal}" };
case "bool":
case "boolean":
bool bVal = valueToken?.ToObject<bool>() ?? false;
animator.SetBool(paramName, bVal);
return new { success = true, message = $"Set bool '{paramName}' = {bVal}" };
case "trigger":
animator.SetTrigger(paramName);
return new { success = true, message = $"Set trigger '{paramName}'" };
default:
return new { success = false, message = $"Unknown parameter type: {paramType}. Valid: float, int, bool, trigger" };
}
}
else
{
// Edit mode: modify the AnimatorController asset's default parameter values
var controller = animator.runtimeAnimatorController as AnimatorController;
if (controller == null)
return new { success = false, message = $"No AnimatorController assigned to Animator on '{go.name}'. Cannot set parameter defaults in Edit mode." };
var allParams = controller.parameters;
int paramIndex = -1;
for (int i = 0; i < allParams.Length; i++)
{
if (allParams[i].name == paramName)
{
paramIndex = i;
break;
}
}
if (paramIndex < 0)
return new { success = false, message = $"Parameter '{paramName}' not found on controller '{controller.name}'." };
Undo.RecordObject(controller, $"Set Parameter Default {paramName}");
switch (paramType)
{
case "float":
float fVal = valueToken?.ToObject<float>() ?? 0f;
allParams[paramIndex].defaultFloat = fVal;
controller.parameters = allParams;
EditorUtility.SetDirty(controller);
AssetDatabase.SaveAssets();
return new { success = true, message = $"Set float '{paramName}' = {fVal} (default value, Edit mode)" };
case "int":
case "integer":
int iVal = valueToken?.ToObject<int>() ?? 0;
allParams[paramIndex].defaultInt = iVal;
controller.parameters = allParams;
EditorUtility.SetDirty(controller);
AssetDatabase.SaveAssets();
return new { success = true, message = $"Set int '{paramName}' = {iVal} (default value, Edit mode)" };
case "bool":
case "boolean":
bool bVal = valueToken?.ToObject<bool>() ?? false;
allParams[paramIndex].defaultBool = bVal;
controller.parameters = allParams;
EditorUtility.SetDirty(controller);
AssetDatabase.SaveAssets();
return new { success = true, message = $"Set bool '{paramName}' = {bVal} (default value, Edit mode)" };
case "trigger":
return new { success = true, message = $"Trigger '{paramName}' noted (triggers are runtime-only, no default to set)" };
default:
return new { success = false, message = $"Unknown parameter type: {paramType}. Valid: float, int, bool, trigger" };
}
}
}
public static object SetSpeed(JObject @params)
{
var go = ObjectResolver.ResolveGameObject(@params["target"], @params["searchMethod"]?.ToString());
if (go == null)
return new { success = false, message = "Target GameObject not found" };
var animator = go.GetComponent<Animator>();
if (animator == null)
return new { success = false, message = $"No Animator component on '{go.name}'" };
float speed = @params["speed"]?.ToObject<float>() ?? 1f;
Undo.RecordObject(animator, "Set Animator Speed");
animator.speed = speed;
return new { success = true, message = $"Set animator speed to {speed} on '{go.name}'" };
}
public static object SetEnabled(JObject @params)
{
var go = ObjectResolver.ResolveGameObject(@params["target"], @params["searchMethod"]?.ToString());
if (go == null)
return new { success = false, message = "Target GameObject not found" };
var animator = go.GetComponent<Animator>();
if (animator == null)
return new { success = false, message = $"No Animator component on '{go.name}'" };
bool enabled = @params["enabled"]?.ToObject<bool>() ?? true;
Undo.RecordObject(animator, "Set Animator Enabled");
animator.enabled = enabled;
return new { success = true, message = $"Animator {(enabled ? "enabled" : "disabled")} on '{go.name}'" };
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: ac285365908a4fb39f775b2af5edc60b
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,152 @@
using System.Collections.Generic;
using System.Linq;
using Newtonsoft.Json.Linq;
using MCPForUnity.Editor.Helpers;
using UnityEngine;
namespace MCPForUnity.Editor.Tools.Animation
{
internal static class AnimatorRead
{
public static object GetInfo(JObject @params)
{
var go = ObjectResolver.ResolveGameObject(@params["target"], @params["searchMethod"]?.ToString());
if (go == null)
return new { success = false, message = "Target GameObject not found" };
var animator = go.GetComponent<Animator>();
if (animator == null)
return new { success = false, message = $"No Animator component on '{go.name}'" };
var parameters = new List<object>();
for (int i = 0; i < animator.parameterCount; i++)
{
var p = animator.GetParameter(i);
parameters.Add(new
{
name = p.name,
type = p.type.ToString(),
defaultFloat = p.defaultFloat,
defaultInt = p.defaultInt,
defaultBool = p.defaultBool
});
}
var layers = new List<object>();
for (int i = 0; i < animator.layerCount; i++)
{
var stateInfo = animator.IsInTransition(i)
? animator.GetNextAnimatorStateInfo(i)
: animator.GetCurrentAnimatorStateInfo(i);
layers.Add(new
{
index = i,
name = animator.GetLayerName(i),
weight = animator.GetLayerWeight(i),
currentStateHash = stateInfo.fullPathHash,
currentStateNormalizedTime = stateInfo.normalizedTime,
currentStateLength = stateInfo.length,
isInTransition = animator.IsInTransition(i)
});
}
var clips = new List<object>();
if (animator.runtimeAnimatorController != null)
{
foreach (var clip in animator.runtimeAnimatorController.animationClips)
{
clips.Add(new
{
name = clip.name,
length = clip.length,
frameRate = clip.frameRate,
isLooping = clip.isLooping,
wrapMode = clip.wrapMode.ToString()
});
}
}
return new
{
success = true,
data = new
{
gameObject = go.name,
enabled = animator.enabled,
speed = animator.speed,
hasController = animator.runtimeAnimatorController != null,
controllerName = animator.runtimeAnimatorController?.name,
applyRootMotion = animator.applyRootMotion,
updateMode = animator.updateMode.ToString(),
cullingMode = animator.cullingMode.ToString(),
parameterCount = animator.parameterCount,
layerCount = animator.layerCount,
parameters,
layers,
clips
}
};
}
public static object GetParameter(JObject @params)
{
var go = ObjectResolver.ResolveGameObject(@params["target"], @params["searchMethod"]?.ToString());
if (go == null)
return new { success = false, message = "Target GameObject not found" };
var animator = go.GetComponent<Animator>();
if (animator == null)
return new { success = false, message = $"No Animator component on '{go.name}'" };
string paramName = @params["parameterName"]?.ToString();
if (string.IsNullOrEmpty(paramName))
return new { success = false, message = "'parameterName' is required" };
AnimatorControllerParameter found = null;
for (int i = 0; i < animator.parameterCount; i++)
{
var p = animator.GetParameter(i);
if (p.name == paramName)
{
found = p;
break;
}
}
if (found == null)
return new { success = false, message = $"Parameter '{paramName}' not found on Animator" };
object value;
switch (found.type)
{
case AnimatorControllerParameterType.Float:
value = animator.GetFloat(paramName);
break;
case AnimatorControllerParameterType.Int:
value = animator.GetInteger(paramName);
break;
case AnimatorControllerParameterType.Bool:
value = animator.GetBool(paramName);
break;
case AnimatorControllerParameterType.Trigger:
value = animator.GetBool(paramName);
break;
default:
value = null;
break;
}
return new
{
success = true,
data = new
{
name = found.name,
type = found.type.ToString(),
value
}
};
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 66c604dff493453da1bc8cb6032ebf35
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,637 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using Newtonsoft.Json.Linq;
using MCPForUnity.Editor.Helpers;
using MCPForUnity.Runtime.Helpers;
using UnityEditor;
using UnityEngine;
namespace MCPForUnity.Editor.Tools.Animation
{
internal static class ClipCreate
{
public static object Create(JObject @params)
{
string clipPath = @params["clipPath"]?.ToString();
if (string.IsNullOrEmpty(clipPath))
return new { success = false, message = "'clipPath' is required (e.g. 'Assets/Animations/Walk.anim')" };
clipPath = AssetPathUtility.SanitizeAssetPath(clipPath);
if (clipPath == null)
return new { success = false, message = "Invalid asset path" };
if (!clipPath.EndsWith(".anim", StringComparison.OrdinalIgnoreCase))
clipPath += ".anim";
// Ensure directory exists
string dir = Path.GetDirectoryName(clipPath)?.Replace('\\', '/');
if (!string.IsNullOrEmpty(dir) && !AssetDatabase.IsValidFolder(dir))
{
CreateFoldersRecursive(dir);
}
// Check if already exists
var existing = AssetDatabase.LoadAssetAtPath<AnimationClip>(clipPath);
if (existing != null)
return new { success = false, message = $"AnimationClip already exists at '{clipPath}'. Delete it first or use a different path." };
var clip = new AnimationClip();
string name = @params["name"]?.ToString();
clip.name = !string.IsNullOrEmpty(name)
? name
: Path.GetFileNameWithoutExtension(clipPath);
float length = @params["length"]?.ToObject<float>() ?? 1f;
clip.frameRate = @params["frameRate"]?.ToObject<float>() ?? 60f;
bool loop = @params["loop"]?.ToObject<bool>() ?? false;
var settings = AnimationUtility.GetAnimationClipSettings(clip);
settings.loopTime = loop;
settings.stopTime = length;
AnimationUtility.SetAnimationClipSettings(clip, settings);
AssetDatabase.CreateAsset(clip, clipPath);
// Set m_WrapMode via SerializedObject — clip.wrapMode is a runtime property
// that doesn't serialize to m_WrapMode, so we set it directly for the legacy system
if (loop)
{
var so = new SerializedObject(clip);
var wrapProp = so.FindProperty("m_WrapMode");
if (wrapProp != null)
{
wrapProp.intValue = (int)WrapMode.Loop;
so.ApplyModifiedProperties();
}
}
AssetDatabase.SaveAssets();
return new
{
success = true,
message = $"Created AnimationClip at '{clipPath}'",
data = new
{
path = clipPath,
name = clip.name,
length,
frameRate = clip.frameRate,
isLooping = loop
}
};
}
public static object GetInfo(JObject @params)
{
string clipPath = @params["clipPath"]?.ToString();
if (string.IsNullOrEmpty(clipPath))
return new { success = false, message = "'clipPath' is required" };
clipPath = AssetPathUtility.SanitizeAssetPath(clipPath);
if (clipPath == null)
return new { success = false, message = "Invalid asset path" };
var clip = AssetDatabase.LoadAssetAtPath<AnimationClip>(clipPath);
if (clip == null)
return new { success = false, message = $"AnimationClip not found at '{clipPath}'" };
var settings = AnimationUtility.GetAnimationClipSettings(clip);
var bindings = AnimationUtility.GetCurveBindings(clip);
var curves = new List<object>();
foreach (var binding in bindings)
{
var curve = AnimationUtility.GetEditorCurve(clip, binding);
curves.Add(new
{
path = binding.path,
propertyName = binding.propertyName,
type = binding.type.Name,
keyCount = curve?.length ?? 0
});
}
var events = AnimationUtility.GetAnimationEvents(clip);
var eventList = events.Select(e => new
{
time = e.time,
functionName = e.functionName,
stringParameter = e.stringParameter,
floatParameter = e.floatParameter,
intParameter = e.intParameter
}).ToArray();
return new
{
success = true,
data = new
{
path = clipPath,
name = clip.name,
length = clip.length,
frameRate = clip.frameRate,
isLooping = settings.loopTime,
wrapMode = clip.wrapMode.ToString(),
curveCount = bindings.Length,
curves,
eventCount = events.Length,
events = eventList
}
};
}
public static object AddCurve(JObject @params)
{
return SetOrAddCurve(@params, append: true);
}
public static object SetCurve(JObject @params)
{
return SetOrAddCurve(@params, append: false);
}
private static object SetOrAddCurve(JObject @params, bool append)
{
string clipPath = @params["clipPath"]?.ToString();
if (string.IsNullOrEmpty(clipPath))
return new { success = false, message = "'clipPath' is required" };
clipPath = AssetPathUtility.SanitizeAssetPath(clipPath);
if (clipPath == null)
return new { success = false, message = "Invalid asset path" };
var clip = AssetDatabase.LoadAssetAtPath<AnimationClip>(clipPath);
if (clip == null)
return new { success = false, message = $"AnimationClip not found at '{clipPath}'" };
string propertyPath = @params["propertyPath"]?.ToString();
if (string.IsNullOrEmpty(propertyPath))
return new { success = false, message = "'propertyPath' is required (e.g. 'localPosition.x')" };
string typeName = @params["type"]?.ToString() ?? "Transform";
Type componentType = ResolveType(typeName);
if (componentType == null)
return new { success = false, message = $"Could not resolve type '{typeName}'" };
string relativePath = @params["relativePath"]?.ToString() ?? "";
JToken keysToken = @params["keys"];
if (keysToken == null)
return new { success = false, message = "'keys' is required" };
var keyframes = ParseKeyframes(keysToken);
if (keyframes == null || keyframes.Length == 0)
return new { success = false, message = "Failed to parse keyframes. Use [{\"time\":0,\"value\":0},...] or [[0,0],[1,1],...]" };
AnimationCurve curve;
var binding = EditorCurveBinding.FloatCurve(relativePath, componentType, propertyPath);
if (append)
{
curve = AnimationUtility.GetEditorCurve(clip, binding) ?? new AnimationCurve();
foreach (var kf in keyframes)
{
curve.AddKey(kf);
}
}
else
{
curve = new AnimationCurve(keyframes);
}
// Use AnimationUtility.SetEditorCurve instead of clip.SetCurve to avoid
// marking the clip as legacy — legacy clips cannot be used in Mecanim BlendTrees.
Undo.RecordObject(clip, append ? "Add Animation Curve" : "Set Animation Curve");
AnimationUtility.SetEditorCurve(clip, binding, curve);
EditorUtility.SetDirty(clip);
AssetDatabase.SaveAssets();
string verb = append ? "Added" : "Set";
return new
{
success = true,
message = $"{verb} curve on '{propertyPath}' ({typeName}) with {keyframes.Length} keyframes",
data = new
{
clipPath,
propertyPath,
type = typeName,
keyframeCount = curve.length
}
};
}
public static object SetVectorCurve(JObject @params)
{
string clipPath = @params["clipPath"]?.ToString();
if (string.IsNullOrEmpty(clipPath))
return new { success = false, message = "'clipPath' is required" };
clipPath = AssetPathUtility.SanitizeAssetPath(clipPath);
if (clipPath == null)
return new { success = false, message = "Invalid asset path" };
var clip = AssetDatabase.LoadAssetAtPath<AnimationClip>(clipPath);
if (clip == null)
return new { success = false, message = $"AnimationClip not found at '{clipPath}'" };
// Accept both 'property' and 'propertyPath' for consistency with add_curve/set_curve
string property = @params["property"]?.ToString() ?? @params["propertyPath"]?.ToString();
if (string.IsNullOrEmpty(property))
return new { success = false, message = "'property' (or 'propertyPath') is required (e.g. 'localPosition', 'localEulerAngles', 'localScale')" };
string typeName = @params["type"]?.ToString() ?? "Transform";
Type componentType = ResolveType(typeName);
if (componentType == null)
return new { success = false, message = $"Could not resolve type '{typeName}'" };
string relativePath = @params["relativePath"]?.ToString() ?? "";
JToken keysToken = @params["keys"];
if (keysToken == null || keysToken is not JArray keysArray || keysArray.Count == 0)
return new { success = false, message = "'keys' is required. Use [{\"time\":0,\"value\":[0,1,0]},...]" };
// Map property group to axis suffixes
string[] suffixes;
switch (property.ToLowerInvariant())
{
case "localposition":
property = "localPosition";
suffixes = new[] { ".x", ".y", ".z" };
break;
case "localeulerangles":
property = "localEulerAngles";
suffixes = new[] { ".x", ".y", ".z" };
break;
case "localscale":
property = "localScale";
suffixes = new[] { ".x", ".y", ".z" };
break;
default:
suffixes = new[] { ".x", ".y", ".z" };
break;
}
var xKeys = new List<Keyframe>();
var yKeys = new List<Keyframe>();
var zKeys = new List<Keyframe>();
foreach (var item in keysArray)
{
if (item is not JObject keyObj)
return new { success = false, message = "Each key must be an object with 'time' and 'value' (Vector3 array)" };
float time = keyObj["time"]?.ToObject<float>() ?? 0f;
JToken valueToken = keyObj["value"];
if (valueToken is not JArray valArray || valArray.Count < 3)
return new { success = false, message = $"Key at time {time}: 'value' must be a 3-element array [x, y, z]" };
float vx = valArray[0].ToObject<float>();
float vy = valArray[1].ToObject<float>();
float vz = valArray[2].ToObject<float>();
xKeys.Add(new Keyframe(time, vx));
yKeys.Add(new Keyframe(time, vy));
zKeys.Add(new Keyframe(time, vz));
}
// Use AnimationUtility.SetEditorCurve instead of clip.SetCurve to avoid
// marking the clip as legacy — legacy clips cannot be used in Mecanim BlendTrees.
Undo.RecordObject(clip, "Set Vector Curve");
var bindingX = EditorCurveBinding.FloatCurve(relativePath, componentType, property + suffixes[0]);
var bindingY = EditorCurveBinding.FloatCurve(relativePath, componentType, property + suffixes[1]);
var bindingZ = EditorCurveBinding.FloatCurve(relativePath, componentType, property + suffixes[2]);
AnimationUtility.SetEditorCurve(clip, bindingX, new AnimationCurve(xKeys.ToArray()));
AnimationUtility.SetEditorCurve(clip, bindingY, new AnimationCurve(yKeys.ToArray()));
AnimationUtility.SetEditorCurve(clip, bindingZ, new AnimationCurve(zKeys.ToArray()));
EditorUtility.SetDirty(clip);
AssetDatabase.SaveAssets();
return new
{
success = true,
message = $"Set 3 curves on '{property}' ({typeName}) with {keysArray.Count} vector keyframes",
data = new
{
clipPath,
property,
type = typeName,
curves = new[] { property + suffixes[0], property + suffixes[1], property + suffixes[2] },
keyframeCount = keysArray.Count
}
};
}
public static object Assign(JObject @params)
{
var go = ObjectResolver.ResolveGameObject(@params["target"], @params["searchMethod"]?.ToString());
if (go == null)
return new { success = false, message = "Target GameObject not found" };
string clipPath = @params["clipPath"]?.ToString();
if (string.IsNullOrEmpty(clipPath))
return new { success = false, message = "'clipPath' is required" };
clipPath = AssetPathUtility.SanitizeAssetPath(clipPath);
if (clipPath == null)
return new { success = false, message = "Invalid asset path" };
var clip = AssetDatabase.LoadAssetAtPath<AnimationClip>(clipPath);
if (clip == null)
return new { success = false, message = $"AnimationClip not found at '{clipPath}'" };
// Try legacy Animation component first
var legacyAnim = go.GetComponent<UnityEngine.Animation>();
if (legacyAnim != null)
{
var wasLegacy = clip.legacy;
SetupLegacyClip(clip);
Undo.RecordObject(legacyAnim, "Assign Animation Clip");
legacyAnim.clip = clip;
legacyAnim.AddClip(clip, clip.name);
legacyAnim.playAutomatically = true;
EditorUtility.SetDirty(legacyAnim);
AssetDatabase.SaveAssets();
// Warn about AnimationEvents if present — they require a MonoBehaviour receiver
var events = AnimationUtility.GetAnimationEvents(clip);
string warning = "";
if (events != null && events.Length > 0)
{
var eventNames = new System.Collections.Generic.List<string>();
foreach (var e in events)
eventNames.Add(e.functionName);
warning = $" Warning: This clip has {events.Length} AnimationEvent(s) ({string.Join(", ", eventNames)}). " +
$"'{go.name}' must have a MonoBehaviour with matching method(s) to receive them, " +
"otherwise Unity will log 'AnimationEvent has no receiver' errors.";
}
if (!wasLegacy) warning += " Warning: clip was converted to legacy and will not be usable in Mecanim/BlendTrees.";
return new { success = true, message = $"Assigned clip '{clip.name}' to Animation component on '{go.name}'.{warning}" };
}
// Add Animation component if no Animator or Animation exists
var animator = go.GetComponent<Animator>();
if (animator == null)
{
var wasLegacy = clip.legacy;
SetupLegacyClip(clip);
Undo.RecordObject(go, "Add Animation Component");
legacyAnim = Undo.AddComponent<UnityEngine.Animation>(go);
legacyAnim.clip = clip;
legacyAnim.AddClip(clip, clip.name);
legacyAnim.playAutomatically = true;
EditorUtility.SetDirty(go);
AssetDatabase.SaveAssets();
var legacyWarning = !wasLegacy ? " Warning: clip was converted to legacy and will not be usable in Mecanim/BlendTrees." : "";
return new { success = true, message = $"Added Animation component and assigned clip '{clip.name}' to '{go.name}'.{legacyWarning}" };
}
// Has Animator - we can't programmatically assign clips to Animator states easily,
// so report what the user should do
return new
{
success = true,
message = $"GameObject '{go.name}' has an Animator component. The clip '{clip.name}' is available at '{clipPath}'. " +
"Assign it to an Animator Controller state via the Animator window or create an AnimatorOverrideController."
};
}
private static void SetupLegacyClip(AnimationClip clip)
{
var so = new SerializedObject(clip);
bool changed = false;
if (!clip.legacy)
{
var legacyProp = so.FindProperty("m_Legacy");
if (legacyProp != null)
{
legacyProp.boolValue = true;
changed = true;
}
}
var settings = AnimationUtility.GetAnimationClipSettings(clip);
if (settings.loopTime)
{
var wrapProp = so.FindProperty("m_WrapMode");
if (wrapProp != null && wrapProp.intValue != (int)WrapMode.Loop)
{
wrapProp.intValue = (int)WrapMode.Loop;
changed = true;
}
}
if (changed)
so.ApplyModifiedProperties();
}
private static Keyframe[] ParseKeyframes(JToken keysToken)
{
if (keysToken is JArray array && array.Count > 0)
{
var keyframes = new List<Keyframe>();
foreach (var item in array)
{
if (item is JArray pair && pair.Count >= 2)
{
// Shorthand: [time, value]
float time = pair[0].ToObject<float>();
float value = pair[1].ToObject<float>();
keyframes.Add(new Keyframe(time, value));
}
else if (item is JObject obj)
{
// Full form: {"time":0, "value":0, "inTangent":0, "outTangent":0}
float time = obj["time"]?.ToObject<float>() ?? 0f;
float value = obj["value"]?.ToObject<float>() ?? 0f;
var kf = new Keyframe(time, value);
if (obj["inTangent"] != null)
kf.inTangent = obj["inTangent"].ToObject<float>();
if (obj["outTangent"] != null)
kf.outTangent = obj["outTangent"].ToObject<float>();
if (obj["inWeight"] != null)
kf.inWeight = obj["inWeight"].ToObject<float>();
if (obj["outWeight"] != null)
kf.outWeight = obj["outWeight"].ToObject<float>();
keyframes.Add(kf);
}
}
return keyframes.ToArray();
}
return null;
}
private static Type ResolveType(string typeName)
{
if (string.IsNullOrEmpty(typeName))
return typeof(Transform);
// Try common Unity types
Type type = Type.GetType($"UnityEngine.{typeName}, UnityEngine.CoreModule");
if (type != null) return type;
type = Type.GetType($"UnityEngine.{typeName}, UnityEngine.AnimationModule");
if (type != null) return type;
type = Type.GetType($"UnityEngine.{typeName}, UnityEngine");
if (type != null) return type;
// Try fully qualified
type = Type.GetType(typeName);
if (type != null) return type;
// Fallback: search all loaded assemblies
foreach (var assembly in UnityAssembliesCompat.GetLoadedAssemblies())
{
type = assembly.GetType(typeName);
if (type != null) return type;
type = assembly.GetType($"UnityEngine.{typeName}");
if (type != null) return type;
}
return null;
}
public static object AddEvent(JObject @params)
{
string clipPath = @params["clipPath"]?.ToString();
if (string.IsNullOrEmpty(clipPath))
return new { success = false, message = "'clipPath' is required" };
clipPath = AssetPathUtility.SanitizeAssetPath(clipPath);
if (clipPath == null)
return new { success = false, message = "Invalid asset path" };
var clip = AssetDatabase.LoadAssetAtPath<AnimationClip>(clipPath);
if (clip == null)
return new { success = false, message = $"AnimationClip not found at '{clipPath}'" };
float time = @params["time"]?.ToObject<float>() ?? 0f;
string functionName = @params["functionName"]?.ToString();
if (string.IsNullOrEmpty(functionName))
return new { success = false, message = "'functionName' is required" };
var animEvent = new AnimationEvent
{
time = time,
functionName = functionName,
stringParameter = @params["stringParameter"]?.ToString() ?? "",
floatParameter = @params["floatParameter"]?.ToObject<float>() ?? 0f,
intParameter = @params["intParameter"]?.ToObject<int>() ?? 0
};
var events = AnimationUtility.GetAnimationEvents(clip).ToList();
events.Add(animEvent);
AnimationUtility.SetAnimationEvents(clip, events.ToArray());
EditorUtility.SetDirty(clip);
AssetDatabase.SaveAssets();
return new
{
success = true,
message = $"Added event '{functionName}' at time {time} to '{clipPath}'",
data = new
{
clipPath,
time,
functionName,
stringParameter = animEvent.stringParameter,
floatParameter = animEvent.floatParameter,
intParameter = animEvent.intParameter
}
};
}
public static object RemoveEvent(JObject @params)
{
string clipPath = @params["clipPath"]?.ToString();
if (string.IsNullOrEmpty(clipPath))
return new { success = false, message = "'clipPath' is required" };
clipPath = AssetPathUtility.SanitizeAssetPath(clipPath);
if (clipPath == null)
return new { success = false, message = "Invalid asset path" };
var clip = AssetDatabase.LoadAssetAtPath<AnimationClip>(clipPath);
if (clip == null)
return new { success = false, message = $"AnimationClip not found at '{clipPath}'" };
var events = AnimationUtility.GetAnimationEvents(clip).ToList();
int originalCount = events.Count;
int? eventIndex = @params["eventIndex"]?.ToObject<int?>();
if (eventIndex.HasValue)
{
if (eventIndex.Value < 0 || eventIndex.Value >= events.Count)
return new { success = false, message = $"Event index {eventIndex.Value} out of range (0-{events.Count - 1})" };
events.RemoveAt(eventIndex.Value);
}
else
{
string functionName = @params["functionName"]?.ToString();
if (string.IsNullOrEmpty(functionName))
return new { success = false, message = "Either 'eventIndex' or 'functionName' is required" };
float? timeFilter = @params["time"]?.ToObject<float?>();
events.RemoveAll(e =>
{
bool matchesFunction = e.functionName == functionName;
bool matchesTime = !timeFilter.HasValue || Mathf.Approximately(e.time, timeFilter.Value);
return matchesFunction && matchesTime;
});
}
int removedCount = originalCount - events.Count;
if (removedCount == 0)
return new { success = false, message = "No matching events found to remove" };
AnimationUtility.SetAnimationEvents(clip, events.ToArray());
EditorUtility.SetDirty(clip);
AssetDatabase.SaveAssets();
return new
{
success = true,
message = $"Removed {removedCount} event(s) from '{clipPath}'",
data = new
{
clipPath,
removedCount,
remainingCount = events.Count
}
};
}
private static void CreateFoldersRecursive(string folderPath)
{
if (AssetDatabase.IsValidFolder(folderPath))
return;
string parent = Path.GetDirectoryName(folderPath)?.Replace('\\', '/');
if (!string.IsNullOrEmpty(parent) && parent != "Assets" && !AssetDatabase.IsValidFolder(parent))
{
CreateFoldersRecursive(parent);
}
string folderName = Path.GetFileName(folderPath);
if (!string.IsNullOrEmpty(parent) && !string.IsNullOrEmpty(folderName))
{
AssetDatabase.CreateFolder(parent, folderName);
}
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: d7513801696d4b16b906561009481548
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,401 @@
using System;
using System.IO;
using Newtonsoft.Json.Linq;
using MCPForUnity.Editor.Helpers;
using UnityEditor;
using UnityEngine;
namespace MCPForUnity.Editor.Tools.Animation
{
internal static class ClipPresets
{
private static readonly string[] ValidPresets = { "bounce", "rotate", "pulse", "fade", "shake", "hover", "spin", "sway", "bob", "wiggle", "blink", "slide_in", "elastic", "grow", "shrink" };
public static object CreatePreset(JObject @params)
{
string clipPath = @params["clipPath"]?.ToString();
if (string.IsNullOrEmpty(clipPath))
return new { success = false, message = "'clipPath' is required (e.g. 'Assets/Animations/Bounce.anim')" };
clipPath = AssetPathUtility.SanitizeAssetPath(clipPath);
if (clipPath == null)
return new { success = false, message = "Invalid asset path" };
if (!clipPath.EndsWith(".anim", StringComparison.OrdinalIgnoreCase))
clipPath += ".anim";
string preset = @params["preset"]?.ToString()?.ToLowerInvariant();
if (string.IsNullOrEmpty(preset))
return new { success = false, message = $"'preset' is required. Valid: {string.Join(", ", ValidPresets)}" };
float duration = @params["duration"]?.ToObject<float>() ?? 1f;
float amplitude = @params["amplitude"]?.ToObject<float>() ?? 1f;
bool loop = @params["loop"]?.ToObject<bool>() ?? true;
// Resolve position offset from target GameObject or explicit offset parameter.
// localPosition rather than absolute origin, preventing objects from jumping to (0,0,0).
Vector3 offset = Vector3.zero;
var targetToken = @params["target"];
if (targetToken != null && targetToken.Type != JTokenType.Null)
{
string searchMethod = @params["searchMethod"]?.ToString();
var go = ObjectResolver.ResolveGameObject(targetToken, searchMethod);
if (go != null)
offset = go.transform.localPosition;
}
var offsetToken = @params["offset"];
if (offsetToken is JArray offsetArray && offsetArray.Count >= 3)
{
offset = new Vector3(
offsetArray[0].ToObject<float>(),
offsetArray[1].ToObject<float>(),
offsetArray[2].ToObject<float>()
);
}
string dir = Path.GetDirectoryName(clipPath)?.Replace('\\', '/');
if (!string.IsNullOrEmpty(dir) && !AssetDatabase.IsValidFolder(dir))
CreateFoldersRecursive(dir);
var existing = AssetDatabase.LoadAssetAtPath<AnimationClip>(clipPath);
if (existing != null)
return new { success = false, message = $"AnimationClip already exists at '{clipPath}'. Delete it first or use a different path." };
var clip = new AnimationClip();
clip.name = Path.GetFileNameWithoutExtension(clipPath);
clip.frameRate = 60f;
var settings = AnimationUtility.GetAnimationClipSettings(clip);
settings.loopTime = loop;
settings.stopTime = duration;
AnimationUtility.SetAnimationClipSettings(clip, settings);
switch (preset)
{
case "bounce":
ApplyBounce(clip, duration, amplitude, offset);
break;
case "rotate":
ApplyRotate(clip, duration, amplitude);
break;
case "pulse":
ApplyPulse(clip, duration, amplitude);
break;
case "fade":
ApplyFade(clip, duration);
break;
case "shake":
ApplyShake(clip, duration, amplitude, offset);
break;
case "hover":
ApplyHover(clip, duration, amplitude, offset);
break;
case "spin":
ApplySpin(clip, duration, amplitude);
break;
case "sway":
ApplySway(clip, duration, amplitude);
break;
case "bob":
ApplyBob(clip, duration, amplitude, offset);
break;
case "wiggle":
ApplyWiggle(clip, duration, amplitude);
break;
case "blink":
ApplyBlink(clip, duration);
break;
case "slide_in":
ApplySlideIn(clip, duration, amplitude, offset);
break;
case "elastic":
ApplyElastic(clip, duration, amplitude);
break;
case "grow":
ApplyGrow(clip, duration, amplitude);
break;
case "shrink":
ApplyShrink(clip, duration, amplitude);
break;
default:
return new { success = false, message = $"Unknown preset '{preset}'. Valid: {string.Join(", ", ValidPresets)}" };
}
AssetDatabase.CreateAsset(clip, clipPath);
AssetDatabase.SaveAssets();
return new
{
success = true,
message = $"Created '{preset}' preset clip at '{clipPath}'" + (offset != Vector3.zero ? $" (offset: {offset})" : ""),
data = new
{
path = clipPath,
name = clip.name,
preset,
duration,
amplitude,
isLooping = loop,
offset = new { x = offset.x, y = offset.y, z = offset.z },
curveCount = AnimationUtility.GetCurveBindings(clip).Length
}
};
}
private static void ApplyBounce(AnimationClip clip, float duration, float amplitude, Vector3 offset)
{
// localPosition.y sine wave oscillation, offset by target's current position
float half = duration * 0.5f;
var curve = new AnimationCurve(
new Keyframe(0f, offset.y),
new Keyframe(half * 0.5f, offset.y + amplitude),
new Keyframe(half, offset.y),
new Keyframe(half + half * 0.5f, offset.y + amplitude),
new Keyframe(duration, offset.y)
);
SetTransformCurve(clip, "localPosition.y", curve);
}
private static void ApplyRotate(AnimationClip clip, float duration, float amplitude)
{
// localEulerAngles.y full 360 rotation (amplitude acts as multiplier)
var curve = new AnimationCurve(
new Keyframe(0f, 0f),
new Keyframe(duration, 360f * amplitude)
);
// Linear tangents for smooth rotation
var keys = curve.keys;
keys[0].outTangent = 360f * amplitude / duration;
keys[1].inTangent = 360f * amplitude / duration;
curve.keys = keys;
SetTransformCurve(clip, "localEulerAngles.y", curve);
}
private static void ApplyPulse(AnimationClip clip, float duration, float amplitude)
{
// localScale uniform scale up/down
float peak = 1f + amplitude * 0.5f;
float half = duration * 0.5f;
var curve = new AnimationCurve(
new Keyframe(0f, 1f),
new Keyframe(half, peak),
new Keyframe(duration, 1f)
);
SetTransformCurve(clip, "localScale.x", curve);
SetTransformCurve(clip, "localScale.y", curve);
SetTransformCurve(clip, "localScale.z", curve);
}
private static void ApplyFade(AnimationClip clip, float duration)
{
// CanvasGroup alpha 1 -> 0
var curve = new AnimationCurve(
new Keyframe(0f, 1f),
new Keyframe(duration, 0f)
);
var binding = EditorCurveBinding.FloatCurve("", typeof(CanvasGroup), "m_Alpha");
AnimationUtility.SetEditorCurve(clip, binding, curve);
}
private static void ApplyShake(AnimationClip clip, float duration, float amplitude, Vector3 offset)
{
// localPosition.x/z oscillation simulating shake, centered on target's current position
int steps = 8;
float stepTime = duration / steps;
var xKeys = new Keyframe[steps + 1];
var zKeys = new Keyframe[steps + 1];
for (int i = 0; i <= steps; i++)
{
float t = i * stepTime;
float decay = 1f - (float)i / steps;
// Alternating direction with decay
float sign = (i % 2 == 0) ? 1f : -1f;
xKeys[i] = new Keyframe(t, offset.x + sign * amplitude * decay);
zKeys[i] = new Keyframe(t, offset.z - sign * amplitude * 0.5f * decay);
}
// End at offset position
xKeys[steps] = new Keyframe(duration, offset.x);
zKeys[steps] = new Keyframe(duration, offset.z);
SetTransformCurve(clip, "localPosition.x", new AnimationCurve(xKeys));
SetTransformCurve(clip, "localPosition.z", new AnimationCurve(zKeys));
}
private static void ApplyHover(AnimationClip clip, float duration, float amplitude, Vector3 offset)
{
// localPosition.y gentle sine wave, offset by target's current position
float q = duration * 0.25f;
var curve = new AnimationCurve(
new Keyframe(0f, offset.y),
new Keyframe(q, offset.y + amplitude * 0.5f),
new Keyframe(q * 2f, offset.y),
new Keyframe(q * 3f, offset.y - amplitude * 0.5f),
new Keyframe(duration, offset.y)
);
SetTransformCurve(clip, "localPosition.y", curve);
}
private static void ApplySpin(AnimationClip clip, float duration, float amplitude)
{
// localEulerAngles.z continuous rotation
var curve = new AnimationCurve(
new Keyframe(0f, 0f),
new Keyframe(duration, 360f * amplitude)
);
var keys = curve.keys;
keys[0].outTangent = 360f * amplitude / duration;
keys[1].inTangent = 360f * amplitude / duration;
curve.keys = keys;
SetTransformCurve(clip, "localEulerAngles.z", curve);
}
private static void ApplySway(AnimationClip clip, float duration, float amplitude)
{
// localEulerAngles.z gentle side-to-side rotation (sine wave)
float q = duration * 0.25f;
var curve = new AnimationCurve(
new Keyframe(0f, 0f),
new Keyframe(q, amplitude),
new Keyframe(q * 2f, 0f),
new Keyframe(q * 3f, -amplitude),
new Keyframe(duration, 0f)
);
SetTransformCurve(clip, "localEulerAngles.z", curve);
}
private static void ApplyBob(AnimationClip clip, float duration, float amplitude, Vector3 offset)
{
// localPosition.z gentle forward/back movement, offset by target's current position
float q = duration * 0.25f;
var curve = new AnimationCurve(
new Keyframe(0f, offset.z),
new Keyframe(q, offset.z + amplitude * 0.5f),
new Keyframe(q * 2f, offset.z),
new Keyframe(q * 3f, offset.z - amplitude * 0.5f),
new Keyframe(duration, offset.z)
);
SetTransformCurve(clip, "localPosition.z", curve);
}
private static void ApplyWiggle(AnimationClip clip, float duration, float amplitude)
{
// localEulerAngles.z rapid oscillation (similar to shake but rotation)
int steps = 8;
float stepTime = duration / steps;
var keys = new Keyframe[steps + 1];
for (int i = 0; i <= steps; i++)
{
float t = i * stepTime;
float decay = 1f - (float)i / steps;
float sign = (i % 2 == 0) ? 1f : -1f;
keys[i] = new Keyframe(t, sign * amplitude * decay);
}
keys[steps] = new Keyframe(duration, 0f);
SetTransformCurve(clip, "localEulerAngles.z", new AnimationCurve(keys));
}
private static void ApplyBlink(AnimationClip clip, float duration)
{
// localScale uniform scale to near-zero and back
float mid = duration * 0.5f;
var curve = new AnimationCurve(
new Keyframe(0f, 1f),
new Keyframe(mid, 0.05f),
new Keyframe(duration, 1f)
);
SetTransformCurve(clip, "localScale.x", curve);
SetTransformCurve(clip, "localScale.y", curve);
SetTransformCurve(clip, "localScale.z", curve);
}
private static void ApplySlideIn(AnimationClip clip, float duration, float amplitude, Vector3 offset)
{
// localPosition.x slide from offset-amplitude to offset (linear)
var curve = new AnimationCurve(
new Keyframe(0f, offset.x - amplitude),
new Keyframe(duration, offset.x)
);
// Set linear tangents for smooth slide
var keys = curve.keys;
keys[0].outTangent = amplitude / duration;
keys[1].inTangent = amplitude / duration;
curve.keys = keys;
SetTransformCurve(clip, "localPosition.x", curve);
}
private static void ApplyElastic(AnimationClip clip, float duration, float amplitude)
{
// localScale uniform with overshoot effect
float third = duration / 3f;
float peak = 1f + amplitude * 1.2f;
float settle = 1f + amplitude * 0.8f;
var curve = new AnimationCurve(
new Keyframe(0f, 1f),
new Keyframe(third, peak),
new Keyframe(third * 2f, settle),
new Keyframe(duration, 1f)
);
SetTransformCurve(clip, "localScale.x", curve);
SetTransformCurve(clip, "localScale.y", curve);
SetTransformCurve(clip, "localScale.z", curve);
}
private static void ApplyGrow(AnimationClip clip, float duration, float amplitude)
{
// localScale uniform from a reduced value up to 1.0
float clamped = Mathf.Max(0f, amplitude);
float start = Mathf.Clamp01(1f - clamped);
var curve = new AnimationCurve(
new Keyframe(0f, start),
new Keyframe(duration, 1f)
);
SetTransformCurve(clip, "localScale.x", curve);
SetTransformCurve(clip, "localScale.y", curve);
SetTransformCurve(clip, "localScale.z", curve);
}
private static void ApplyShrink(AnimationClip clip, float duration, float amplitude)
{
// localScale uniform from 1.0 down to a reduced value
float clamped = Mathf.Max(0f, amplitude);
float end = Mathf.Clamp01(1f - clamped);
var curve = new AnimationCurve(
new Keyframe(0f, 1f),
new Keyframe(duration, end)
);
SetTransformCurve(clip, "localScale.x", curve);
SetTransformCurve(clip, "localScale.y", curve);
SetTransformCurve(clip, "localScale.z", curve);
}
/// <summary>
/// Sets an animation curve on a Transform property using AnimationUtility.SetEditorCurve
/// instead of clip.SetCurve to avoid marking the clip as legacy. Legacy clips cannot be
/// used with Mecanim AnimatorControllers, and legacy Animation components take control of
/// the entire Vector3 property (zeroing non-animated axes).
/// </summary>
private static void SetTransformCurve(AnimationClip clip, string propertyName, AnimationCurve curve)
{
var binding = EditorCurveBinding.FloatCurve("", typeof(Transform), propertyName);
AnimationUtility.SetEditorCurve(clip, binding, curve);
}
private static void CreateFoldersRecursive(string folderPath)
{
if (AssetDatabase.IsValidFolder(folderPath))
return;
string parent = Path.GetDirectoryName(folderPath)?.Replace('\\', '/');
if (!string.IsNullOrEmpty(parent) && parent != "Assets" && !AssetDatabase.IsValidFolder(parent))
CreateFoldersRecursive(parent);
string folderName = Path.GetFileName(folderPath);
if (!string.IsNullOrEmpty(parent) && !string.IsNullOrEmpty(folderName))
AssetDatabase.CreateFolder(parent, folderName);
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: b5e91d58c0a24e6db187f2a3c6840e29
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,255 @@
using System;
using Newtonsoft.Json.Linq;
using MCPForUnity.Editor.Helpers;
using UnityEditor;
using UnityEditor.Animations;
using UnityEngine;
namespace MCPForUnity.Editor.Tools.Animation
{
internal static class ControllerBlendTrees
{
public static object CreateBlendTree1D(JObject @params)
{
string controllerPath = @params["controllerPath"]?.ToString();
if (string.IsNullOrEmpty(controllerPath))
return new { success = false, message = "'controllerPath' is required" };
controllerPath = AssetPathUtility.SanitizeAssetPath(controllerPath);
if (controllerPath == null)
return new { success = false, message = "Invalid asset path" };
var controller = AssetDatabase.LoadAssetAtPath<AnimatorController>(controllerPath);
if (controller == null)
return new { success = false, message = $"AnimatorController not found at '{controllerPath}'" };
string stateName = @params["stateName"]?.ToString();
if (string.IsNullOrEmpty(stateName))
return new { success = false, message = "'stateName' is required" };
string blendParameter = @params["blendParameter"]?.ToString();
if (string.IsNullOrEmpty(blendParameter))
return new { success = false, message = "'blendParameter' is required" };
int layerIndex = @params["layerIndex"]?.ToObject<int>() ?? 0;
var layers = controller.layers;
if (layerIndex < 0 || layerIndex >= layers.Length)
return new { success = false, message = $"Layer index {layerIndex} out of range (0-{layers.Length - 1})" };
var stateMachine = layers[layerIndex].stateMachine;
Undo.RecordObject(controller, "Create Blend Tree 1D");
var state = stateMachine.AddState(stateName);
var blendTree = new BlendTree
{
name = stateName,
blendType = BlendTreeType.Simple1D,
blendParameter = blendParameter,
hideFlags = HideFlags.HideInHierarchy
};
AssetDatabase.AddObjectToAsset(blendTree, controller);
state.motion = blendTree;
EditorUtility.SetDirty(controller);
AssetDatabase.SaveAssets();
return new
{
success = true,
message = $"Created 1D blend tree state '{stateName}' in '{controllerPath}'",
data = new
{
controllerPath,
stateName,
layerIndex,
blendParameter,
blendType = "Simple1D"
}
};
}
public static object CreateBlendTree2D(JObject @params)
{
string controllerPath = @params["controllerPath"]?.ToString();
if (string.IsNullOrEmpty(controllerPath))
return new { success = false, message = "'controllerPath' is required" };
controllerPath = AssetPathUtility.SanitizeAssetPath(controllerPath);
if (controllerPath == null)
return new { success = false, message = "Invalid asset path" };
var controller = AssetDatabase.LoadAssetAtPath<AnimatorController>(controllerPath);
if (controller == null)
return new { success = false, message = $"AnimatorController not found at '{controllerPath}'" };
string stateName = @params["stateName"]?.ToString();
if (string.IsNullOrEmpty(stateName))
return new { success = false, message = "'stateName' is required" };
string blendParameterX = @params["blendParameterX"]?.ToString();
string blendParameterY = @params["blendParameterY"]?.ToString();
if (string.IsNullOrEmpty(blendParameterX) || string.IsNullOrEmpty(blendParameterY))
return new { success = false, message = "'blendParameterX' and 'blendParameterY' are required" };
int layerIndex = @params["layerIndex"]?.ToObject<int>() ?? 0;
string blendTypeStr = @params["blendType"]?.ToString()?.ToLowerInvariant() ?? "simpledirectional2d";
BlendTreeType blendType = blendTypeStr switch
{
"freeformdirectional2d" => BlendTreeType.FreeformDirectional2D,
"freeformcartesian2d" => BlendTreeType.FreeformCartesian2D,
_ => BlendTreeType.SimpleDirectional2D
};
var layers = controller.layers;
if (layerIndex < 0 || layerIndex >= layers.Length)
return new { success = false, message = $"Layer index {layerIndex} out of range (0-{layers.Length - 1})" };
var stateMachine = layers[layerIndex].stateMachine;
Undo.RecordObject(controller, "Create Blend Tree 2D");
var state = stateMachine.AddState(stateName);
var blendTree = new BlendTree
{
name = stateName,
blendType = blendType,
blendParameter = blendParameterX,
blendParameterY = blendParameterY,
hideFlags = HideFlags.HideInHierarchy
};
AssetDatabase.AddObjectToAsset(blendTree, controller);
state.motion = blendTree;
EditorUtility.SetDirty(controller);
AssetDatabase.SaveAssets();
return new
{
success = true,
message = $"Created 2D blend tree state '{stateName}' in '{controllerPath}'",
data = new
{
controllerPath,
stateName,
layerIndex,
blendParameterX,
blendParameterY,
blendType = blendType.ToString()
}
};
}
public static object AddBlendTreeChild(JObject @params)
{
string controllerPath = @params["controllerPath"]?.ToString();
if (string.IsNullOrEmpty(controllerPath))
return new { success = false, message = "'controllerPath' is required" };
controllerPath = AssetPathUtility.SanitizeAssetPath(controllerPath);
if (controllerPath == null)
return new { success = false, message = "Invalid asset path" };
var controller = AssetDatabase.LoadAssetAtPath<AnimatorController>(controllerPath);
if (controller == null)
return new { success = false, message = $"AnimatorController not found at '{controllerPath}'" };
string stateName = @params["stateName"]?.ToString();
if (string.IsNullOrEmpty(stateName))
return new { success = false, message = "'stateName' is required" };
string clipPath = @params["clipPath"]?.ToString();
if (string.IsNullOrEmpty(clipPath))
return new { success = false, message = "'clipPath' is required" };
clipPath = AssetPathUtility.SanitizeAssetPath(clipPath);
var clip = AssetDatabase.LoadAssetAtPath<AnimationClip>(clipPath);
if (clip == null)
return new { success = false, message = $"AnimationClip not found at '{clipPath}'" };
int layerIndex = @params["layerIndex"]?.ToObject<int>() ?? 0;
var layers = controller.layers;
if (layerIndex < 0 || layerIndex >= layers.Length)
return new { success = false, message = $"Layer index {layerIndex} out of range (0-{layers.Length - 1})" };
var stateMachine = layers[layerIndex].stateMachine;
AnimatorState state = null;
foreach (var s in stateMachine.states)
{
if (s.state.name == stateName)
{
state = s.state;
break;
}
}
if (state == null)
return new { success = false, message = $"State '{stateName}' not found in layer {layerIndex}" };
if (!(state.motion is BlendTree blendTree))
return new { success = false, message = $"State '{stateName}' does not have a BlendTree motion" };
Undo.RecordObject(blendTree, "Add Blend Tree Child");
if (blendTree.blendType == BlendTreeType.Simple1D)
{
float? threshold = @params["threshold"]?.ToObject<float?>();
if (!threshold.HasValue)
return new { success = false, message = "'threshold' is required for 1D blend trees" };
blendTree.AddChild(clip, threshold.Value);
EditorUtility.SetDirty(blendTree);
EditorUtility.SetDirty(controller);
AssetDatabase.SaveAssets();
return new
{
success = true,
message = $"Added clip '{clip.name}' to blend tree '{stateName}' at threshold {threshold.Value}",
data = new
{
controllerPath,
stateName,
clipPath,
threshold = threshold.Value,
childCount = blendTree.children.Length
}
};
}
else
{
JToken positionToken = @params["position"];
if (positionToken == null || !(positionToken is JArray posArray) || posArray.Count < 2)
return new { success = false, message = "'position' is required for 2D blend trees as [x, y]" };
float posX = posArray[0].ToObject<float>();
float posY = posArray[1].ToObject<float>();
Vector2 position = new Vector2(posX, posY);
blendTree.AddChild(clip, position);
EditorUtility.SetDirty(blendTree);
EditorUtility.SetDirty(controller);
AssetDatabase.SaveAssets();
return new
{
success = true,
message = $"Added clip '{clip.name}' to blend tree '{stateName}' at position ({posX}, {posY})",
data = new
{
controllerPath,
stateName,
clipPath,
position = new { x = posX, y = posY },
childCount = blendTree.children.Length
}
};
}
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: b8d4f0a2e53c5b7f9a6e1d4c8f0b2a5e
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,458 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using Newtonsoft.Json.Linq;
using MCPForUnity.Editor.Helpers;
using UnityEditor;
using UnityEditor.Animations;
using UnityEngine;
namespace MCPForUnity.Editor.Tools.Animation
{
internal static class ControllerCreate
{
public static object Create(JObject @params)
{
string controllerPath = @params["controllerPath"]?.ToString();
if (string.IsNullOrEmpty(controllerPath))
return new { success = false, message = "'controllerPath' is required (e.g. 'Assets/Animations/Player.controller')" };
controllerPath = AssetPathUtility.SanitizeAssetPath(controllerPath);
if (controllerPath == null)
return new { success = false, message = "Invalid asset path" };
if (!controllerPath.EndsWith(".controller", StringComparison.OrdinalIgnoreCase))
controllerPath += ".controller";
string dir = Path.GetDirectoryName(controllerPath)?.Replace('\\', '/');
if (!string.IsNullOrEmpty(dir) && !AssetDatabase.IsValidFolder(dir))
CreateFoldersRecursive(dir);
var existing = AssetDatabase.LoadAssetAtPath<AnimatorController>(controllerPath);
if (existing != null)
return new { success = false, message = $"AnimatorController already exists at '{controllerPath}'. Delete it first or use a different path." };
var controller = AnimatorController.CreateAnimatorControllerAtPath(controllerPath);
AssetDatabase.SaveAssets();
return new
{
success = true,
message = $"Created AnimatorController at '{controllerPath}'",
data = new
{
path = controllerPath,
name = controller.name,
layerCount = controller.layers.Length,
parameterCount = controller.parameters.Length
}
};
}
public static object AddState(JObject @params)
{
var controller = LoadController(@params);
if (controller == null)
return ControllerNotFoundError(@params);
string stateName = @params["stateName"]?.ToString();
if (string.IsNullOrEmpty(stateName))
return new { success = false, message = "'stateName' is required" };
int layerIndex = @params["layerIndex"]?.ToObject<int>() ?? 0;
if (layerIndex < 0 || layerIndex >= controller.layers.Length)
return new { success = false, message = $"Layer index {layerIndex} out of range (controller has {controller.layers.Length} layers)" };
var rootStateMachine = controller.layers[layerIndex].stateMachine;
// Check for duplicate state name
foreach (var existingState in rootStateMachine.states)
{
if (existingState.state.name == stateName)
return new { success = false, message = $"State '{stateName}' already exists in layer {layerIndex}" };
}
var state = rootStateMachine.AddState(stateName);
// Optionally assign a clip
string clipPath = @params["clipPath"]?.ToString();
if (!string.IsNullOrEmpty(clipPath))
{
clipPath = AssetPathUtility.SanitizeAssetPath(clipPath);
if (clipPath != null)
{
var clip = AssetDatabase.LoadAssetAtPath<AnimationClip>(clipPath);
if (clip != null)
state.motion = clip;
}
}
float speed = @params["speed"]?.ToObject<float>() ?? 1f;
state.speed = speed;
bool isDefault = @params["isDefault"]?.ToObject<bool>() ?? false;
if (isDefault)
rootStateMachine.defaultState = state;
EditorUtility.SetDirty(controller);
AssetDatabase.SaveAssets();
return new
{
success = true,
message = $"Added state '{stateName}' to layer {layerIndex}",
data = new
{
stateName,
layerIndex,
hasMotion = state.motion != null,
speed = state.speed,
isDefault
}
};
}
public static object AddTransition(JObject @params)
{
var controller = LoadController(@params);
if (controller == null)
return ControllerNotFoundError(@params);
string fromStateName = @params["fromState"]?.ToString();
string toStateName = @params["toState"]?.ToString();
if (string.IsNullOrEmpty(fromStateName) || string.IsNullOrEmpty(toStateName))
return new { success = false, message = "'fromState' and 'toState' are required" };
int layerIndex = @params["layerIndex"]?.ToObject<int>() ?? 0;
if (layerIndex < 0 || layerIndex >= controller.layers.Length)
return new { success = false, message = $"Layer index {layerIndex} out of range" };
var rootStateMachine = controller.layers[layerIndex].stateMachine;
// Check for AnyState as source
bool isAnyState = string.Equals(fromStateName, "AnyState", StringComparison.OrdinalIgnoreCase)
|| string.Equals(fromStateName, "Any", StringComparison.OrdinalIgnoreCase)
|| string.Equals(fromStateName, "Any State", StringComparison.OrdinalIgnoreCase);
AnimatorState toState = null;
foreach (var cs in rootStateMachine.states)
{
if (cs.state.name == toStateName) toState = cs.state;
}
if (toState == null)
return new { success = false, message = $"State '{toStateName}' not found in layer {layerIndex}" };
AnimatorStateTransition transition;
if (isAnyState)
{
transition = rootStateMachine.AddAnyStateTransition(toState);
fromStateName = "AnyState";
}
else
{
AnimatorState fromState = null;
foreach (var cs in rootStateMachine.states)
{
if (cs.state.name == fromStateName) fromState = cs.state;
}
if (fromState == null)
return new { success = false, message = $"State '{fromStateName}' not found in layer {layerIndex}" };
transition = fromState.AddTransition(toState);
}
bool hasExitTime = @params["hasExitTime"]?.ToObject<bool>() ?? true;
transition.hasExitTime = hasExitTime;
float duration = @params["duration"]?.ToObject<float>() ?? 0.25f;
transition.duration = duration;
float exitTime = @params["exitTime"]?.ToObject<float>() ?? 0.75f;
transition.exitTime = exitTime;
// Add conditions
JToken conditionsToken = @params["conditions"];
int conditionCount = 0;
if (conditionsToken is JArray conditionsArray)
{
foreach (var condItem in conditionsArray)
{
if (condItem is not JObject condObj) continue;
string paramName = condObj["parameter"]?.ToString();
if (string.IsNullOrEmpty(paramName)) continue;
string modeStr = condObj["mode"]?.ToString()?.ToLowerInvariant() ?? "greater";
float threshold = condObj["threshold"]?.ToObject<float>() ?? 0f;
AnimatorConditionMode mode;
switch (modeStr)
{
case "greater": mode = AnimatorConditionMode.Greater; break;
case "less": mode = AnimatorConditionMode.Less; break;
case "equals": mode = AnimatorConditionMode.Equals; break;
case "notequal":
case "not_equal": mode = AnimatorConditionMode.NotEqual; break;
case "if":
case "true": mode = AnimatorConditionMode.If; break;
case "ifnot":
case "if_not":
case "false": mode = AnimatorConditionMode.IfNot; break;
default: mode = AnimatorConditionMode.Greater; break;
}
transition.AddCondition(mode, threshold, paramName);
conditionCount++;
}
}
EditorUtility.SetDirty(controller);
AssetDatabase.SaveAssets();
return new
{
success = true,
message = $"Added transition from '{fromStateName}' to '{toStateName}' with {conditionCount} conditions",
data = new
{
fromState = fromStateName,
toState = toStateName,
hasExitTime,
duration,
conditionCount
}
};
}
public static object AddParameter(JObject @params)
{
var controller = LoadController(@params);
if (controller == null)
return ControllerNotFoundError(@params);
string paramName = @params["parameterName"]?.ToString();
if (string.IsNullOrEmpty(paramName))
return new { success = false, message = "'parameterName' is required" };
string typeStr = @params["parameterType"]?.ToString()?.ToLowerInvariant() ?? "float";
AnimatorControllerParameterType paramType;
switch (typeStr)
{
case "float": paramType = AnimatorControllerParameterType.Float; break;
case "int":
case "integer": paramType = AnimatorControllerParameterType.Int; break;
case "bool":
case "boolean": paramType = AnimatorControllerParameterType.Bool; break;
case "trigger": paramType = AnimatorControllerParameterType.Trigger; break;
default:
return new { success = false, message = $"Unknown parameter type '{typeStr}'. Valid: float, int, bool, trigger" };
}
// Check for duplicate
foreach (var existing in controller.parameters)
{
if (existing.name == paramName)
return new { success = false, message = $"Parameter '{paramName}' already exists" };
}
controller.AddParameter(paramName, paramType);
// Set default value if provided
JToken defaultValue = @params["defaultValue"];
if (defaultValue != null)
{
var allParams = controller.parameters;
var addedParam = allParams[allParams.Length - 1];
switch (paramType)
{
case AnimatorControllerParameterType.Float:
addedParam.defaultFloat = defaultValue.ToObject<float>();
break;
case AnimatorControllerParameterType.Int:
addedParam.defaultInt = defaultValue.ToObject<int>();
break;
case AnimatorControllerParameterType.Bool:
addedParam.defaultBool = defaultValue.ToObject<bool>();
break;
}
controller.parameters = allParams;
}
EditorUtility.SetDirty(controller);
AssetDatabase.SaveAssets();
return new
{
success = true,
message = $"Added {typeStr} parameter '{paramName}'",
data = new
{
parameterName = paramName,
parameterType = typeStr,
totalParameters = controller.parameters.Length
}
};
}
public static object GetInfo(JObject @params)
{
var controller = LoadController(@params);
if (controller == null)
return ControllerNotFoundError(@params);
var layers = new List<object>();
for (int i = 0; i < controller.layers.Length; i++)
{
var layer = controller.layers[i];
var states = new List<object>();
foreach (var cs in layer.stateMachine.states)
{
var transitions = new List<object>();
foreach (var t in cs.state.transitions)
{
var conditions = new List<object>();
foreach (var c in t.conditions)
{
conditions.Add(new
{
parameter = c.parameter,
mode = c.mode.ToString(),
threshold = c.threshold
});
}
transitions.Add(new
{
destinationState = t.destinationState?.name,
hasExitTime = t.hasExitTime,
exitTime = t.exitTime,
duration = t.duration,
conditionCount = t.conditions.Length,
conditions
});
}
states.Add(new
{
name = cs.state.name,
speed = cs.state.speed,
hasMotion = cs.state.motion != null,
motionName = cs.state.motion?.name,
isDefault = layer.stateMachine.defaultState == cs.state,
transitionCount = cs.state.transitions.Length,
transitions
});
}
layers.Add(new
{
index = i,
name = layer.name,
stateCount = layer.stateMachine.states.Length,
states
});
}
var parameters = new List<object>();
foreach (var p in controller.parameters)
{
parameters.Add(new
{
name = p.name,
type = p.type.ToString(),
defaultFloat = p.defaultFloat,
defaultInt = p.defaultInt,
defaultBool = p.defaultBool
});
}
return new
{
success = true,
data = new
{
path = AssetDatabase.GetAssetPath(controller),
name = controller.name,
layerCount = controller.layers.Length,
parameterCount = controller.parameters.Length,
layers,
parameters
}
};
}
public static object AssignToGameObject(JObject @params)
{
var controller = LoadController(@params);
if (controller == null)
return ControllerNotFoundError(@params);
var go = ObjectResolver.ResolveGameObject(@params["target"], @params["searchMethod"]?.ToString());
if (go == null)
return new { success = false, message = "Target GameObject not found" };
var animator = go.GetComponent<Animator>();
if (animator == null)
{
Undo.RecordObject(go, "Add Animator Component");
animator = Undo.AddComponent<Animator>(go);
}
Undo.RecordObject(animator, "Assign AnimatorController");
animator.runtimeAnimatorController = controller;
EditorUtility.SetDirty(go);
AssetDatabase.SaveAssets();
return new
{
success = true,
message = $"Assigned controller '{controller.name}' to '{go.name}'",
data = new
{
gameObject = go.name,
controllerName = controller.name,
controllerPath = AssetDatabase.GetAssetPath(controller)
}
};
}
private static AnimatorController LoadController(JObject @params)
{
string controllerPath = @params["controllerPath"]?.ToString();
if (string.IsNullOrEmpty(controllerPath))
return null;
controllerPath = AssetPathUtility.SanitizeAssetPath(controllerPath);
if (controllerPath == null)
return null;
return AssetDatabase.LoadAssetAtPath<AnimatorController>(controllerPath);
}
private static object ControllerNotFoundError(JObject @params)
{
string path = @params["controllerPath"]?.ToString() ?? "(not specified)";
return new { success = false, message = $"AnimatorController not found at '{path}'. Provide a valid 'controllerPath'." };
}
private static void CreateFoldersRecursive(string folderPath)
{
if (AssetDatabase.IsValidFolder(folderPath))
return;
string parent = Path.GetDirectoryName(folderPath)?.Replace('\\', '/');
if (!string.IsNullOrEmpty(parent) && parent != "Assets" && !AssetDatabase.IsValidFolder(parent))
CreateFoldersRecursive(parent);
string folderName = Path.GetFileName(folderPath);
if (!string.IsNullOrEmpty(parent) && !string.IsNullOrEmpty(folderName))
AssetDatabase.CreateFolder(parent, folderName);
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: a3f82c47d9e14b8fa0c5e1b7d4923f16
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,202 @@
using System;
using Newtonsoft.Json.Linq;
using MCPForUnity.Editor.Helpers;
using UnityEditor;
using UnityEditor.Animations;
namespace MCPForUnity.Editor.Tools.Animation
{
internal static class ControllerLayers
{
public static object AddLayer(JObject @params)
{
string controllerPath = @params["controllerPath"]?.ToString();
if (string.IsNullOrEmpty(controllerPath))
return new { success = false, message = "'controllerPath' is required" };
controllerPath = AssetPathUtility.SanitizeAssetPath(controllerPath);
if (controllerPath == null)
return new { success = false, message = "Invalid asset path" };
var controller = AssetDatabase.LoadAssetAtPath<AnimatorController>(controllerPath);
if (controller == null)
return new { success = false, message = $"AnimatorController not found at '{controllerPath}'" };
string layerName = @params["layerName"]?.ToString();
if (string.IsNullOrEmpty(layerName))
return new { success = false, message = "'layerName' is required" };
float weight = @params["weight"]?.ToObject<float>() ?? 1f;
string blendingModeStr = @params["blendingMode"]?.ToString()?.ToLowerInvariant() ?? "override";
AnimatorLayerBlendingMode blendingMode = blendingModeStr == "additive"
? AnimatorLayerBlendingMode.Additive
: AnimatorLayerBlendingMode.Override;
Undo.RecordObject(controller, "Add Layer");
controller.AddLayer(layerName);
var layers = controller.layers;
var newLayer = layers[layers.Length - 1];
newLayer.defaultWeight = weight;
newLayer.blendingMode = blendingMode;
layers[layers.Length - 1] = newLayer;
controller.layers = layers;
EditorUtility.SetDirty(controller);
AssetDatabase.SaveAssets();
return new
{
success = true,
message = $"Added layer '{layerName}' to '{controllerPath}'",
data = new
{
controllerPath,
layerName,
layerIndex = layers.Length - 1,
weight,
blendingMode = blendingMode.ToString()
}
};
}
public static object RemoveLayer(JObject @params)
{
string controllerPath = @params["controllerPath"]?.ToString();
if (string.IsNullOrEmpty(controllerPath))
return new { success = false, message = "'controllerPath' is required" };
controllerPath = AssetPathUtility.SanitizeAssetPath(controllerPath);
if (controllerPath == null)
return new { success = false, message = "Invalid asset path" };
var controller = AssetDatabase.LoadAssetAtPath<AnimatorController>(controllerPath);
if (controller == null)
return new { success = false, message = $"AnimatorController not found at '{controllerPath}'" };
int? layerIndex = @params["layerIndex"]?.ToObject<int?>();
string layerName = @params["layerName"]?.ToString();
if (!layerIndex.HasValue && string.IsNullOrEmpty(layerName))
return new { success = false, message = "Either 'layerIndex' or 'layerName' is required" };
var layers = controller.layers;
if (layerIndex.HasValue)
{
if (layerIndex.Value < 0 || layerIndex.Value >= layers.Length)
return new { success = false, message = $"Layer index {layerIndex.Value} out of range (0-{layers.Length - 1})" };
if (layerIndex.Value == 0)
return new { success = false, message = "Cannot remove base layer (index 0)" };
layerName = layers[layerIndex.Value].name;
}
else
{
layerIndex = -1;
for (int i = 0; i < layers.Length; i++)
{
if (layers[i].name == layerName)
{
layerIndex = i;
break;
}
}
if (layerIndex.Value < 0)
return new { success = false, message = $"Layer '{layerName}' not found" };
if (layerIndex.Value == 0)
return new { success = false, message = $"Cannot remove base layer '{layerName}'" };
}
Undo.RecordObject(controller, "Remove Layer");
controller.RemoveLayer(layerIndex.Value);
EditorUtility.SetDirty(controller);
AssetDatabase.SaveAssets();
return new
{
success = true,
message = $"Removed layer '{layerName}' from '{controllerPath}'",
data = new
{
controllerPath,
layerName,
layerIndex = layerIndex.Value
}
};
}
public static object SetLayerWeight(JObject @params)
{
string controllerPath = @params["controllerPath"]?.ToString();
if (string.IsNullOrEmpty(controllerPath))
return new { success = false, message = "'controllerPath' is required" };
controllerPath = AssetPathUtility.SanitizeAssetPath(controllerPath);
if (controllerPath == null)
return new { success = false, message = "Invalid asset path" };
var controller = AssetDatabase.LoadAssetAtPath<AnimatorController>(controllerPath);
if (controller == null)
return new { success = false, message = $"AnimatorController not found at '{controllerPath}'" };
int? layerIndex = @params["layerIndex"]?.ToObject<int?>();
string layerName = @params["layerName"]?.ToString();
if (!layerIndex.HasValue && string.IsNullOrEmpty(layerName))
return new { success = false, message = "Either 'layerIndex' or 'layerName' is required" };
float weight = @params["weight"]?.ToObject<float>() ?? 1f;
var layers = controller.layers;
if (layerIndex.HasValue)
{
if (layerIndex.Value < 0 || layerIndex.Value >= layers.Length)
return new { success = false, message = $"Layer index {layerIndex.Value} out of range (0-{layers.Length - 1})" };
layerName = layers[layerIndex.Value].name;
}
else
{
layerIndex = -1;
for (int i = 0; i < layers.Length; i++)
{
if (layers[i].name == layerName)
{
layerIndex = i;
break;
}
}
if (layerIndex.Value < 0)
return new { success = false, message = $"Layer '{layerName}' not found" };
}
Undo.RecordObject(controller, "Set Layer Weight");
var layer = layers[layerIndex.Value];
layer.defaultWeight = weight;
layers[layerIndex.Value] = layer;
controller.layers = layers;
EditorUtility.SetDirty(controller);
AssetDatabase.SaveAssets();
return new
{
success = true,
message = $"Set layer '{layerName}' weight to {weight}",
data = new
{
controllerPath,
layerName,
layerIndex = layerIndex.Value,
weight
}
};
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: a7c3e9f1d42b4a6e8f5d0c3b7e9a1f4d
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,253 @@
using System;
using System.Collections.Generic;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using MCPForUnity.Editor.Helpers;
using UnityEngine;
namespace MCPForUnity.Editor.Tools.Animation
{
[McpForUnityTool("manage_animation", AutoRegister = false, Group = "animation")]
public static class ManageAnimation
{
private static readonly Dictionary<string, string> ParamAliases = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
{ "clip_path", "clipPath" },
{ "controller_path", "controllerPath" },
{ "state_name", "stateName" },
{ "from_state", "fromState" },
{ "to_state", "toState" },
{ "parameter_name", "parameterName" },
{ "parameter_type", "parameterType" },
{ "property_path", "propertyPath" },
{ "default_value", "defaultValue" },
{ "has_exit_time", "hasExitTime" },
{ "exit_time", "exitTime" },
{ "layer_index", "layerIndex" },
{ "is_default", "isDefault" },
{ "relative_path", "relativePath" },
{ "function_name", "functionName" },
{ "string_parameter", "stringParameter" },
{ "float_parameter", "floatParameter" },
{ "int_parameter", "intParameter" },
{ "event_index", "eventIndex" },
{ "layer_name", "layerName" },
{ "blending_mode", "blendingMode" },
{ "blend_parameter", "blendParameter" },
{ "blend_parameter_x", "blendParameterX" },
{ "blend_parameter_y", "blendParameterY" },
{ "blend_type", "blendType" },
};
private static JObject NormalizeParams(JObject source)
{
if (source == null)
{
return new JObject();
}
var normalized = new JObject();
var properties = ExtractProperties(source);
if (properties != null)
{
foreach (var prop in properties.Properties())
{
normalized[NormalizeKey(prop.Name, true)] = NormalizeToken(prop.Value);
}
}
foreach (var prop in source.Properties())
{
if (string.Equals(prop.Name, "properties", StringComparison.OrdinalIgnoreCase))
{
continue;
}
normalized[NormalizeKey(prop.Name, true)] = NormalizeToken(prop.Value);
}
return normalized;
}
private static JObject ExtractProperties(JObject source)
{
if (source == null)
{
return null;
}
if (!source.TryGetValue("properties", StringComparison.OrdinalIgnoreCase, out var token))
{
return null;
}
if (token == null || token.Type == JTokenType.Null)
{
return null;
}
if (token is JObject obj)
{
return obj;
}
if (token.Type == JTokenType.String)
{
try
{
return JToken.Parse(token.ToString()) as JObject;
}
catch (JsonException ex)
{
throw new JsonException(
$"Failed to parse 'properties' JSON string. Raw value: {token}",
ex);
}
}
return null;
}
private static string NormalizeKey(string key, bool allowAliases)
{
if (string.IsNullOrEmpty(key))
{
return key;
}
if (string.Equals(key, "action", StringComparison.OrdinalIgnoreCase))
{
return "action";
}
if (allowAliases && ParamAliases.TryGetValue(key, out var alias))
{
return alias;
}
if (key.IndexOf('_') >= 0)
{
return StringCaseUtility.ToCamelCase(key);
}
return key;
}
private static JToken NormalizeToken(JToken token)
{
if (token == null)
{
return null;
}
if (token is JObject obj)
{
var normalized = new JObject();
foreach (var prop in obj.Properties())
{
normalized[NormalizeKey(prop.Name, false)] = NormalizeToken(prop.Value);
}
return normalized;
}
if (token is JArray array)
{
var normalized = new JArray();
foreach (var item in array)
{
normalized.Add(NormalizeToken(item));
}
return normalized;
}
return token;
}
public static object HandleCommand(JObject @params)
{
JObject normalizedParams = NormalizeParams(@params);
string action = normalizedParams["action"]?.ToString();
if (string.IsNullOrEmpty(action))
{
return new { success = false, message = "Action is required" };
}
try
{
string actionLower = action.ToLowerInvariant();
if (actionLower.StartsWith("animator_"))
{
return HandleAnimatorAction(normalizedParams, actionLower.Substring(9));
}
if (actionLower.StartsWith("controller_"))
{
return HandleControllerAction(normalizedParams, actionLower.Substring(11));
}
if (actionLower.StartsWith("clip_"))
{
return HandleClipAction(normalizedParams, actionLower.Substring(5));
}
return new { success = false, message = $"Unknown action: {action}. Actions must be prefixed with: animator_, controller_, or clip_" };
}
catch (Exception e)
{
McpLog.Error($"[ManageAnimation] Action '{action}' failed: {e}");
return new ErrorResponse($"Internal error processing action '{action}': {e.Message}");
}
}
private static object HandleAnimatorAction(JObject @params, string action)
{
switch (action)
{
case "get_info": return AnimatorRead.GetInfo(@params);
case "get_parameter": return AnimatorRead.GetParameter(@params);
case "play": return AnimatorControl.Play(@params);
case "crossfade": return AnimatorControl.Crossfade(@params);
case "set_parameter": return AnimatorControl.SetParameter(@params);
case "set_speed": return AnimatorControl.SetSpeed(@params);
case "set_enabled": return AnimatorControl.SetEnabled(@params);
default:
return new { success = false, message = $"Unknown animator action: {action}. Valid: get_info, get_parameter, play, crossfade, set_parameter, set_speed, set_enabled" };
}
}
private static object HandleControllerAction(JObject @params, string action)
{
switch (action)
{
case "create": return ControllerCreate.Create(@params);
case "add_state": return ControllerCreate.AddState(@params);
case "add_transition": return ControllerCreate.AddTransition(@params);
case "add_parameter": return ControllerCreate.AddParameter(@params);
case "get_info": return ControllerCreate.GetInfo(@params);
case "assign": return ControllerCreate.AssignToGameObject(@params);
case "add_layer": return ControllerLayers.AddLayer(@params);
case "remove_layer": return ControllerLayers.RemoveLayer(@params);
case "set_layer_weight": return ControllerLayers.SetLayerWeight(@params);
case "create_blend_tree_1d": return ControllerBlendTrees.CreateBlendTree1D(@params);
case "create_blend_tree_2d": return ControllerBlendTrees.CreateBlendTree2D(@params);
case "add_blend_tree_child": return ControllerBlendTrees.AddBlendTreeChild(@params);
default:
return new { success = false, message = $"Unknown controller action: {action}. Valid: create, add_state, add_transition, add_parameter, get_info, assign, add_layer, remove_layer, set_layer_weight, create_blend_tree_1d, create_blend_tree_2d, add_blend_tree_child" };
}
}
private static object HandleClipAction(JObject @params, string action)
{
switch (action)
{
case "create": return ClipCreate.Create(@params);
case "get_info": return ClipCreate.GetInfo(@params);
case "add_curve": return ClipCreate.AddCurve(@params);
case "set_curve": return ClipCreate.SetCurve(@params);
case "set_vector_curve": return ClipCreate.SetVectorCurve(@params);
case "create_preset": return ClipPresets.CreatePreset(@params);
case "assign": return ClipCreate.Assign(@params);
case "add_event": return ClipCreate.AddEvent(@params);
case "remove_event": return ClipCreate.RemoveEvent(@params);
default:
return new { success = false, message = $"Unknown clip action: {action}. Valid: create, get_info, add_curve, set_curve, set_vector_curve, create_preset, assign, add_event, remove_event" };
}
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 13c8f20dbd31461796f64c421b0a1239
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
+8
View File
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 9ba1214fff954a9498b166afcda0dc4b
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,151 @@
using System;
using System.Collections.Generic;
using MCPForUnity.Editor.Helpers;
using MCPForUnity.Editor.Security;
using MCPForUnity.Editor.Services.AssetGen;
using MCPForUnity.Editor.Services.AssetGen.Providers;
using Newtonsoft.Json.Linq;
namespace MCPForUnity.Editor.Tools.AssetGen
{
/// <summary>
/// 2D image generation via an aggregator (fal.ai / OpenRouter). Triggered here (never from the
/// GUI); the C# side reads the provider key from the secure store and runs the job. Returns a
/// job_id immediately; the client polls the `status` action.
/// </summary>
[McpForUnityTool("generate_image", AutoRegister = false, Group = "asset_gen", RequiresPolling = true, PollAction = "status", MaxPollSeconds = 300)]
public static class GenerateImage
{
public static object HandleCommand(JObject @params)
{
if (@params == null) return new ErrorResponse("Parameters cannot be null.");
var p = new ToolParams(@params);
string action = (p.Get("action") ?? string.Empty).ToLowerInvariant();
try
{
switch (action)
{
case "generate": return Generate(p);
case "remove_background":
return new ErrorResponse("remove_background is not implemented in this version.");
case "status": return Status(p);
case "cancel": return Cancel(p);
case "list_providers": return ListProviders();
case "": return new ErrorResponse("'action' parameter is required.");
default:
return new ErrorResponse($"Unknown action: '{action}'. Supported: generate, remove_background, status, cancel, list_providers.");
}
}
catch (NotSupportedException nse)
{
return new ErrorResponse(nse.Message);
}
catch (Exception e)
{
return new ErrorResponse(SecretRedactor.Scrub(e.Message));
}
}
private static object Generate(ToolParams p)
{
string provider = (p.Get("provider", "fal") ?? "fal").ToLowerInvariant();
AssetGenProviders.Image(provider); // throws NotSupportedException for unknown providers
if (!SecureKeyStore.Current.Has(provider))
return new ErrorResponse(AssetGenProviders.MissingKeyMessage(provider));
var req = new ImageGenRequest
{
Provider = provider,
Mode = (p.Get("mode", "text") ?? "text").ToLowerInvariant(),
Prompt = p.Get("prompt"),
ImagePath = p.Get("imagePath"),
ImageUrl = p.Get("imageUrl"),
Model = p.Get("model"),
Transparent = p.GetBool("transparent", false),
AsSprite = p.GetBool("asSprite", true),
Width = p.GetInt("width", 0) ?? 0,
Height = p.GetInt("height", 0) ?? 0,
Name = p.Get("name"),
OutputFolder = p.Get("outputFolder"),
};
if (!NormalizeOutputFolder(req.OutputFolder, out req.OutputFolder, out string outputErr))
return new ErrorResponse(outputErr);
if (req.Mode == "text" && string.IsNullOrWhiteSpace(req.Prompt))
return new ErrorResponse("'prompt' is required for text mode.");
if (req.Mode == "image" && string.IsNullOrWhiteSpace(req.ImageUrl))
{
if (string.IsNullOrWhiteSpace(req.ImagePath))
return new ErrorResponse("image mode requires 'image_url' or 'image_path'.");
if (!LocalImage.ResolveExisting(req.ImagePath, out string absImg, out string imgErr))
return new ErrorResponse(imgErr);
req.ImagePath = absImg;
}
AssetGenJob job = AssetGenJobManager.StartImageGeneration(req);
if (job.State == AssetGenJobState.Failed)
return new ErrorResponse(job.Error ?? "Failed to start generation.");
return new PendingResponse(
$"Image generation started with '{provider}'. Poll the status action with this job_id.",
pollIntervalSeconds: 2.0,
data: new { job_id = job.JobId, provider, status = "pending" });
}
private static bool NormalizeOutputFolder(string outputFolder, out string normalized, out string error)
{
normalized = outputFolder;
error = null;
if (string.IsNullOrWhiteSpace(outputFolder)) return true;
if (AssetGenPaths.TryGetAssetsFolder(outputFolder, out normalized)) return true;
error = "'output_folder' must resolve under the project's Assets folder.";
return false;
}
private static object Status(ToolParams p)
{
string jobId = p.Get("job_id");
if (string.IsNullOrEmpty(jobId)) return new ErrorResponse("'job_id' is required for status.");
AssetGenJob job = AssetGenJobManager.GetJob(jobId);
if (job == null) return new ErrorResponse($"No job found with ID '{jobId}'.");
switch (job.State)
{
case AssetGenJobState.Done:
return new SuccessResponse(
$"Image generated: {job.AssetPath}",
new { state = "done", asset_path = job.AssetPath, asset_guid = job.AssetGuid, progress = 1f });
case AssetGenJobState.Failed:
return new ErrorResponse(job.Error ?? "Generation failed.", new { state = "failed" });
case AssetGenJobState.Canceled:
return new SuccessResponse("Generation canceled.", new { state = "canceled" });
default:
return new PendingResponse(
$"Image {job.State.ToString().ToLowerInvariant()} ({job.Progress:P0}).",
pollIntervalSeconds: 2.0,
data: new { job_id = job.JobId, state = job.State.ToString().ToLowerInvariant(), progress = job.Progress });
}
}
private static object Cancel(ToolParams p)
{
string jobId = p.Get("job_id");
if (string.IsNullOrEmpty(jobId)) return new ErrorResponse("'job_id' is required for cancel.");
return AssetGenJobManager.Cancel(jobId)
? new SuccessResponse($"Cancel requested for job '{jobId}'.")
: new ErrorResponse($"No cancelable job found with ID '{jobId}'.");
}
private static object ListProviders()
{
var list = new List<object>();
foreach (ProviderInfo info in AssetGenProviders.List())
{
if (info.Kind != "image") continue;
list.Add(new { id = info.Id, kind = info.Kind, configured = info.Configured, capabilities = info.Capabilities });
}
return new SuccessResponse($"{list.Count} image provider(s).", new { providers = list });
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: a5e03ff5e4e040959fedaac555752094
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,147 @@
using System;
using System.Collections.Generic;
using MCPForUnity.Editor.Helpers;
using MCPForUnity.Editor.Security;
using MCPForUnity.Editor.Services.AssetGen;
using MCPForUnity.Editor.Services.AssetGen.Providers;
using Newtonsoft.Json.Linq;
namespace MCPForUnity.Editor.Tools.AssetGen
{
/// <summary>
/// 3D model generation (Tripo/Meshy). Generation is triggered here (never from
/// the GUI); the C# side reads the provider key from the secure store and runs the job.
/// Long-running: returns a job_id immediately and the client polls the `status` action.
/// </summary>
[McpForUnityTool("generate_model", AutoRegister = false, Group = "asset_gen", RequiresPolling = true, PollAction = "status", MaxPollSeconds = 300)]
public static class GenerateModel
{
public static object HandleCommand(JObject @params)
{
if (@params == null) return new ErrorResponse("Parameters cannot be null.");
var p = new ToolParams(@params);
string action = (p.Get("action") ?? string.Empty).ToLowerInvariant();
try
{
switch (action)
{
case "generate": return Generate(p);
case "status": return Status(p);
case "cancel": return Cancel(p);
case "list_providers": return ListProviders();
case "": return new ErrorResponse("'action' parameter is required.");
default:
return new ErrorResponse($"Unknown action: '{action}'. Supported: generate, status, cancel, list_providers.");
}
}
catch (NotSupportedException nse)
{
return new ErrorResponse(nse.Message);
}
catch (Exception e)
{
return new ErrorResponse(SecretRedactor.Scrub(e.Message));
}
}
private static object Generate(ToolParams p)
{
string provider = (p.Get("provider", "tripo") ?? "tripo").ToLowerInvariant();
AssetGenProviders.Model(provider); // throws NotSupportedException for unimplemented providers
if (!SecureKeyStore.Current.Has(provider))
return new ErrorResponse(AssetGenProviders.MissingKeyMessage(provider));
var req = new ModelGenRequest
{
Provider = provider,
Mode = (p.Get("mode", "text") ?? "text").ToLowerInvariant(),
Prompt = p.Get("prompt"),
ImagePath = p.Get("imagePath"),
ImageUrl = p.Get("imageUrl"),
Format = (p.Get("format", "glb") ?? "glb").ToLowerInvariant(),
TargetSize = p.GetFloat("targetSize", 1f) ?? 1f,
Texture = p.GetBool("texture", true),
Tier = p.Get("tier"),
Name = p.Get("name"),
OutputFolder = p.Get("outputFolder"),
};
if (!NormalizeOutputFolder(req.OutputFolder, out req.OutputFolder, out string outputErr))
return new ErrorResponse(outputErr);
if (req.Mode == "text" && string.IsNullOrWhiteSpace(req.Prompt))
return new ErrorResponse("'prompt' is required for text mode.");
if (req.Mode == "image" && string.IsNullOrWhiteSpace(req.ImageUrl))
{
if (string.IsNullOrWhiteSpace(req.ImagePath))
return new ErrorResponse("image mode requires 'image_url' or 'image_path'.");
if (provider == "tripo")
return new ErrorResponse("Tripo image input requires a hosted 'image_url'; local 'image_path' is not supported for Tripo (use Meshy for local-image→3D).");
if (!LocalImage.ResolveExisting(req.ImagePath, out string absImg, out string imgErr))
return new ErrorResponse(imgErr);
req.ImagePath = absImg;
}
AssetGenJob job = AssetGenJobManager.StartModelGeneration(req);
if (job.State == AssetGenJobState.Failed)
return new ErrorResponse(job.Error ?? "Failed to start generation.");
return new PendingResponse(
$"3D generation started with '{provider}'. Poll the status action with this job_id.",
pollIntervalSeconds: 3.0,
data: new { job_id = job.JobId, provider, status = "pending" });
}
private static bool NormalizeOutputFolder(string outputFolder, out string normalized, out string error)
{
normalized = outputFolder;
error = null;
if (string.IsNullOrWhiteSpace(outputFolder)) return true;
if (AssetGenPaths.TryGetAssetsFolder(outputFolder, out normalized)) return true;
error = "'output_folder' must resolve under the project's Assets folder.";
return false;
}
private static object Status(ToolParams p)
{
string jobId = p.Get("job_id");
if (string.IsNullOrEmpty(jobId)) return new ErrorResponse("'job_id' is required for status.");
AssetGenJob job = AssetGenJobManager.GetJob(jobId);
if (job == null) return new ErrorResponse($"No job found with ID '{jobId}'.");
switch (job.State)
{
case AssetGenJobState.Done:
return new SuccessResponse(
$"Generation complete: {job.AssetPath}",
new { state = "done", asset_path = job.AssetPath, asset_guid = job.AssetGuid, progress = 1f });
case AssetGenJobState.Failed:
return new ErrorResponse(job.Error ?? "Generation failed.", new { state = "failed" });
case AssetGenJobState.Canceled:
return new SuccessResponse("Generation canceled.", new { state = "canceled" });
default:
return new PendingResponse(
$"Generation {job.State.ToString().ToLowerInvariant()} ({job.Progress:P0}).",
pollIntervalSeconds: 3.0,
data: new { job_id = job.JobId, state = job.State.ToString().ToLowerInvariant(), progress = job.Progress });
}
}
private static object Cancel(ToolParams p)
{
string jobId = p.Get("job_id");
if (string.IsNullOrEmpty(jobId)) return new ErrorResponse("'job_id' is required for cancel.");
return AssetGenJobManager.Cancel(jobId)
? new SuccessResponse($"Cancel requested for job '{jobId}'.")
: new ErrorResponse($"No cancelable job found with ID '{jobId}'.");
}
private static object ListProviders()
{
var list = new List<object>();
foreach (ProviderInfo info in AssetGenProviders.List())
list.Add(new { id = info.Id, kind = info.Kind, configured = info.Configured, capabilities = info.Capabilities });
return new SuccessResponse($"{list.Count} provider(s).", new { providers = list });
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: fa7787add8344fd99adfceff3b5dd57e
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,171 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using MCPForUnity.Editor.Helpers;
using MCPForUnity.Editor.Security;
using MCPForUnity.Editor.Services.AssetGen;
using MCPForUnity.Editor.Services.AssetGen.Http;
using MCPForUnity.Editor.Services.AssetGen.Providers;
using Newtonsoft.Json.Linq;
namespace MCPForUnity.Editor.Tools.AssetGen
{
/// <summary>
/// 3D marketplace import (Sketchfab). Search/preview are read-only calls awaited directly:
/// the handler is async so the underlying UnityWebRequest completes on the editor loop
/// rather than blocking the main thread (a synchronous .GetResult() here deadlocks the
/// editor — the request can only finish on a tick the blocked main thread can't run).
/// Import downloads the model archive and unpacks it into the project as a long-running job
/// (returns a job_id; the client polls the `status` action). The provider key is read from
/// the secure store on the C# side and never transits the bridge.
/// </summary>
[McpForUnityTool("import_model", AutoRegister = false, Group = "asset_gen", RequiresPolling = true, PollAction = "status", MaxPollSeconds = 300)]
public static class ImportModel
{
private const string Provider = "sketchfab";
// Test seam for the search/preview calls (import routes through the job manager's
// own transport seam). Defaults to the production UnityWebRequest transport.
internal static IHttpTransport TransportOverrideForTests;
public static async Task<object> HandleCommand(JObject @params)
{
if (@params == null) return new ErrorResponse("Parameters cannot be null.");
var p = new ToolParams(@params);
string action = (p.Get("action") ?? string.Empty).ToLowerInvariant();
try
{
switch (action)
{
case "search": return await Search(p);
case "preview": return await Preview(p);
case "import": return Import(p);
case "status": return Status(p);
case "cancel": return Cancel(p);
case "list_providers": return ListProviders();
case "": return new ErrorResponse("'action' parameter is required.");
default:
return new ErrorResponse($"Unknown action: '{action}'. Supported: search, preview, import, status, cancel, list_providers.");
}
}
catch (NotSupportedException nse)
{
return new ErrorResponse(nse.Message);
}
catch (Exception e)
{
return new ErrorResponse(SecretRedactor.Scrub(e.Message));
}
}
private static IHttpTransport Transport() => TransportOverrideForTests ?? new UnityWebRequestTransport();
private static async Task<object> Search(ToolParams p)
{
string query = p.Get("query");
if (string.IsNullOrWhiteSpace(query)) return new ErrorResponse("'query' is required for search.");
if (!SecureKeyStore.Current.TryGet(Provider, out string key) || string.IsNullOrEmpty(key))
return KeyError();
IMarketplaceProviderAdapter adapter = AssetGenProviders.Marketplace(Provider);
string results = await adapter.SearchAsync(
query, p.Get("categories"), p.GetBool("downloadable", true), p.GetInt("count"), p.Get("cursor"),
key, Transport(), CancellationToken.None);
return new SuccessResponse($"Search results for '{query}'.",
new { provider = Provider, results = ParseOrRaw(results) });
}
private static async Task<object> Preview(ToolParams p)
{
string uid = p.Get("uid");
if (string.IsNullOrWhiteSpace(uid)) return new ErrorResponse("'uid' is required for preview.");
if (!SecureKeyStore.Current.TryGet(Provider, out string key) || string.IsNullOrEmpty(key))
return KeyError();
IMarketplaceProviderAdapter adapter = AssetGenProviders.Marketplace(Provider);
string preview = await adapter.PreviewAsync(uid, key, Transport(), CancellationToken.None);
return new SuccessResponse($"Preview for '{uid}'.",
new { provider = Provider, uid, preview = ParseOrRaw(preview) });
}
private static object Import(ToolParams p)
{
string uid = p.Get("uid");
if (string.IsNullOrWhiteSpace(uid)) return new ErrorResponse("'uid' is required for import.");
AssetGenProviders.Marketplace(Provider); // throws NotSupportedException for unimplemented providers
float targetSize = p.GetFloat("targetSize", 1f) ?? 1f;
string name = p.Get("name");
string outputFolder = p.Get("outputFolder");
if (!string.IsNullOrWhiteSpace(outputFolder)
&& !AssetGenPaths.TryGetAssetsFolder(outputFolder, out outputFolder))
{
return new ErrorResponse("'output_folder' must resolve under the project's Assets folder.");
}
AssetGenJob job = AssetGenJobManager.StartMarketplaceImport(uid, targetSize, name, outputFolder);
if (job.State == AssetGenJobState.Failed)
return new ErrorResponse(job.Error ?? "Failed to start import.");
return new PendingResponse(
$"Sketchfab import started for '{uid}'. Poll the status action with this job_id.",
pollIntervalSeconds: 3.0,
data: new { job_id = job.JobId, provider = Provider, status = "pending" });
}
private static object Status(ToolParams p)
{
string jobId = p.Get("job_id");
if (string.IsNullOrEmpty(jobId)) return new ErrorResponse("'job_id' is required for status.");
AssetGenJob job = AssetGenJobManager.GetJob(jobId);
if (job == null) return new ErrorResponse($"No job found with ID '{jobId}'.");
switch (job.State)
{
case AssetGenJobState.Done:
return new SuccessResponse(
$"Import complete: {job.AssetPath}",
new { state = "done", asset_path = job.AssetPath, asset_guid = job.AssetGuid, progress = 1f });
case AssetGenJobState.Failed:
return new ErrorResponse(job.Error ?? "Import failed.", new { state = "failed" });
case AssetGenJobState.Canceled:
return new SuccessResponse("Import canceled.", new { state = "canceled" });
default:
return new PendingResponse(
$"Import {job.State.ToString().ToLowerInvariant()} ({job.Progress:P0}).",
pollIntervalSeconds: 3.0,
data: new { job_id = job.JobId, state = job.State.ToString().ToLowerInvariant(), progress = job.Progress });
}
}
private static object Cancel(ToolParams p)
{
string jobId = p.Get("job_id");
if (string.IsNullOrEmpty(jobId)) return new ErrorResponse("'job_id' is required for cancel.");
return AssetGenJobManager.Cancel(jobId)
? new SuccessResponse($"Cancel requested for job '{jobId}'.")
: new ErrorResponse($"No cancelable job found with ID '{jobId}'.");
}
private static object ListProviders()
{
var list = new List<object>();
foreach (ProviderInfo info in AssetGenProviders.List())
{
if (info.Kind != "marketplace") continue;
list.Add(new { id = info.Id, kind = info.Kind, configured = info.Configured, capabilities = info.Capabilities });
}
return new SuccessResponse($"{list.Count} provider(s).", new { providers = list });
}
private static object KeyError()
=> new ErrorResponse(AssetGenProviders.MissingKeyMessage(Provider));
private static object ParseOrRaw(string json)
{
if (string.IsNullOrEmpty(json)) return null;
try { return JToken.Parse(json); } catch { return json; }
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 294fabfc32d2417ba2d6274ebcf3eb4b
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,108 @@
using System;
using System.IO;
using MCPForUnity.Editor.Helpers;
using MCPForUnity.Editor.Security;
using MCPForUnity.Editor.Services.AssetGen;
using MCPForUnity.Editor.Services.AssetGen.Import;
using Newtonsoft.Json.Linq;
using UnityEditor;
namespace MCPForUnity.Editor.Tools.AssetGen
{
/// <summary>
/// Import a local 3D model file (already on disk — e.g. exported from Blender/Maya) into the
/// Unity project. DCC-agnostic and key-free: the file is copied under Assets/ and run through
/// the shared ModelImportPipeline (glTFast/FBX/OBJ/zip handling, scale-normalize, material
/// settings). Placement into the scene is the caller's job (kept single-purpose).
/// </summary>
[McpForUnityTool("import_model_file", AutoRegister = false, Group = "asset_gen")]
public static class ImportModelFile
{
private static readonly string[] SupportedExt = { ".fbx", ".obj", ".glb", ".gltf", ".zip" };
public static object HandleCommand(JObject @params)
{
if (@params == null) return new ErrorResponse("Parameters cannot be null.");
var p = new ToolParams(@params);
try
{
string source = p.Get("sourcePath");
if (string.IsNullOrWhiteSpace(source))
return new ErrorResponse("'source_path' is required.");
string srcAbs = ResolveSource(source);
if (!File.Exists(srcAbs))
return new ErrorResponse($"Source file not found: {source}");
string ext = Path.GetExtension(srcAbs).ToLowerInvariant();
if (Array.IndexOf(SupportedExt, ext) < 0)
return new ErrorResponse(
$"Unsupported model extension '{ext}'. Supported: .fbx, .obj, .glb, .gltf, .zip.");
string baseName = p.Get("name");
if (string.IsNullOrWhiteSpace(baseName))
baseName = Path.GetFileNameWithoutExtension(srcAbs);
string destRel = StageUnderAssets(srcAbs, baseName, ext, p.Get("outputFolder"));
AssetDatabase.Refresh();
var job = new AssetGenJob
{
TargetSize = p.GetFloat("targetSize", 1f) ?? 1f,
AnimationType = p.Get("animationType"),
};
AssetGenJob result = ModelImportPipeline.ImportInto(job, destRel);
if (result == null || result.State == AssetGenJobState.Failed)
return new ErrorResponse(result?.Error ?? "Import failed.");
return new SuccessResponse(
$"Imported model: {result.AssetPath}",
new { asset_path = result.AssetPath, asset_guid = result.AssetGuid });
}
catch (Exception e)
{
return new ErrorResponse(SecretRedactor.Scrub(e.Message));
}
}
private static string ResolveSource(string source)
{
string s = source.Replace('\\', '/');
if (s == "Assets" || s.StartsWith("Assets/")) return AssetGenPaths.ToAbsolute(s);
return s; // absolute path on disk
}
private static string StageUnderAssets(string srcAbs, string baseName, string ext, string outputFolder)
{
string root = !string.IsNullOrWhiteSpace(outputFolder)
? outputFolder
: AssetGenPrefs.OutputRoot + "/Imported";
if (!AssetGenPaths.TryGetAssetsFolder(root, out root))
{
if (!string.IsNullOrWhiteSpace(outputFolder))
throw new ArgumentException("'output_folder' must resolve under the project's Assets folder.");
root = AssetGenPrefs.DefaultOutputRoot + "/Imported";
}
string absRoot = AssetGenPaths.ToAbsolute(root);
Directory.CreateDirectory(absRoot);
string safe = SanitizeName(baseName);
string fileName = safe + ext;
string abs = Path.Combine(absRoot, fileName);
int n = 1;
while (File.Exists(abs)) { fileName = safe + "_" + n++ + ext; abs = Path.Combine(absRoot, fileName); }
File.Copy(srcAbs, abs);
return (root.TrimEnd('/') + "/" + fileName).Replace('\\', '/');
}
private static string SanitizeName(string raw)
{
if (string.IsNullOrWhiteSpace(raw)) return "model";
foreach (char c in Path.GetInvalidFileNameChars()) raw = raw.Replace(c, '_');
return raw.Trim();
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: e71d7281f8e84c45802ed38d1cceed81
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
+235
View File
@@ -0,0 +1,235 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using MCPForUnity.Editor.Constants;
using MCPForUnity.Editor.Helpers;
using MCPForUnity.Editor.Services;
using Newtonsoft.Json.Linq;
using UnityEditor;
namespace MCPForUnity.Editor.Tools
{
/// <summary>
/// Executes multiple MCP commands within a single Unity-side handler. Commands are executed sequentially
/// on the main thread to preserve determinism and Unity API safety.
/// </summary>
[McpForUnityTool("batch_execute", AutoRegister = false)]
public static class BatchExecute
{
/// <summary>Default limit when no EditorPrefs override is set.</summary>
internal const int DefaultMaxCommandsPerBatch = 25;
/// <summary>Hard ceiling to prevent extreme editor freezes regardless of user setting.</summary>
internal const int AbsoluteMaxCommandsPerBatch = 100;
/// <summary>
/// Returns the user-configured max commands per batch, clamped between 1 and <see cref="AbsoluteMaxCommandsPerBatch"/>.
/// </summary>
internal static int GetMaxCommandsPerBatch()
{
int configured = EditorPrefs.GetInt(EditorPrefKeys.BatchExecuteMaxCommands, DefaultMaxCommandsPerBatch);
return Math.Clamp(configured, 1, AbsoluteMaxCommandsPerBatch);
}
public static async Task<object> HandleCommand(JObject @params)
{
if (@params == null)
{
return new ErrorResponse("'commands' payload is required.");
}
var commandsToken = @params["commands"] as JArray;
if (commandsToken == null || commandsToken.Count == 0)
{
return new ErrorResponse("Provide at least one command entry in 'commands'.");
}
int maxCommands = GetMaxCommandsPerBatch();
if (commandsToken.Count > maxCommands)
{
return new ErrorResponse(
$"A maximum of {maxCommands} commands are allowed per batch (configurable in MCP Tools window, hard max {AbsoluteMaxCommandsPerBatch}).");
}
bool failFast = @params.Value<bool?>("failFast") ?? false;
bool parallelRequested = @params.Value<bool?>("parallel") ?? false;
int? maxParallel = @params.Value<int?>("maxParallelism");
if (parallelRequested)
{
McpLog.Warn("batch_execute parallel mode requested, but commands will run sequentially on the main thread for safety.");
}
var commandResults = new List<object>(commandsToken.Count);
int invocationSuccessCount = 0;
int invocationFailureCount = 0;
bool anyCommandFailed = false;
foreach (var token in commandsToken)
{
if (token is not JObject commandObj)
{
invocationFailureCount++;
anyCommandFailed = true;
commandResults.Add(new
{
tool = (string)null,
callSucceeded = false,
error = "Command entries must be JSON objects."
});
if (failFast)
{
break;
}
continue;
}
string toolName = commandObj["tool"]?.ToString();
var rawParams = commandObj["params"] as JObject ?? new JObject();
var commandParams = NormalizeParameterKeys(rawParams);
if (string.IsNullOrWhiteSpace(toolName))
{
invocationFailureCount++;
anyCommandFailed = true;
commandResults.Add(new
{
tool = toolName,
callSucceeded = false,
error = "Each command must include a non-empty 'tool' field."
});
if (failFast)
{
break;
}
continue;
}
// Block disabled tools (mirrors TransportCommandDispatcher check)
var toolMeta = MCPServiceLocator.ToolDiscovery.GetToolMetadata(toolName);
if (toolMeta != null && !MCPServiceLocator.ToolDiscovery.IsToolEnabled(toolName))
{
invocationFailureCount++;
anyCommandFailed = true;
commandResults.Add(new
{
tool = toolName,
callSucceeded = false,
result = new ErrorResponse($"Tool '{toolName}' is disabled in the Unity Editor.")
});
if (failFast) break;
continue;
}
try
{
var result = await CommandRegistry.InvokeCommandAsync(toolName, commandParams).ConfigureAwait(true);
bool callSucceeded = DetermineCallSucceeded(result);
if (callSucceeded)
{
invocationSuccessCount++;
}
else
{
invocationFailureCount++;
anyCommandFailed = true;
}
commandResults.Add(new
{
tool = toolName,
callSucceeded,
result
});
if (!callSucceeded && failFast)
{
break;
}
}
catch (Exception ex)
{
invocationFailureCount++;
anyCommandFailed = true;
commandResults.Add(new
{
tool = toolName,
callSucceeded = false,
error = ex.Message
});
if (failFast)
{
break;
}
}
}
bool overallSuccess = !anyCommandFailed;
var data = new
{
results = commandResults,
callSuccessCount = invocationSuccessCount,
callFailureCount = invocationFailureCount,
parallelRequested,
parallelApplied = false,
maxParallelism = maxParallel
};
return overallSuccess
? new SuccessResponse("Batch execution completed.", data)
: new ErrorResponse("One or more commands failed.", data);
}
private static bool DetermineCallSucceeded(object result)
{
if (result == null)
{
return true;
}
if (result is IMcpResponse response)
{
return response.Success;
}
if (result is JObject obj)
{
var successToken = obj["success"];
if (successToken != null && successToken.Type == JTokenType.Boolean)
{
return successToken.Value<bool>();
}
}
if (result is JToken token)
{
var successToken = token["success"];
if (successToken != null && successToken.Type == JTokenType.Boolean)
{
return successToken.Value<bool>();
}
}
return true;
}
private static JObject NormalizeParameterKeys(JObject source)
{
if (source == null)
{
return new JObject();
}
var normalized = new JObject();
foreach (var property in source.Properties())
{
string normalizedName = ToCamelCase(property.Name);
normalized[normalizedName] = property.Value;
}
return normalized;
}
private static string ToCamelCase(string key) => StringCaseUtility.ToCamelCase(key);
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 4e1e2d8f3a454a37b18d06a7a7b6c3fb
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
+8
View File
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 6552334ce31748c6a1d4acd9f3b6ce06
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
+189
View File
@@ -0,0 +1,189 @@
using System;
using System.Collections.Generic;
using UnityEditor;
using UnityEditor.Build.Reporting;
namespace MCPForUnity.Editor.Tools.Build
{
public enum BuildJobState
{
Pending,
Building,
Succeeded,
Failed,
Cancelled,
Skipped
}
public class BuildJob
{
public string JobId { get; }
public BuildJobState State { get; set; } = BuildJobState.Pending;
public BuildTarget Target { get; set; }
public string OutputPath { get; set; }
public DateTime StartedAt { get; set; }
public DateTime? CompletedAt { get; set; }
public string ErrorMessage { get; set; }
// Extracted from BuildReport at completion to avoid retaining the heavy native object
public double TotalSizeMb { get; set; }
public int TotalErrors { get; set; }
public int TotalWarnings { get; set; }
public BuildJob(string jobId, BuildTarget target, string outputPath)
{
JobId = jobId;
Target = target;
OutputPath = outputPath;
}
public object ToStatusResponse()
{
var data = new Dictionary<string, object>
{
["job_id"] = JobId,
["result"] = State.ToString().ToLowerInvariant(),
["platform"] = Target.ToString(),
["output_path"] = OutputPath
};
if (StartedAt != default)
data["started_at"] = StartedAt.ToString("O");
if (CompletedAt.HasValue)
{
data["duration_seconds"] = (CompletedAt.Value - StartedAt).TotalSeconds;
data["completed_at"] = CompletedAt.Value.ToString("O");
}
if (State == BuildJobState.Succeeded || State == BuildJobState.Failed)
{
data["total_size_mb"] = TotalSizeMb;
data["errors"] = TotalErrors;
data["warnings"] = TotalWarnings;
}
if (!string.IsNullOrEmpty(ErrorMessage))
data["error"] = ErrorMessage;
return data;
}
}
public class BatchJob
{
public string JobId { get; }
public BuildJobState State { get; set; } = BuildJobState.Pending;
public List<BuildJob> Children { get; } = new();
public int CurrentIndex { get; set; } = -1;
public BatchJob(string jobId)
{
JobId = jobId;
}
public object ToStatusResponse()
{
int completed = 0;
string currentBuild = null;
var builds = new List<object>();
foreach (var child in Children)
{
if (child.State == BuildJobState.Succeeded || child.State == BuildJobState.Failed
|| child.State == BuildJobState.Skipped || child.State == BuildJobState.Cancelled)
completed++;
if (child.State == BuildJobState.Building)
currentBuild = child.JobId;
builds.Add(child.ToStatusResponse());
}
return new Dictionary<string, object>
{
["job_id"] = JobId,
["result"] = State.ToString().ToLowerInvariant(),
["completed"] = completed,
["total"] = Children.Count,
["current_build"] = currentBuild,
["builds"] = builds
};
}
}
/// <summary>
/// Static store for all build jobs. Note: static fields are cleared on domain reload,
/// but this is acceptable because BuildPipeline.BuildPlayer blocks the editor thread,
/// preventing domain reload during a build. For batch builds with platform switches,
/// the batch scheduling happens after each build completes via EditorApplication.update
/// callbacks (ScheduleOnNextUpdate / WaitForCompletion), so state is maintained within
/// a single domain lifecycle.
/// </summary>
public static class BuildJobStore
{
private static readonly Dictionary<string, BuildJob> _buildJobs = new();
private static readonly Dictionary<string, BatchJob> _batchJobs = new();
private static BuildJob _lastCompletedJob;
public static string CreateJobId() => $"build-{Guid.NewGuid():N}".Substring(0, 16);
public static string CreateBatchId() => $"batch-{Guid.NewGuid():N}".Substring(0, 16);
public static void AddBuildJob(BuildJob job) => _buildJobs[job.JobId] = job;
public static void AddBatchJob(BatchJob job) => _batchJobs[job.JobId] = job;
public static BuildJob GetBuildJob(string jobId)
{
_buildJobs.TryGetValue(jobId, out var job);
return job;
}
public static BatchJob GetBatchJob(string jobId)
{
_batchJobs.TryGetValue(jobId, out var job);
return job;
}
public static BuildJob LastCompletedJob => _lastCompletedJob;
public static void SetLastCompleted(BuildJob job)
{
_lastCompletedJob = job;
PruneOldJobs();
}
private const int MaxRetainedJobs = 50;
private static void PruneOldJobs()
{
if (_buildJobs.Count <= MaxRetainedJobs) return;
var toRemove = new List<string>();
foreach (var kvp in _buildJobs)
{
if (kvp.Value.State != BuildJobState.Building && kvp.Value.State != BuildJobState.Pending
&& kvp.Value != _lastCompletedJob)
toRemove.Add(kvp.Key);
}
foreach (var key in toRemove)
{
_buildJobs.Remove(key);
if (_buildJobs.Count <= MaxRetainedJobs / 2) break;
}
// Also prune batch jobs whose children are all terminal
var batchesToRemove = new List<string>();
foreach (var kvp in _batchJobs)
{
var batch = kvp.Value;
if (batch.State == BuildJobState.Building || batch.State == BuildJobState.Pending)
continue;
// Remove child references that were already pruned from _buildJobs
batch.Children.RemoveAll(c => !_buildJobs.ContainsKey(c.JobId));
if (batch.Children.Count == 0)
batchesToRemove.Add(kvp.Key);
}
foreach (var key in batchesToRemove)
_batchJobs.Remove(key);
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 6c04470155554320bc9e1655674d6aa7
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,234 @@
using System;
using System.Collections.Generic;
using System.Linq;
using UnityEditor;
using UnityEditor.Build.Reporting;
using UnityEditor.Build;
using MCPForUnity.Editor.Helpers;
namespace MCPForUnity.Editor.Tools.Build
{
public static class BuildRunner
{
public static object ScheduleBuild(BuildJob job, BuildPlayerOptions options)
{
job.State = BuildJobState.Pending;
BuildJobStore.AddBuildJob(job);
ScheduleOnNextUpdate(() =>
RunBuildCore(job, () => BuildPipeline.BuildPlayer(options)));
return new PendingResponse(
$"Build scheduled for {job.Target}. Polling for completion...",
pollIntervalSeconds: 5.0,
data: new { job_id = job.JobId, platform = job.Target.ToString() }
);
}
#if UNITY_6000_0_OR_NEWER
public static object ScheduleProfileBuild(BuildJob job, BuildPlayerWithProfileOptions options)
{
job.State = BuildJobState.Pending;
BuildJobStore.AddBuildJob(job);
ScheduleOnNextUpdate(() =>
RunBuildCore(job, () => BuildPipeline.BuildPlayer(options)));
return new PendingResponse(
$"Profile build scheduled for {job.Target}. Polling for completion...",
pollIntervalSeconds: 5.0,
data: new { job_id = job.JobId, platform = job.Target.ToString() }
);
}
#endif
public static BuildPlayerOptions CreateBuildOptions(
BuildTarget target,
string outputPath,
string[] scenes,
BuildOptions buildOptions,
int subtarget)
{
var options = new BuildPlayerOptions
{
target = target,
targetGroup = BuildTargetMapping.GetTargetGroup(target),
locationPathName = outputPath,
scenes = scenes ?? GetDefaultScenes(),
options = buildOptions,
};
// Subtarget is only meaningful for Standalone (Server vs Player).
// On Android/iOS it maps to MobileTextureSubtarget (texture compression format).
// Passing StandaloneBuildSubtarget.Player (0) for mobile platforms forces
// a specific texture format — confirmed Unity bug IN-102413 where value 0
// on Unity 6000+ triggers PVRTC, ignoring Player Settings.
// Leave at platform default (0) so Unity respects Player Settings.
if (target == BuildTarget.StandaloneWindows
|| target == BuildTarget.StandaloneWindows64
|| target == BuildTarget.StandaloneOSX
|| target == BuildTarget.StandaloneLinux64)
{
options.subtarget = subtarget;
}
return options;
}
public static BuildOptions ParseBuildOptions(string[] optionNames, bool development)
{
var opts = BuildOptions.None;
if (development)
opts |= BuildOptions.Development;
if (optionNames == null) return opts;
foreach (var name in optionNames)
{
switch (name.ToLowerInvariant())
{
case "clean_build": opts |= BuildOptions.CleanBuildCache; break;
case "auto_run": opts |= BuildOptions.AutoRunPlayer; break;
case "deep_profiling": opts |= BuildOptions.EnableDeepProfilingSupport; break;
case "compress_lz4": opts |= BuildOptions.CompressWithLz4; break;
case "strict_mode": opts |= BuildOptions.StrictMode; break;
case "detailed_report": opts |= BuildOptions.DetailedBuildReport; break;
case "allow_debugging": opts |= BuildOptions.AllowDebugging; break;
case "connect_profiler": opts |= BuildOptions.ConnectWithProfiler; break;
case "scripts_only": opts |= BuildOptions.BuildScriptsOnly; break;
case "show_player": opts |= BuildOptions.ShowBuiltPlayer; break;
case "include_tests": opts |= BuildOptions.IncludeTestAssemblies; break;
}
}
return opts;
}
private static void RunBuildCore(BuildJob job, Func<BuildReport> buildFunc)
{
job.State = BuildJobState.Building;
job.StartedAt = DateTime.UtcNow;
try
{
BuildReport report = buildFunc();
job.CompletedAt = DateTime.UtcNow;
// Extract summary data immediately, then let the BuildReport be GC'd
var summary = report.summary;
job.TotalSizeMb = Math.Round(summary.totalSize / (1024.0 * 1024.0), 2);
job.TotalErrors = summary.totalErrors;
job.TotalWarnings = summary.totalWarnings;
if (summary.result == BuildResult.Succeeded)
{
job.State = BuildJobState.Succeeded;
}
else
{
job.State = BuildJobState.Failed;
#if UNITY_2023_1_OR_NEWER
job.ErrorMessage = report.SummarizeErrors();
#else
job.ErrorMessage = $"Build failed with result: {summary.result}";
#endif
}
}
catch (Exception ex)
{
job.State = BuildJobState.Failed;
job.CompletedAt = DateTime.UtcNow;
job.ErrorMessage = ex.Message;
}
BuildJobStore.SetLastCompleted(job);
// Emit console signal so the build result is visible in Unity's Console
if (job.State == BuildJobState.Succeeded)
{
UnityEngine.Debug.Log(
$"[MCP Build] Build succeeded: {job.Target} → {job.OutputPath} " +
$"({job.TotalSizeMb} MB, {(job.CompletedAt.Value - job.StartedAt).TotalSeconds:F1}s)");
}
else
{
UnityEngine.Debug.LogError(
$"[MCP Build] ✗ Build failed: {job.Target} — {job.ErrorMessage}");
}
}
public static void ScheduleNextBatchBuild(BatchJob batch, Func<int, BuildJob> createChildBuild)
{
batch.CurrentIndex++;
if (batch.State == BuildJobState.Cancelled)
{
for (int i = batch.CurrentIndex; i < batch.Children.Count; i++)
batch.Children[i].State = BuildJobState.Skipped;
return;
}
if (batch.CurrentIndex >= batch.Children.Count)
{
bool anyFailed = batch.Children.Any(c => c.State == BuildJobState.Failed);
batch.State = anyFailed ? BuildJobState.Failed : BuildJobState.Succeeded;
return;
}
var child = batch.Children[batch.CurrentIndex];
createChildBuild(batch.CurrentIndex);
// Safety timeout: if child never transitions out of Building/Pending after 2 hours,
// unregister the delegate to prevent an orphaned update loop
var watchStart = DateTime.UtcNow;
var maxWait = TimeSpan.FromHours(2);
void WaitForCompletion()
{
if (DateTime.UtcNow - watchStart > maxWait)
{
EditorApplication.update -= WaitForCompletion;
if (child.State == BuildJobState.Building || child.State == BuildJobState.Pending)
{
child.State = BuildJobState.Failed;
child.ErrorMessage = "Build timed out after 2 hours.";
}
ScheduleNextBatchBuild(batch, createChildBuild);
return;
}
if (child.State == BuildJobState.Building || child.State == BuildJobState.Pending)
return;
EditorApplication.update -= WaitForCompletion;
ScheduleNextBatchBuild(batch, createChildBuild);
}
EditorApplication.update += WaitForCompletion;
}
/// <summary>
/// Schedule an action to run on the next EditorApplication.update tick.
/// Unlike delayCall, update callbacks are guaranteed to fire since the
/// TransportCommandDispatcher processes commands through the same mechanism.
/// </summary>
private static void ScheduleOnNextUpdate(Action action)
{
bool executed = false;
void RunOnce()
{
if (executed) return;
executed = true;
EditorApplication.update -= RunOnce;
action();
}
EditorApplication.update += RunOnce;
}
private static string[] GetDefaultScenes()
{
return EditorBuildSettings.scenes
.Where(s => s.enabled)
.Select(s => s.path)
.ToArray();
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 9e1fff59ee9947f49ad112a9403b11df
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,94 @@
using System;
using System.Collections.Generic;
using UnityEditor;
using UnityEditor.Build;
namespace MCPForUnity.Editor.Tools.Build
{
public static class BuildSettingsHelper
{
public static object ReadProperty(string property, NamedBuildTarget namedTarget)
{
switch (property.ToLowerInvariant())
{
case "product_name":
return new { property, value = PlayerSettings.productName };
case "company_name":
return new { property, value = PlayerSettings.companyName };
case "version":
return new { property, value = PlayerSettings.bundleVersion };
case "bundle_id":
return new { property, value = PlayerSettings.GetApplicationIdentifier(namedTarget) };
case "scripting_backend":
var backend = PlayerSettings.GetScriptingBackend(namedTarget);
return new { property, value = backend == ScriptingImplementation.IL2CPP ? "il2cpp" : "mono" };
case "defines":
return new { property, value = PlayerSettings.GetScriptingDefineSymbols(namedTarget) };
case "architecture":
var arch = PlayerSettings.GetArchitecture(namedTarget);
string archName = arch switch { 0 => "x86_64", 1 => "arm64", 2 => "universal", _ => "unknown" };
return new { property, value = archName, raw = arch };
default:
return null;
}
}
public static string WriteProperty(string property, string value, NamedBuildTarget namedTarget)
{
try
{
switch (property.ToLowerInvariant())
{
case "product_name":
PlayerSettings.productName = value;
return null;
case "company_name":
PlayerSettings.companyName = value;
return null;
case "version":
PlayerSettings.bundleVersion = value;
return null;
case "bundle_id":
PlayerSettings.SetApplicationIdentifier(namedTarget, value);
return null;
case "scripting_backend":
var backendValue = value.ToLowerInvariant();
if (backendValue != "il2cpp" && backendValue != "mono")
return $"Unknown scripting_backend '{value}'. Valid: mono, il2cpp";
var impl = backendValue == "il2cpp"
? ScriptingImplementation.IL2CPP
: ScriptingImplementation.Mono2x;
PlayerSettings.SetScriptingBackend(namedTarget, impl);
return null;
case "defines":
PlayerSettings.SetScriptingDefineSymbols(namedTarget, value);
return null;
case "architecture":
int arch = value.ToLowerInvariant() switch
{
"x86_64" or "none" or "default" => 0,
"arm64" => 1,
"universal" => 2,
_ => -1
};
if (arch < 0)
return $"Unknown architecture '{value}'. Valid: x86_64, arm64, universal";
PlayerSettings.SetArchitecture(namedTarget, arch);
return null;
default:
return $"Unknown property '{property}'. Valid: product_name, company_name, version, bundle_id, scripting_backend, defines, architecture";
}
}
catch (Exception ex)
{
return $"Failed to set {property}: {ex.Message}";
}
}
public static readonly IReadOnlyList<string> ValidProperties = new[]
{
"product_name", "company_name", "version", "bundle_id",
"scripting_backend", "defines", "architecture"
};
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 51f7a2852819440eada31cda61039b6a
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,165 @@
using System;
using UnityEditor;
using UnityEditor.Build;
namespace MCPForUnity.Editor.Tools.Build
{
public static class BuildTargetMapping
{
private const string VisionOSName = "VisionOS";
public static bool TryResolveBuildTarget(string name, out BuildTarget target)
{
if (string.IsNullOrEmpty(name))
{
target = EditorUserBuildSettings.activeBuildTarget;
return true;
}
switch (name.ToLowerInvariant())
{
case "windows64": target = BuildTarget.StandaloneWindows64; return true;
case "windows": case "windows32": target = BuildTarget.StandaloneWindows; return true;
case "osx": case "macos": target = BuildTarget.StandaloneOSX; return true;
case "linux64": case "linux": target = BuildTarget.StandaloneLinux64; return true;
case "android": target = BuildTarget.Android; return true;
case "ios": target = BuildTarget.iOS; return true;
case "webgl": target = BuildTarget.WebGL; return true;
case "uwp": target = BuildTarget.WSAPlayer; return true;
case "tvos": target = BuildTarget.tvOS; return true;
default:
if (TryParseDefinedBuildTarget(name, out target))
{
return true;
}
target = default;
return false;
}
}
public static BuildTargetGroup GetTargetGroup(BuildTarget target)
{
switch (target)
{
case BuildTarget.StandaloneWindows:
case BuildTarget.StandaloneWindows64:
case BuildTarget.StandaloneOSX:
case BuildTarget.StandaloneLinux64:
return BuildTargetGroup.Standalone;
case BuildTarget.iOS: return BuildTargetGroup.iOS;
case BuildTarget.Android: return BuildTargetGroup.Android;
case BuildTarget.WebGL: return BuildTargetGroup.WebGL;
case BuildTarget.WSAPlayer: return BuildTargetGroup.WSA;
case BuildTarget.tvOS: return BuildTargetGroup.tvOS;
default:
if (IsVisionOSTarget(target)
&& Enum.TryParse(VisionOSName, true, out BuildTargetGroup visionOSGroup))
{
return visionOSGroup;
}
return BuildTargetGroup.Unknown;
}
}
public static NamedBuildTarget GetNamedBuildTarget(BuildTarget target)
{
return NamedBuildTarget.FromBuildTargetGroup(GetTargetGroup(target));
}
public static string TryResolveNamedBuildTarget(string name, out NamedBuildTarget namedTarget)
{
if (!TryResolveBuildTarget(name, out var buildTarget))
{
namedTarget = default;
return GetUnknownBuildTargetMessage(name);
}
var targetGroup = GetTargetGroup(buildTarget);
if (targetGroup == BuildTargetGroup.Unknown)
{
namedTarget = default;
return IsVisionOSTarget(buildTarget)
? "VisionOS build target is available, but its BuildTargetGroup is not exposed by this Unity editor installation."
: $"Build target group could not be resolved for target '{buildTarget}'.";
}
namedTarget = NamedBuildTarget.FromBuildTargetGroup(targetGroup);
return null;
}
public static string GetUnknownBuildTargetMessage(string name)
{
if (string.Equals(name, "visionos", StringComparison.OrdinalIgnoreCase))
{
return "VisionOS build target is not available in this Unity editor installation. "
+ "Install the visionOS build support module or use a Unity version/configuration that exposes BuildTarget.VisionOS.";
}
return $"Unknown build target: '{name}'. Valid targets: {GetValidTargetsList()}.";
}
private static string GetValidTargetsList()
{
string validTargets = "windows64, osx, linux64, android, ios, webgl, uwp, tvos";
if (TryParseDefinedBuildTarget(VisionOSName, out _))
{
validTargets += ", visionos";
}
return validTargets;
}
private static bool IsVisionOSTarget(BuildTarget target)
{
return string.Equals(target.ToString(), VisionOSName, StringComparison.OrdinalIgnoreCase);
}
private static bool TryParseDefinedBuildTarget(string name, out BuildTarget target)
{
target = default;
if (int.TryParse(name, out _))
{
return false;
}
return Enum.TryParse(name, true, out target)
&& Enum.IsDefined(typeof(BuildTarget), target);
}
public static string GetDefaultOutputPath(BuildTarget target, string productName)
{
string basePath = $"Builds/{target}";
switch (target)
{
case BuildTarget.StandaloneWindows:
case BuildTarget.StandaloneWindows64:
return $"{basePath}/{productName}.exe";
case BuildTarget.StandaloneOSX:
return $"{basePath}/{productName}.app";
case BuildTarget.StandaloneLinux64:
return $"{basePath}/{productName}.x86_64";
case BuildTarget.Android:
return EditorUserBuildSettings.buildAppBundle
? $"{basePath}/{productName}.aab"
: $"{basePath}/{productName}.apk";
case BuildTarget.iOS:
case BuildTarget.WebGL:
return $"{basePath}/{productName}";
default:
return $"{basePath}/{productName}";
}
}
public static int ResolveSubtarget(string subtarget)
{
if (string.IsNullOrEmpty(subtarget))
return (int)StandaloneBuildSubtarget.Player;
string lower = subtarget.ToLowerInvariant();
if (lower == "server")
return (int)StandaloneBuildSubtarget.Server;
return (int)StandaloneBuildSubtarget.Player;
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: c338ede07ac74e0ab9db99a1ac8c1a87
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
+8
View File
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 34337a86f21c4749be2a115f48fe6700
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,412 @@
using System;
using System.Reflection;
using MCPForUnity.Editor.Helpers;
using Newtonsoft.Json.Linq;
using UnityEditor;
using UnityEngine;
using MCPForUnity.Runtime.Helpers;
namespace MCPForUnity.Editor.Tools.Cameras
{
internal static class CameraConfigure
{
#region Tier 1 Basic Camera
internal static object SetBasicCameraTarget(JObject @params)
{
var go = CameraHelpers.FindTargetGameObject(@params);
if (go == null) return new ErrorResponse("Target Camera not found.");
var cam = go.GetComponent<UnityEngine.Camera>();
if (cam == null) return new ErrorResponse($"No Camera component on '{go.name}'.");
var props = CameraHelpers.ExtractProperties(@params) ?? new JObject();
var lookAtToken = props["lookAt"] ?? props["look_at"] ?? props["follow"];
if (lookAtToken == null)
return new ErrorResponse("'follow' or 'lookAt' property is required.");
var target = CameraHelpers.ResolveGameObjectRef(lookAtToken);
if (target == null)
return new ErrorResponse($"Target '{lookAtToken}' not found.");
Undo.RecordObject(go.transform, "Set Camera Target");
go.transform.LookAt(target.transform);
CameraHelpers.MarkDirty(go);
return new
{
success = true,
message = $"Camera '{go.name}' now looking at '{target.name}'.",
data = new { instanceID = go.GetInstanceIDCompat() }
};
}
internal static object SetBasicCameraLens(JObject @params)
{
var go = CameraHelpers.FindTargetGameObject(@params);
if (go == null) return new ErrorResponse("Target Camera not found.");
var cam = go.GetComponent<UnityEngine.Camera>();
if (cam == null) return new ErrorResponse($"No Camera component on '{go.name}'.");
var props = CameraHelpers.ExtractProperties(@params) ?? new JObject();
Undo.RecordObject(cam, "Set Camera Lens");
if (props["fieldOfView"] != null)
cam.fieldOfView = ParamCoercion.CoerceFloat(props["fieldOfView"], cam.fieldOfView);
if (props["nearClipPlane"] != null)
cam.nearClipPlane = ParamCoercion.CoerceFloat(props["nearClipPlane"], cam.nearClipPlane);
if (props["farClipPlane"] != null)
cam.farClipPlane = ParamCoercion.CoerceFloat(props["farClipPlane"], cam.farClipPlane);
if (props["orthographicSize"] != null)
cam.orthographicSize = ParamCoercion.CoerceFloat(props["orthographicSize"], cam.orthographicSize);
CameraHelpers.MarkDirty(go);
return new
{
success = true,
message = $"Lens properties set on Camera '{go.name}'.",
data = new { instanceID = go.GetInstanceIDCompat() }
};
}
internal static object SetBasicCameraPriority(JObject @params)
{
var go = CameraHelpers.FindTargetGameObject(@params);
if (go == null) return new ErrorResponse("Target Camera not found.");
var cam = go.GetComponent<UnityEngine.Camera>();
if (cam == null) return new ErrorResponse($"No Camera component on '{go.name}'.");
var props = CameraHelpers.ExtractProperties(@params) ?? new JObject();
float depth = ParamCoercion.CoerceFloat(props["priority"], cam.depth);
Undo.RecordObject(cam, "Set Camera Depth");
cam.depth = depth;
CameraHelpers.MarkDirty(go);
return new
{
success = true,
message = $"Camera '{go.name}' depth set to {depth}.",
data = new { instanceID = go.GetInstanceIDCompat(), depth }
};
}
#endregion
#region Tier 2 Cinemachine
internal static object SetCinemachineTarget(JObject @params)
{
var cmCamera = CameraHelpers.FindCinemachineCamera(@params);
if (cmCamera == null) return new ErrorResponse("Target CinemachineCamera not found.");
var props = CameraHelpers.ExtractProperties(@params) ?? new JObject();
Undo.RecordObject(cmCamera, "Set Cinemachine Target");
if (props.ContainsKey("follow"))
CameraHelpers.SetTransformTarget(cmCamera, "Follow", props["follow"]);
if (props.ContainsKey("lookAt") || props.ContainsKey("look_at"))
CameraHelpers.SetTransformTarget(cmCamera, "LookAt", props["lookAt"] ?? props["look_at"]);
CameraHelpers.MarkDirty(cmCamera.gameObject);
return new
{
success = true,
message = $"Targets set on CinemachineCamera '{cmCamera.gameObject.name}'.",
data = new { instanceID = cmCamera.gameObject.GetInstanceIDCompat() }
};
}
internal static object SetCinemachineLens(JObject @params)
{
var cmCamera = CameraHelpers.FindCinemachineCamera(@params);
if (cmCamera == null) return new ErrorResponse("Target CinemachineCamera not found.");
var props = CameraHelpers.ExtractProperties(@params) ?? new JObject();
Undo.RecordObject(cmCamera, "Set Cinemachine Lens");
// Lens is a struct field — use SerializedProperty for reliable setting
using var so = new SerializedObject(cmCamera);
var lensProp = so.FindProperty("Lens") ?? so.FindProperty("m_Lens");
if (lensProp == null)
return new ErrorResponse("Could not find Lens property on CinemachineCamera.");
SetFloatSubProp(lensProp, "FieldOfView", props["fieldOfView"]);
SetFloatSubProp(lensProp, "NearClipPlane", props["nearClipPlane"]);
SetFloatSubProp(lensProp, "FarClipPlane", props["farClipPlane"]);
SetFloatSubProp(lensProp, "OrthographicSize", props["orthographicSize"]);
SetFloatSubProp(lensProp, "Dutch", props["dutch"]);
so.ApplyModifiedProperties();
CameraHelpers.MarkDirty(cmCamera.gameObject);
return new
{
success = true,
message = $"Lens properties set on CinemachineCamera '{cmCamera.gameObject.name}'.",
data = new { instanceID = cmCamera.gameObject.GetInstanceIDCompat() }
};
}
internal static object SetCinemachinePriority(JObject @params)
{
var cmCamera = CameraHelpers.FindCinemachineCamera(@params);
if (cmCamera == null) return new ErrorResponse("Target CinemachineCamera not found.");
var props = CameraHelpers.ExtractProperties(@params) ?? new JObject();
int priority = ParamCoercion.CoerceInt(props["priority"], 10);
// PrioritySettings is a struct with Enabled + m_Value — use SerializedProperty
using var so = new SerializedObject(cmCamera);
var priorityProp = so.FindProperty("Priority");
if (priorityProp != null)
{
var enabledProp = priorityProp.FindPropertyRelative("Enabled");
var valueProp = priorityProp.FindPropertyRelative("m_Value");
if (enabledProp != null) enabledProp.boolValue = true;
if (valueProp != null) valueProp.intValue = priority;
so.ApplyModifiedProperties();
}
else
{
Undo.RecordObject(cmCamera, "Set Cinemachine Priority");
CameraHelpers.SetReflectionProperty(cmCamera, "Priority", priority);
}
CameraHelpers.MarkDirty(cmCamera.gameObject);
return new
{
success = true,
message = $"Priority set to {priority} on CinemachineCamera '{cmCamera.gameObject.name}'.",
data = new { instanceID = cmCamera.gameObject.GetInstanceIDCompat(), priority }
};
}
internal static object SetBody(JObject @params)
{
var cmCamera = CameraHelpers.FindCinemachineCamera(@params);
if (cmCamera == null) return new ErrorResponse("Target CinemachineCamera not found.");
var props = CameraHelpers.ExtractProperties(@params) ?? new JObject();
var go = cmCamera.gameObject;
// Optionally swap body component
string bodyTypeName = ParamCoercion.CoerceString(props["bodyType"] ?? props["body_type"], null);
Component bodyComponent;
if (bodyTypeName != null)
{
bodyComponent = SwapPipelineComponent(go, "Body", bodyTypeName);
if (bodyComponent == null)
return new ErrorResponse($"Could not resolve body component type '{bodyTypeName}'.");
}
else
{
bodyComponent = CameraHelpers.GetPipelineComponent(cmCamera, "Body");
if (bodyComponent == null)
return new ErrorResponse("No Body component found on this CinemachineCamera. Provide 'bodyType' to add one.");
}
// Set properties on body component
SetComponentProperties(bodyComponent, props, new[] { "bodyType", "body_type" });
CameraHelpers.MarkDirty(go);
return new
{
success = true,
message = $"Body configured on CinemachineCamera '{go.name}'.",
data = new { instanceID = go.GetInstanceIDCompat(), body = bodyComponent.GetType().Name }
};
}
internal static object SetAim(JObject @params)
{
var cmCamera = CameraHelpers.FindCinemachineCamera(@params);
if (cmCamera == null) return new ErrorResponse("Target CinemachineCamera not found.");
var props = CameraHelpers.ExtractProperties(@params) ?? new JObject();
var go = cmCamera.gameObject;
string aimTypeName = ParamCoercion.CoerceString(props["aimType"] ?? props["aim_type"], null);
Component aimComponent;
if (aimTypeName != null)
{
aimComponent = SwapPipelineComponent(go, "Aim", aimTypeName);
if (aimComponent == null)
return new ErrorResponse($"Could not resolve aim component type '{aimTypeName}'.");
}
else
{
aimComponent = CameraHelpers.GetPipelineComponent(cmCamera, "Aim");
if (aimComponent == null)
return new ErrorResponse("No Aim component found. Provide 'aimType' to add one.");
}
SetComponentProperties(aimComponent, props, new[] { "aimType", "aim_type" });
CameraHelpers.MarkDirty(go);
return new
{
success = true,
message = $"Aim configured on CinemachineCamera '{go.name}'.",
data = new { instanceID = go.GetInstanceIDCompat(), aim = aimComponent.GetType().Name }
};
}
internal static object SetNoise(JObject @params)
{
var cmCamera = CameraHelpers.FindCinemachineCamera(@params);
if (cmCamera == null) return new ErrorResponse("Target CinemachineCamera not found.");
var props = CameraHelpers.ExtractProperties(@params) ?? new JObject();
var go = cmCamera.gameObject;
// Get or add noise component
var noiseType = CameraHelpers.ResolveComponentType("CinemachineBasicMultiChannelPerlin");
if (noiseType == null)
return new ErrorResponse("CinemachineBasicMultiChannelPerlin type not found.");
var noiseComponent = go.GetComponent(noiseType);
bool added = false;
if (noiseComponent == null)
{
noiseComponent = Undo.AddComponent(go, noiseType);
added = true;
}
Undo.RecordObject(noiseComponent, "Set Cinemachine Noise");
SetComponentProperties(noiseComponent, props, Array.Empty<string>());
CameraHelpers.MarkDirty(go);
return new
{
success = true,
message = added
? $"Added noise to CinemachineCamera '{go.name}'."
: $"Noise configured on CinemachineCamera '{go.name}'.",
data = new { instanceID = go.GetInstanceIDCompat(), added }
};
}
internal static object AddExtension(JObject @params)
{
var cmCamera = CameraHelpers.FindCinemachineCamera(@params);
if (cmCamera == null) return new ErrorResponse("Target CinemachineCamera not found.");
var props = CameraHelpers.ExtractProperties(@params) ?? new JObject();
string extTypeName = ParamCoercion.CoerceString(
props["extensionType"] ?? props["extension_type"], null);
if (string.IsNullOrEmpty(extTypeName))
return new ErrorResponse("'extensionType' property is required.");
var extType = CameraHelpers.ResolveComponentType(extTypeName);
if (extType == null)
return new ErrorResponse($"Extension type '{extTypeName}' not found.");
var go = cmCamera.gameObject;
var existing = go.GetComponent(extType);
if (existing != null)
return new { success = true, message = $"Extension '{extTypeName}' already exists on '{go.name}'." };
var ext = Undo.AddComponent(go, extType);
SetComponentProperties(ext, props, new[] { "extensionType", "extension_type" });
CameraHelpers.MarkDirty(go);
return new
{
success = true,
message = $"Extension '{extTypeName}' added to CinemachineCamera '{go.name}'.",
data = new { instanceID = go.GetInstanceIDCompat(), extensionType = extTypeName }
};
}
internal static object RemoveExtension(JObject @params)
{
var cmCamera = CameraHelpers.FindCinemachineCamera(@params);
if (cmCamera == null) return new ErrorResponse("Target CinemachineCamera not found.");
var props = CameraHelpers.ExtractProperties(@params) ?? new JObject();
string extTypeName = ParamCoercion.CoerceString(
props["extensionType"] ?? props["extension_type"], null);
if (string.IsNullOrEmpty(extTypeName))
return new ErrorResponse("'extensionType' property is required.");
var extType = CameraHelpers.ResolveComponentType(extTypeName);
if (extType == null)
return new ErrorResponse($"Extension type '{extTypeName}' not found.");
var go = cmCamera.gameObject;
var ext = go.GetComponent(extType);
if (ext == null)
return new ErrorResponse($"Extension '{extTypeName}' not found on '{go.name}'.");
Undo.DestroyObjectImmediate(ext);
CameraHelpers.MarkDirty(go);
return new
{
success = true,
message = $"Extension '{extTypeName}' removed from CinemachineCamera '{go.name}'.",
data = new { instanceID = go.GetInstanceIDCompat() }
};
}
#endregion
#region Helpers
private static void SetFloatSubProp(SerializedProperty parent, string subPropName, JToken value)
{
if (value == null || value.Type == JTokenType.Null) return;
var sub = parent.FindPropertyRelative(subPropName)
?? parent.FindPropertyRelative("m_" + subPropName);
if (sub != null && sub.propertyType == SerializedPropertyType.Float)
sub.floatValue = ParamCoercion.CoerceFloat(value, sub.floatValue);
}
private static Component SwapPipelineComponent(GameObject go, string stage, string newTypeName)
{
var newType = CameraHelpers.ResolveComponentType(newTypeName);
if (newType == null) return null;
// Remove existing component of same pipeline stage
var cmCamera = go.GetComponent(CameraHelpers.CinemachineCameraType);
if (cmCamera != null)
{
var existing = CameraHelpers.GetPipelineComponent(cmCamera, stage);
if (existing != null && existing.GetType() != newType)
Undo.DestroyObjectImmediate(existing);
}
// Add new component if not already present
var comp = go.GetComponent(newType);
if (comp == null)
comp = Undo.AddComponent(go, newType);
return comp;
}
private static void SetComponentProperties(Component component, JObject props, string[] skipKeys)
{
if (component == null || props == null) return;
var skipSet = new System.Collections.Generic.HashSet<string>(
skipKeys, StringComparer.OrdinalIgnoreCase);
Undo.RecordObject(component, $"Configure {component.GetType().Name}");
foreach (var kv in props)
{
if (skipSet.Contains(kv.Key)) continue;
ComponentOps.SetProperty(component, kv.Key, kv.Value, out _);
}
}
#endregion
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 7a26286aeede4949844309a8a952b2b0
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,315 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using MCPForUnity.Editor.Helpers;
using Newtonsoft.Json.Linq;
using UnityEditor;
using UnityEngine;
using MCPForUnity.Runtime.Helpers;
namespace MCPForUnity.Editor.Tools.Cameras
{
internal static class CameraControl
{
internal static object ListCameras(JObject @params)
{
var unityCameras = UnityFindObjectsCompat.FindAll<UnityEngine.Camera>();
var cameraList = new List<object>();
var unityCamList = new List<object>();
// Cinemachine cameras
if (CameraHelpers.HasCinemachine)
{
var cmType = CameraHelpers.CinemachineCameraType;
var allCm = UnityFindObjectsCompat.FindAll(cmType);
foreach (Component cm in allCm)
{
var follow = CameraHelpers.GetReflectionProperty(cm, "Follow") as Transform;
var lookAt = CameraHelpers.GetReflectionProperty(cm, "LookAt") as Transform;
var isLive = CameraHelpers.GetReflectionProperty(cm, "IsLive");
var priority = CameraHelpers.ReadCinemachinePriority(cm);
var body = CameraHelpers.GetPipelineComponent(cm, "Body");
var aim = CameraHelpers.GetPipelineComponent(cm, "Aim");
var noise = CameraHelpers.GetPipelineComponent(cm, "Noise");
// Collect extensions
var extensions = new List<string>();
var cmExtBaseType = cm.GetType().Assembly.GetType("Unity.Cinemachine.CinemachineExtension");
if (cmExtBaseType != null)
{
foreach (var comp in cm.gameObject.GetComponents(cmExtBaseType))
{
if (comp != null)
extensions.Add(comp.GetType().Name);
}
}
cameraList.Add(new
{
instanceID = cm.gameObject.GetInstanceIDCompat(),
name = cm.gameObject.name,
isLive = isLive is bool b && b,
priority,
follow = follow != null ? new { name = follow.gameObject.name, instanceID = follow.gameObject.GetInstanceIDCompat() } : null,
lookAt = lookAt != null ? new { name = lookAt.gameObject.name, instanceID = lookAt.gameObject.GetInstanceIDCompat() } : null,
body = body?.GetType().Name,
aim = aim?.GetType().Name,
noise = noise?.GetType().Name,
extensions
});
}
}
// Unity cameras
foreach (var cam in unityCameras)
{
bool hasBrain = CameraHelpers.HasCinemachine &&
cam.gameObject.GetComponent(CameraHelpers.CinemachineBrainType) != null;
unityCamList.Add(new
{
instanceID = cam.gameObject.GetInstanceIDCompat(),
name = cam.gameObject.name,
depth = cam.depth,
fieldOfView = cam.fieldOfView,
hasBrain
});
}
// Brain info
object brainInfo = null;
if (CameraHelpers.HasCinemachine)
{
var brain = CameraHelpers.FindBrain();
if (brain != null)
{
var activeCam = CameraHelpers.GetReflectionProperty(brain, "ActiveVirtualCamera");
var isBlending = CameraHelpers.GetReflectionProperty(brain, "IsBlending");
string activeName = null;
int? activeID = null;
if (activeCam != null)
{
var nameProp = activeCam.GetType().GetProperty("Name");
activeName = nameProp?.GetValue(activeCam) as string;
if (activeCam is Component activeComp)
activeID = activeComp.gameObject.GetInstanceIDCompat();
}
brainInfo = new
{
exists = true,
gameObject = brain.gameObject.name,
instanceID = brain.gameObject.GetInstanceIDCompat(),
activeCameraName = activeName,
activeCameraID = activeID,
isBlending = isBlending is bool bl && bl
};
}
}
return new
{
success = true,
data = new
{
brain = brainInfo,
cinemachineCameras = cameraList,
unityCameras = unityCamList,
cinemachineInstalled = CameraHelpers.HasCinemachine
}
};
}
internal static object GetBrainStatus(JObject @params)
{
var brain = CameraHelpers.FindBrain();
if (brain == null)
return new ErrorResponse("No CinemachineBrain found in the scene.");
var activeCam = CameraHelpers.GetReflectionProperty(brain, "ActiveVirtualCamera");
var isBlending = CameraHelpers.GetReflectionProperty(brain, "IsBlending");
var activeBlend = CameraHelpers.GetReflectionProperty(brain, "ActiveBlend");
string activeName = null;
int? activeID = null;
if (activeCam != null)
{
var nameProp = activeCam.GetType().GetProperty("Name");
activeName = nameProp?.GetValue(activeCam) as string;
if (activeCam is Component comp)
activeID = comp.gameObject.GetInstanceIDCompat();
}
string blendDesc = null;
if (activeBlend != null)
{
var descProp = activeBlend.GetType().GetProperty("Description");
blendDesc = descProp?.GetValue(activeBlend) as string;
}
return new
{
success = true,
data = new
{
gameObject = brain.gameObject.name,
instanceID = brain.gameObject.GetInstanceIDCompat(),
activeCameraName = activeName,
activeCameraID = activeID,
isBlending = isBlending is bool b && b,
blendDescription = blendDesc
}
};
}
internal static object SetBlend(JObject @params)
{
var brain = CameraHelpers.FindBrain();
if (brain == null)
return new ErrorResponse("No CinemachineBrain found. Use 'ensure_brain' first.");
var props = CameraHelpers.ExtractProperties(@params) ?? new JObject();
Undo.RecordObject(brain, "Set Camera Blend");
using var so = new SerializedObject(brain);
var defaultBlendProp = so.FindProperty("DefaultBlend") ?? so.FindProperty("m_DefaultBlend");
if (defaultBlendProp == null)
return new ErrorResponse("Could not find DefaultBlend property on CinemachineBrain.");
string style = ParamCoercion.CoerceString(props["style"], null);
if (style != null)
{
var styleProp = defaultBlendProp.FindPropertyRelative("Style")
?? defaultBlendProp.FindPropertyRelative("m_Style");
if (styleProp != null && styleProp.propertyType == SerializedPropertyType.Enum)
{
// Try to parse the style enum
var enumNames = styleProp.enumNames;
int idx = Array.FindIndex(enumNames, n => n.Equals(style, StringComparison.OrdinalIgnoreCase));
if (idx >= 0)
styleProp.enumValueIndex = idx;
}
}
float duration = ParamCoercion.CoerceFloat(props["duration"], -1f);
if (duration >= 0)
{
var timeProp = defaultBlendProp.FindPropertyRelative("Time")
?? defaultBlendProp.FindPropertyRelative("m_Time");
if (timeProp != null)
timeProp.floatValue = duration;
}
so.ApplyModifiedProperties();
CameraHelpers.MarkDirty(brain.gameObject);
return new
{
success = true,
message = "Default blend configured on CinemachineBrain.",
data = new { instanceID = brain.gameObject.GetInstanceIDCompat() }
};
}
private static int _overrideId = -1;
internal static object ForceCamera(JObject @params)
{
var brain = CameraHelpers.FindBrain();
if (brain == null)
return new ErrorResponse("No CinemachineBrain found. Use 'ensure_brain' first.");
var cmCamera = CameraHelpers.FindCinemachineCamera(@params);
if (cmCamera == null)
return new ErrorResponse("Target CinemachineCamera not found.");
// Use SetCameraOverride via reflection
var brainType = brain.GetType();
var method = brainType.GetMethod("SetCameraOverride",
BindingFlags.Public | BindingFlags.Instance);
if (method == null)
{
// Fallback: just set high priority
CameraHelpers.SetReflectionProperty(cmCamera, "Priority", 999);
return new
{
success = true,
message = $"Set high priority on '{cmCamera.gameObject.name}' (SetCameraOverride not available).",
data = new { instanceID = cmCamera.gameObject.GetInstanceIDCompat(), method = "priority" }
};
}
try
{
// CM3 signature: SetCameraOverride(int overrideId, int priority,
// ICinemachineCamera camA, ICinemachineCamera camB, float weightB, float deltaTime)
// -1 for overrideId creates a new override; same cam for A+B with weight=1 = instant switch
_overrideId = (int)method.Invoke(brain, new object[]
{
_overrideId >= 0 ? _overrideId : -1,
999, // high priority to win over all others
cmCamera, // camA (at weight=0)
cmCamera, // camB (at weight=1) — same camera = no blend
1f, // weightB = fully on camB
-1f // deltaTime = use default
});
}
catch (Exception ex)
{
// Fallback
CameraHelpers.SetReflectionProperty(cmCamera, "Priority", 999);
return new
{
success = true,
message = $"Forced via priority (override failed: {ex.Message}).",
data = new { instanceID = cmCamera.gameObject.GetInstanceIDCompat(), method = "priority" }
};
}
return new
{
success = true,
message = $"Camera overridden to '{cmCamera.gameObject.name}'.",
data = new
{
instanceID = cmCamera.gameObject.GetInstanceIDCompat(),
overrideId = _overrideId,
method = "override"
}
};
}
internal static object ReleaseOverride(JObject @params)
{
var brain = CameraHelpers.FindBrain();
if (brain == null)
return new ErrorResponse("No CinemachineBrain found.");
if (_overrideId < 0)
return new { success = true, message = "No active camera override to release." };
var method = brain.GetType().GetMethod("ReleaseCameraOverride",
BindingFlags.Public | BindingFlags.Instance);
if (method != null)
{
method.Invoke(brain, new object[] { _overrideId });
int releasedId = _overrideId;
_overrideId = -1;
return new
{
success = true,
message = "Camera override released.",
data = new { releasedOverrideId = releasedId }
};
}
_overrideId = -1;
return new { success = true, message = "Override state cleared." };
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 6644251762504798895ef138ff182d29
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,251 @@
using System;
using System.Collections.Generic;
using System.Reflection;
using MCPForUnity.Editor.Helpers;
using Newtonsoft.Json.Linq;
using UnityEditor;
using UnityEngine;
using MCPForUnity.Runtime.Helpers;
namespace MCPForUnity.Editor.Tools.Cameras
{
internal static class CameraCreate
{
private static readonly Dictionary<string, (string body, string aim)> Presets = new(StringComparer.OrdinalIgnoreCase)
{
["follow"] = ("CinemachineFollow", "CinemachineRotationComposer"),
["third_person"] = ("CinemachineThirdPersonFollow", "CinemachineRotationComposer"),
["freelook"] = ("CinemachineOrbitalFollow", "CinemachineRotationComposer"),
["dolly"] = ("CinemachineSplineDolly", "CinemachineRotationComposer"),
["static"] = (null, "CinemachineHardLookAt"),
["top_down"] = ("CinemachineFollow", null),
["side_scroller"] = ("CinemachinePositionComposer", null),
};
internal static object CreateBasicCamera(JObject @params)
{
var props = CameraHelpers.ExtractProperties(@params) ?? new JObject();
string name = ParamCoercion.CoerceString(props["name"], null) ?? "Camera";
float fov = ParamCoercion.CoerceFloat(props["fieldOfView"], 60f);
float near = ParamCoercion.CoerceFloat(props["nearClipPlane"], 0.3f);
float far = ParamCoercion.CoerceFloat(props["farClipPlane"], 1000f);
var go = new GameObject(name);
Undo.RegisterCreatedObjectUndo(go, $"Create Camera '{name}'");
var cam = go.AddComponent<UnityEngine.Camera>();
cam.fieldOfView = fov;
cam.nearClipPlane = near;
cam.farClipPlane = far;
// Position near follow target if provided
string follow = ParamCoercion.CoerceString(props["follow"], null);
if (follow != null)
{
var target = CameraHelpers.ResolveGameObjectRef(follow);
if (target != null)
go.transform.position = target.transform.position + new Vector3(0, 5, -10);
}
// Look at target if provided
string lookAt = ParamCoercion.CoerceString(props["lookAt"] ?? props["look_at"], null);
if (lookAt != null)
{
var target = CameraHelpers.ResolveGameObjectRef(lookAt);
if (target != null)
go.transform.LookAt(target.transform);
}
CameraHelpers.MarkDirty(go);
return new
{
success = true,
message = $"Created basic Camera '{name}' (Cinemachine not installed — using Unity Camera).",
data = new
{
instanceID = go.GetInstanceIDCompat(),
cinemachine = false,
hint = "Install com.unity.cinemachine for presets, blending, and virtual camera features."
}
};
}
internal static object CreateCinemachineCamera(JObject @params)
{
var props = CameraHelpers.ExtractProperties(@params) ?? new JObject();
string name = ParamCoercion.CoerceString(props["name"], null) ?? "CM Camera";
string preset = ParamCoercion.CoerceString(props["preset"], null) ?? "follow";
int priority = ParamCoercion.CoerceInt(props["priority"], 10);
if (!Presets.TryGetValue(preset, out var presetDef))
{
return new ErrorResponse(
$"Unknown preset '{preset}'. Valid presets: {string.Join(", ", Presets.Keys)}.");
}
var go = new GameObject(name);
Undo.RegisterCreatedObjectUndo(go, $"Create CinemachineCamera '{name}'");
// Add CinemachineCamera component
var cmType = CameraHelpers.CinemachineCameraType;
var cmCamera = go.AddComponent(cmType);
// PrioritySettings is a struct with Enabled + m_Value — use SerializedProperty
using (var so = new SerializedObject(cmCamera))
{
var priorityProp = so.FindProperty("Priority");
if (priorityProp != null)
{
var enabledProp = priorityProp.FindPropertyRelative("Enabled");
var valueProp = priorityProp.FindPropertyRelative("m_Value");
if (enabledProp != null) enabledProp.boolValue = true;
if (valueProp != null) valueProp.intValue = priority;
so.ApplyModifiedProperties();
}
else
{
CameraHelpers.SetReflectionProperty(cmCamera, "Priority", priority);
}
}
// Add Body component
string bodyName = null;
if (presetDef.body != null)
{
var bodyType = CameraHelpers.ResolveComponentType(presetDef.body);
if (bodyType != null)
{
go.AddComponent(bodyType);
bodyName = presetDef.body;
}
}
// Add Aim component
string aimName = null;
if (presetDef.aim != null)
{
var aimType = CameraHelpers.ResolveComponentType(presetDef.aim);
if (aimType != null)
{
go.AddComponent(aimType);
aimName = presetDef.aim;
}
}
// Set Follow target
var followToken = props["follow"];
if (followToken != null && followToken.Type != JTokenType.Null)
CameraHelpers.SetTransformTarget(cmCamera, "Follow", followToken);
// Set LookAt target
var lookAtToken = props["lookAt"] ?? props["look_at"];
if (lookAtToken != null && lookAtToken.Type != JTokenType.Null)
CameraHelpers.SetTransformTarget(cmCamera, "LookAt", lookAtToken);
CameraHelpers.MarkDirty(go);
return new
{
success = true,
message = $"Created CinemachineCamera '{name}' with preset '{preset}'.",
data = new
{
instanceID = go.GetInstanceIDCompat(),
cinemachine = true,
preset,
priority,
body = bodyName,
aim = aimName
}
};
}
internal static object EnsureBrain(JObject @params)
{
var props = CameraHelpers.ExtractProperties(@params) ?? new JObject();
// Check if Brain already exists
var existingBrain = CameraHelpers.FindBrain();
if (existingBrain != null)
{
return new
{
success = true,
message = $"CinemachineBrain already exists on '{existingBrain.gameObject.name}'.",
data = new
{
instanceID = existingBrain.gameObject.GetInstanceIDCompat(),
alreadyExisted = true
}
};
}
// Find target camera
string cameraRef = ParamCoercion.CoerceString(props["camera"], null);
UnityEngine.Camera cam;
if (cameraRef != null)
{
var camGo = CameraHelpers.ResolveGameObjectRef(cameraRef);
cam = camGo != null ? camGo.GetComponent<UnityEngine.Camera>() : null;
}
else
{
cam = CameraHelpers.FindMainCamera();
}
if (cam == null)
return new ErrorResponse("No Camera found to add CinemachineBrain to.");
var brainType = CameraHelpers.CinemachineBrainType;
Undo.RecordObject(cam.gameObject, "Add CinemachineBrain");
var brain = cam.gameObject.AddComponent(brainType);
// Configure default blend if provided
string blendStyle = ParamCoercion.CoerceString(props["defaultBlendStyle"] ?? props["default_blend_style"], null);
float blendDuration = ParamCoercion.CoerceFloat(props["defaultBlendDuration"] ?? props["default_blend_duration"], -1f);
if (blendStyle != null || blendDuration >= 0)
{
// Set via SerializedProperty for the DefaultBlend struct
using var so = new SerializedObject(brain);
var defaultBlendProp = so.FindProperty("DefaultBlend") ?? so.FindProperty("m_DefaultBlend");
if (defaultBlendProp != null)
{
if (blendStyle != null)
{
var styleProp = defaultBlendProp.FindPropertyRelative("Style")
?? defaultBlendProp.FindPropertyRelative("m_Style");
if (styleProp != null)
{
int idx = Array.FindIndex(styleProp.enumNames,
n => n.Equals(blendStyle, StringComparison.OrdinalIgnoreCase));
if (idx >= 0)
styleProp.enumValueIndex = idx;
}
}
if (blendDuration >= 0)
{
var timeProp = defaultBlendProp.FindPropertyRelative("Time")
?? defaultBlendProp.FindPropertyRelative("m_Time");
if (timeProp != null)
timeProp.floatValue = blendDuration;
}
so.ApplyModifiedProperties();
}
}
CameraHelpers.MarkDirty(cam.gameObject);
return new
{
success = true,
message = $"CinemachineBrain added to '{cam.gameObject.name}'.",
data = new
{
instanceID = cam.gameObject.GetInstanceIDCompat(),
alreadyExisted = false
}
};
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: a849a1ac03d245fe823e4c02b9e722d5
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,270 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using MCPForUnity.Editor.Helpers;
using MCPForUnity.Runtime.Helpers;
using Newtonsoft.Json.Linq;
using UnityEditor;
using UnityEngine;
namespace MCPForUnity.Editor.Tools.Cameras
{
internal static class CameraHelpers
{
private static bool? _hasCinemachine;
private static Type _cmCameraType;
private static Type _cmBrainType;
internal static bool HasCinemachine
{
get
{
if (_hasCinemachine == null)
DetectCinemachine();
return _hasCinemachine.Value;
}
}
internal static Type CinemachineCameraType
{
get
{
if (_hasCinemachine == null)
DetectCinemachine();
return _cmCameraType;
}
}
internal static Type CinemachineBrainType
{
get
{
if (_hasCinemachine == null)
DetectCinemachine();
return _cmBrainType;
}
}
private static void DetectCinemachine()
{
_cmCameraType = UnityTypeResolver.ResolveComponent("CinemachineCamera");
_cmBrainType = UnityTypeResolver.ResolveComponent("CinemachineBrain");
_hasCinemachine = _cmCameraType != null && _cmBrainType != null;
}
internal static string GetCinemachineVersion()
{
if (!HasCinemachine || _cmCameraType == null)
return null;
try
{
var assembly = _cmCameraType.Assembly;
var version = assembly.GetName().Version;
return version?.ToString();
}
catch
{
return "unknown";
}
}
internal static GameObject FindTargetGameObject(JObject @params)
{
var targetToken = @params["target"];
if (targetToken == null)
return null;
string searchMethod = ParamCoercion.CoerceString(
@params["searchMethod"] ?? @params["search_method"], "by_name");
if (targetToken.Type == JTokenType.Integer)
{
int instanceId = targetToken.Value<int>();
return GameObjectLookup.FindById(instanceId);
}
string targetStr = targetToken.ToString();
if (int.TryParse(targetStr, out int parsedId))
{
var byId = GameObjectLookup.FindById(parsedId);
if (byId != null) return byId;
}
return GameObjectLookup.FindByTarget(targetToken, searchMethod, true);
}
internal static GameObject ResolveGameObjectRef(object reference)
{
if (reference == null) return null;
if (reference is JToken jt)
{
if (jt.Type == JTokenType.Integer)
return GameObjectLookup.FindById(jt.Value<int>());
if (jt.Type == JTokenType.String)
{
string str = jt.ToString();
if (int.TryParse(str, out int id))
{
var byId = GameObjectLookup.FindById(id);
if (byId != null) return byId;
}
return GameObjectLookup.FindByTarget(jt, "by_name", true);
}
}
if (reference is string s)
{
if (int.TryParse(s, out int id))
{
var byId = GameObjectLookup.FindById(id);
if (byId != null) return byId;
}
var ids = GameObjectLookup.SearchGameObjects(
GameObjectLookup.SearchMethod.ByName, s, includeInactive: true, maxResults: 1);
return ids.Count > 0 ? GameObjectLookup.FindById(ids[0]) : null;
}
return null;
}
internal static Component FindCinemachineCamera(JObject @params)
{
if (!HasCinemachine) return null;
var go = FindTargetGameObject(@params);
return go != null ? go.GetComponent(CinemachineCameraType) : null;
}
internal static Component FindBrain()
{
if (!HasCinemachine || _cmBrainType == null)
return null;
return UnityFindObjectsCompat.FindAny(_cmBrainType) as Component;
}
internal static UnityEngine.Camera FindMainCamera()
{
var main = UnityEngine.Camera.main;
if (main != null) return main;
var allCams = UnityFindObjectsCompat.FindAll<UnityEngine.Camera>();
return allCams.Length > 0 ? allCams[0] : null;
}
internal static JObject ExtractProperties(JObject @params)
{
var props = @params["properties"] as JObject;
if (props != null) return props;
var propsStr = ParamCoercion.CoerceString(@params["properties"], null);
if (propsStr != null)
{
try { return JObject.Parse(propsStr); }
catch { return null; }
}
return null;
}
internal static object GetReflectionProperty(Component component, string propertyName)
{
if (component == null) return null;
var type = component.GetType();
var prop = type.GetProperty(propertyName, BindingFlags.Public | BindingFlags.Instance);
return prop?.GetValue(component);
}
/// <summary>Read priority int from a CinemachineCamera component via SerializedObject.</summary>
internal static int ReadCinemachinePriority(Component cmCamera)
{
if (cmCamera == null) return 0;
using var so = new SerializedObject(cmCamera);
var priorityProp = so.FindProperty("Priority");
if (priorityProp == null) return 0;
var enabledProp = priorityProp.FindPropertyRelative("Enabled");
var valueProp = priorityProp.FindPropertyRelative("m_Value");
if (enabledProp != null && !enabledProp.boolValue) return 0;
return valueProp?.intValue ?? 0;
}
internal static bool SetReflectionProperty(Component component, string propertyName, object value)
{
if (component == null) return false;
var type = component.GetType();
var prop = type.GetProperty(propertyName, BindingFlags.Public | BindingFlags.Instance);
if (prop == null || !prop.CanWrite) return false;
prop.SetValue(component, value);
return true;
}
internal static void SetTransformTarget(Component cmCamera, string propertyName, JToken targetRef)
{
if (cmCamera == null) return;
if (targetRef == null || targetRef.Type == JTokenType.Null)
{
SetReflectionProperty(cmCamera, propertyName, null);
return;
}
var go = ResolveGameObjectRef(targetRef);
if (go != null)
SetReflectionProperty(cmCamera, propertyName, go.transform);
}
internal static Type ResolveComponentType(string typeName)
{
return UnityTypeResolver.ResolveComponent(typeName);
}
internal static Component GetPipelineComponent(Component cmCamera, string stageName)
{
if (cmCamera == null) return null;
var type = cmCamera.GetType();
// CinemachineCamera.GetCinemachineComponent(CinemachineCore.Stage stage)
var stageEnumType = type.Assembly.GetType("Unity.Cinemachine.CinemachineCore+Stage")
?? type.Assembly.GetType("Unity.Cinemachine.CinemachineCore")?.GetNestedType("Stage");
if (stageEnumType == null) return null;
object stageEnum;
try { stageEnum = Enum.Parse(stageEnumType, stageName, true); }
catch { return null; }
var method = type.GetMethod("GetCinemachineComponent",
BindingFlags.Public | BindingFlags.Instance,
null, new[] { stageEnumType }, null);
if (method == null) return null;
return method.Invoke(cmCamera, new[] { stageEnum }) as Component;
}
internal static string GetFallbackSuggestion(string action)
{
return action switch
{
"set_body" or "set_aim" => "Use 'set_lens' and 'set_target' for basic camera configuration.",
"set_blend" => "Without Cinemachine, switch cameras by enabling/disabling Camera components.",
"set_noise" => "Camera shake without Cinemachine requires a custom script.",
"ensure_brain" => "CinemachineBrain requires the Cinemachine package. Basic Camera does not need a Brain.",
"get_brain_status" => "No CinemachineBrain available. Cinemachine package not installed.",
_ => "Install Cinemachine via Window > Package Manager."
};
}
internal static void MarkDirty(GameObject go)
{
if (go == null) return;
EditorUtility.SetDirty(go);
var prefabStage = UnityEditor.SceneManagement.PrefabStageUtility.GetCurrentPrefabStage();
if (prefabStage != null)
UnityEditor.SceneManagement.EditorSceneManager.MarkSceneDirty(prefabStage.scene);
else
UnityEditor.SceneManagement.EditorSceneManager.MarkSceneDirty(go.scene);
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 9664df00dcfa4a22b45ffa104bf29e46
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,134 @@
using System;
using Newtonsoft.Json.Linq;
using MCPForUnity.Editor.Helpers;
namespace MCPForUnity.Editor.Tools.Cameras
{
[McpForUnityTool("manage_camera", AutoRegister = false)]
public static class ManageCamera
{
public static object HandleCommand(JObject @params)
{
if (@params == null)
return new ErrorResponse("Parameters cannot be null.");
var p = new ToolParams(@params);
string action = p.Get("action")?.ToLowerInvariant();
if (string.IsNullOrEmpty(action))
return new ErrorResponse("'action' parameter is required.");
try
{
// Tier 1: Always-available actions (basic Camera fallback)
switch (action)
{
case "ping":
return new
{
success = true,
message = CameraHelpers.HasCinemachine
? "Cinemachine is available."
: "Cinemachine not installed. Basic Camera operations available.",
data = new
{
cinemachine = CameraHelpers.HasCinemachine,
version = CameraHelpers.GetCinemachineVersion()
}
};
case "create_camera":
return CameraHelpers.HasCinemachine
? CameraCreate.CreateCinemachineCamera(@params)
: CameraCreate.CreateBasicCamera(@params);
case "set_target":
return CameraHelpers.HasCinemachine
? CameraConfigure.SetCinemachineTarget(@params)
: CameraConfigure.SetBasicCameraTarget(@params);
case "set_lens":
return CameraHelpers.HasCinemachine
? CameraConfigure.SetCinemachineLens(@params)
: CameraConfigure.SetBasicCameraLens(@params);
case "set_priority":
return CameraHelpers.HasCinemachine
? CameraConfigure.SetCinemachinePriority(@params)
: CameraConfigure.SetBasicCameraPriority(@params);
case "list_cameras":
return CameraControl.ListCameras(@params);
case "screenshot":
case "screenshot_multiview":
{
// Delegate to ManageScene's screenshot infrastructure
var shotParams = new JObject(@params);
shotParams["action"] = "screenshot";
if (action == "screenshot_multiview")
{
shotParams["batch"] = "surround";
shotParams["includeImage"] = true;
}
return ManageScene.HandleCommand(shotParams);
}
}
// Tier 2: Cinemachine-only actions
if (!CameraHelpers.HasCinemachine)
{
return new ErrorResponse(
$"Action '{action}' requires the Cinemachine package (com.unity.cinemachine). "
+ CameraHelpers.GetFallbackSuggestion(action));
}
switch (action)
{
case "ensure_brain":
return CameraCreate.EnsureBrain(@params);
case "get_brain_status":
return CameraControl.GetBrainStatus(@params);
case "set_body":
return CameraConfigure.SetBody(@params);
case "set_aim":
return CameraConfigure.SetAim(@params);
case "set_noise":
return CameraConfigure.SetNoise(@params);
case "add_extension":
return CameraConfigure.AddExtension(@params);
case "remove_extension":
return CameraConfigure.RemoveExtension(@params);
case "set_blend":
return CameraControl.SetBlend(@params);
case "force_camera":
return CameraControl.ForceCamera(@params);
case "release_override":
return CameraControl.ReleaseOverride(@params);
default:
return new ErrorResponse(
$"Unknown action: '{action}'. Valid actions: ping, create_camera, set_target, "
+ "set_lens, set_priority, list_cameras, screenshot, screenshot_multiview, "
+ "ensure_brain, get_brain_status, "
+ "set_body, set_aim, set_noise, add_extension, remove_extension, "
+ "set_blend, force_camera, release_override.");
}
}
catch (Exception ex)
{
McpLog.Error($"[ManageCamera] Action '{action}' failed: {ex}");
return new ErrorResponse($"Error in action '{action}': {ex.Message}");
}
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 2f61f34d01e54d5ba8e47acab546ed98
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
+495
View File
@@ -0,0 +1,495 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using System.Threading.Tasks;
using MCPForUnity.Editor.Helpers;
using MCPForUnity.Editor.Resources;
using MCPForUnity.Runtime.Helpers;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
namespace MCPForUnity.Editor.Tools
{
/// <summary>
/// Holds information about a registered command handler.
/// </summary>
class HandlerInfo
{
public string CommandName { get; }
public Func<JObject, object> SyncHandler { get; }
public Func<JObject, Task<object>> AsyncHandler { get; }
public bool IsAsync => AsyncHandler != null;
public HandlerInfo(string commandName, Func<JObject, object> syncHandler, Func<JObject, Task<object>> asyncHandler)
{
CommandName = commandName;
SyncHandler = syncHandler;
AsyncHandler = asyncHandler;
}
}
/// <summary>
/// Registry for all MCP command handlers via reflection.
/// Handles both MCP tools and resources.
/// </summary>
public static class CommandRegistry
{
private static readonly Dictionary<string, HandlerInfo> _handlers = new();
private static bool _initialized = false;
/// <summary>
/// Initialize and auto-discover all tools and resources marked with
/// [McpForUnityTool] or [McpForUnityResource]
/// </summary>
public static void Initialize()
{
if (_initialized) return;
AutoDiscoverCommands();
_initialized = true;
}
private static string ToSnakeCase(string name) => StringCaseUtility.ToSnakeCase(name);
/// <summary>
/// Auto-discover all types with [McpForUnityTool] or [McpForUnityResource] attributes
/// </summary>
private static void AutoDiscoverCommands()
{
// AssetImportWorker is a separate Editor subprocess. It doesn't host the MCP
// transport so the registry is unused there, and Mono can hard-crash inside
// GetCustomAttribute<T>() when scanning types whose owning assembly hasn't
// finished domain-reload bookkeeping in the worker. Skip the scan there
// entirely. See issue #1134.
if (IsRunningInAssetImportWorker())
{
return;
}
try
{
var allTypes = UnityAssembliesCompat.GetLoadedAssemblies()
.Where(a => !a.IsDynamic)
.SelectMany(a =>
{
try { return a.GetTypes(); }
catch { return new Type[0]; }
})
.ToList();
// Discover tools
var toolTypes = allTypes.Where(t => HasAttributeSafe<McpForUnityToolAttribute>(t));
int toolCount = 0;
foreach (var type in toolTypes)
{
if (RegisterCommandType(type, isResource: false))
toolCount++;
}
// Discover resources
var resourceTypes = allTypes.Where(t => HasAttributeSafe<McpForUnityResourceAttribute>(t));
int resourceCount = 0;
foreach (var type in resourceTypes)
{
if (RegisterCommandType(type, isResource: true))
resourceCount++;
}
McpLog.Info($"Auto-discovered {toolCount} tools and {resourceCount} resources ({_handlers.Count} total handlers)", false);
}
catch (Exception ex)
{
McpLog.Error($"Failed to auto-discover MCP commands: {ex.Message}");
}
}
private static bool HasAttributeSafe<T>(Type type) where T : Attribute
{
try
{
return type.GetCustomAttribute<T>() != null;
}
catch
{
// Type metadata can be in a half-loaded state during domain reload; treat
// those as "no attribute" rather than aborting the whole scan.
return false;
}
}
private static bool? _cachedIsAssetImportWorker;
private static bool IsRunningInAssetImportWorker()
{
if (_cachedIsAssetImportWorker.HasValue)
return _cachedIsAssetImportWorker.Value;
bool result = false;
try
{
// AssetDatabase.IsAssetImportWorkerProcess() exists on Unity 2020.2+ but the
// visibility has shifted between versions. Look it up reflectively so we
// tolerate either signature without conditional compilation.
var method = typeof(UnityEditor.AssetDatabase).GetMethod(
"IsAssetImportWorkerProcess",
BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic);
if (method != null && method.GetParameters().Length == 0)
{
result = method.Invoke(null, null) is bool b && b;
}
}
catch
{
// Reflection problems shouldn't break startup; fall through to the cmdline check.
}
if (!result)
{
try
{
string cmd = Environment.CommandLine ?? string.Empty;
if (cmd.IndexOf("-importWorker", StringComparison.OrdinalIgnoreCase) >= 0
|| cmd.IndexOf("AssetImportWorker", StringComparison.OrdinalIgnoreCase) >= 0)
{
result = true;
}
}
catch { }
}
_cachedIsAssetImportWorker = result;
return result;
}
/// <summary>
/// Register a command type (tool or resource) with the registry.
/// Returns true if successfully registered, false otherwise.
/// </summary>
private static bool RegisterCommandType(Type type, bool isResource)
{
string commandName;
string typeLabel = isResource ? "resource" : "tool";
// Get command name from appropriate attribute
if (isResource)
{
var resourceAttr = type.GetCustomAttribute<McpForUnityResourceAttribute>();
commandName = resourceAttr.ResourceName;
}
else
{
var toolAttr = type.GetCustomAttribute<McpForUnityToolAttribute>();
commandName = toolAttr.CommandName;
}
// Auto-generate command name if not explicitly provided
if (string.IsNullOrEmpty(commandName))
{
commandName = ToSnakeCase(type.Name);
}
// Check for duplicate command names
if (_handlers.ContainsKey(commandName))
{
McpLog.Warn(
$"Duplicate command name '{commandName}' detected. " +
$"{typeLabel} {type.Name} will override previously registered handler."
);
}
// Find HandleCommand method
var method = type.GetMethod(
"HandleCommand",
BindingFlags.Public | BindingFlags.Static,
null,
new[] { typeof(JObject) },
null
);
if (method == null)
{
McpLog.Warn(
$"MCP {typeLabel} {type.Name} is marked with [McpForUnity{(isResource ? "Resource" : "Tool")}] " +
$"but has no public static HandleCommand(JObject) method"
);
return false;
}
try
{
HandlerInfo handlerInfo;
if (typeof(Task).IsAssignableFrom(method.ReturnType))
{
var asyncHandler = CreateAsyncHandlerDelegate(method, commandName);
handlerInfo = new HandlerInfo(commandName, null, asyncHandler);
}
else
{
var handler = (Func<JObject, object>)Delegate.CreateDelegate(
typeof(Func<JObject, object>),
method
);
handlerInfo = new HandlerInfo(commandName, handler, null);
}
_handlers[commandName] = handlerInfo;
return true;
}
catch (Exception ex)
{
McpLog.Error($"Failed to register {typeLabel} {type.Name}: {ex.Message}");
return false;
}
}
/// <summary>
/// Get a command handler by name
/// </summary>
private static HandlerInfo GetHandlerInfo(string commandName)
{
if (!_handlers.TryGetValue(commandName, out var handler))
{
throw new InvalidOperationException(
$"Unknown or unsupported command type: {commandName}"
);
}
return handler;
}
/// <summary>
/// Get a synchronous command handler by name.
/// Throws if the command is asynchronous.
/// </summary>
/// <param name="commandName"></param>
/// <returns></returns>
/// <exception cref="InvalidOperationException"></exception>
public static Func<JObject, object> GetHandler(string commandName)
{
var handlerInfo = GetHandlerInfo(commandName);
if (handlerInfo.IsAsync)
{
throw new InvalidOperationException(
$"Command '{commandName}' is asynchronous and must be executed via ExecuteCommand"
);
}
return handlerInfo.SyncHandler;
}
/// <summary>
/// Execute a command handler, supporting both synchronous and asynchronous (coroutine) handlers.
/// If the handler returns an IEnumerator, it will be executed as a coroutine.
/// </summary>
/// <param name="commandName">The command name to execute</param>
/// <param name="params">Command parameters</param>
/// <param name="tcs">TaskCompletionSource to complete when async operation finishes</param>
/// <returns>The result for synchronous commands, or null for async commands (TCS will be completed later)</returns>
public static object ExecuteCommand(string commandName, JObject @params, TaskCompletionSource<string> tcs)
{
var handlerInfo = GetHandlerInfo(commandName);
if (handlerInfo.IsAsync)
{
ExecuteAsyncHandler(handlerInfo, @params, commandName, tcs);
return null;
}
if (handlerInfo.SyncHandler == null)
{
throw new InvalidOperationException($"Handler for '{commandName}' does not provide a synchronous implementation");
}
return handlerInfo.SyncHandler(@params);
}
/// <summary>
/// Execute a command handler and return its raw result, regardless of sync or async implementation.
/// Used internally for features like batch execution where commands need to be composed.
/// </summary>
/// <param name="commandName">The registered command to execute.</param>
/// <param name="params">Parameters to pass to the command (optional).</param>
public static Task<object> InvokeCommandAsync(string commandName, JObject @params)
{
var handlerInfo = GetHandlerInfo(commandName);
var payload = @params ?? new JObject();
if (handlerInfo.IsAsync)
{
if (handlerInfo.AsyncHandler == null)
{
throw new InvalidOperationException($"Async handler for '{commandName}' is not configured correctly");
}
return handlerInfo.AsyncHandler(payload);
}
if (handlerInfo.SyncHandler == null)
{
throw new InvalidOperationException($"Handler for '{commandName}' does not provide a synchronous implementation");
}
object result = handlerInfo.SyncHandler(payload);
return Task.FromResult(result);
}
/// <summary>
/// Create a delegate for an async handler method that returns Task or Task<T>.
/// The delegate will invoke the method and await its completion, returning the result.
/// </summary>
/// <param name="method"></param>
/// <param name="commandName"></param>
/// <returns></returns>
/// <exception cref="InvalidOperationException"></exception>
private static Func<JObject, Task<object>> CreateAsyncHandlerDelegate(MethodInfo method, string commandName)
{
return async (JObject parameters) =>
{
object rawResult;
try
{
rawResult = method.Invoke(null, new object[] { parameters });
}
catch (TargetInvocationException ex)
{
throw ex.InnerException ?? ex;
}
if (rawResult == null)
{
return null;
}
if (rawResult is not Task task)
{
throw new InvalidOperationException(
$"Async handler '{commandName}' returned an object that is not a Task"
);
}
await task.ConfigureAwait(true);
var taskType = task.GetType();
if (taskType.IsGenericType)
{
var resultProperty = taskType.GetProperty("Result");
if (resultProperty != null)
{
return resultProperty.GetValue(task);
}
}
return null;
};
}
private static void ExecuteAsyncHandler(
HandlerInfo handlerInfo,
JObject parameters,
string commandName,
TaskCompletionSource<string> tcs)
{
if (handlerInfo.AsyncHandler == null)
{
throw new InvalidOperationException($"Async handler for '{commandName}' is not configured correctly");
}
Task<object> handlerTask;
try
{
handlerTask = handlerInfo.AsyncHandler(parameters);
}
catch (Exception ex)
{
ReportAsyncFailure(commandName, tcs, ex);
return;
}
if (handlerTask == null)
{
CompleteAsyncCommand(commandName, tcs, null);
return;
}
async void AwaitHandler()
{
try
{
var finalResult = await handlerTask.ConfigureAwait(true);
CompleteAsyncCommand(commandName, tcs, finalResult);
}
catch (Exception ex)
{
ReportAsyncFailure(commandName, tcs, ex);
}
}
AwaitHandler();
}
/// <summary>
/// Complete the TaskCompletionSource for an async command with a success result.
/// </summary>
/// <param name="commandName"></param>
/// <param name="tcs"></param>
/// <param name="result"></param>
private static void CompleteAsyncCommand(string commandName, TaskCompletionSource<string> tcs, object result)
{
try
{
var response = new { status = "success", result };
string json = JsonConvert.SerializeObject(response);
if (!tcs.TrySetResult(json))
{
McpLog.Warn($"TCS for async command '{commandName}' was already completed");
}
}
catch (Exception ex)
{
McpLog.Error($"Error completing async command '{commandName}': {ex.Message}\n{ex.StackTrace}");
ReportAsyncFailure(commandName, tcs, ex);
}
}
/// <summary>
/// Report an error that occurred during async command execution.
/// Completes the TaskCompletionSource with an error response.
/// </summary>
/// <param name="commandName"></param>
/// <param name="tcs"></param>
/// <param name="ex"></param>
private static void ReportAsyncFailure(string commandName, TaskCompletionSource<string> tcs, Exception ex)
{
McpLog.Error($"Error in async command '{commandName}': {ex.Message}\n{ex.StackTrace}");
var errorResponse = new
{
status = "error",
error = ex.Message,
command = commandName,
stackTrace = ex.StackTrace
};
string json;
try
{
json = JsonConvert.SerializeObject(errorResponse);
}
catch (Exception serializationEx)
{
McpLog.Error($"Failed to serialize error response for '{commandName}': {serializationEx.Message}");
json = "{\"status\":\"error\",\"error\":\"Failed to complete command\"}";
}
if (!tcs.TrySetResult(json))
{
McpLog.Warn($"TCS for async command '{commandName}' was already completed when trying to report error");
}
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 5b61b5a84813b5749a5c64422694a0fa
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
+709
View File
@@ -0,0 +1,709 @@
using System;
using System.CodeDom.Compiler;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Text;
using MCPForUnity.Editor.Helpers;
using MCPForUnity.Runtime.Helpers;
using Microsoft.CSharp;
using Newtonsoft.Json.Linq;
using UnityEngine;
namespace MCPForUnity.Editor.Tools
{
[McpForUnityTool("execute_code", AutoRegister = false, Group = "scripting_ext")]
public static class ExecuteCode
{
private const int MaxCodeLength = 50000;
private const int MaxHistoryEntries = 50;
private const int MaxHistoryCodePreview = 500;
internal const int WrapperLineOffset = 10;
private const string WrapperClassName = "MCPDynamicCode";
private const string WrapperMethodName = "Execute";
private const string ActionExecute = "execute";
private const string ActionGetHistory = "get_history";
private const string ActionClearHistory = "clear_history";
private const string ActionReplay = "replay";
private static readonly List<HistoryEntry> _history = new List<HistoryEntry>();
private static string[] _cachedAssemblyPaths;
[UnityEditor.InitializeOnLoadMethod]
private static void OnDomainReload()
{
_cachedAssemblyPaths = null;
RoslynCompiler.ResetCache();
}
private static readonly HashSet<string> _blockedPatterns = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
"System.IO.File.Delete",
"System.IO.Directory.Delete",
"FileUtil.DeleteFileOrDirectory",
"AssetDatabase.DeleteAsset",
"AssetDatabase.MoveAssetToTrash",
"EditorApplication.Exit",
"Process.Start",
"Process.Kill",
"while(true)",
"while (true)",
"for(;;)",
"for (;;)",
};
public static object HandleCommand(JObject @params)
{
if (@params == null)
return new ErrorResponse("Parameters cannot be null.");
var p = new ToolParams(@params);
var actionResult = p.GetRequired("action");
if (!actionResult.IsSuccess)
return new ErrorResponse(actionResult.ErrorMessage);
string action = actionResult.Value.ToLowerInvariant();
switch (action)
{
case ActionExecute:
return HandleExecute(@params);
case ActionGetHistory:
return HandleGetHistory(@params);
case ActionClearHistory:
return HandleClearHistory();
case ActionReplay:
return HandleReplay(@params);
default:
return new ErrorResponse(
$"Unknown action: '{action}'. Valid actions: {ActionExecute}, {ActionGetHistory}, {ActionClearHistory}, {ActionReplay}");
}
}
private static object HandleExecute(JObject @params)
{
string code = @params["code"]?.ToString();
if (string.IsNullOrWhiteSpace(code))
return new ErrorResponse("Required parameter 'code' is missing or empty.");
if (code.Length > MaxCodeLength)
return new ErrorResponse($"Code exceeds maximum length of {MaxCodeLength} characters.");
bool safetyChecks = @params["safety_checks"]?.Value<bool>() ?? true;
string compiler = @params["compiler"]?.ToString()?.ToLowerInvariant() ?? "auto";
if (safetyChecks)
{
var violation = CheckBlockedPatterns(code);
if (violation != null)
return new ErrorResponse($"Blocked pattern detected: {violation}");
}
try
{
var startTime = DateTime.UtcNow;
var result = CompileAndExecute(code, compiler);
var elapsed = (DateTime.UtcNow - startTime).TotalMilliseconds;
AddToHistory(code, result, elapsed, safetyChecks, compiler);
return result;
}
catch (Exception e)
{
McpLog.Error($"[ExecuteCode] Execution failed: {e}");
var errorResult = new ErrorResponse($"Execution failed: {e.Message}");
AddToHistory(code, errorResult, 0, safetyChecks, compiler);
return errorResult;
}
}
private static object HandleGetHistory(JObject @params)
{
int limit = @params["limit"]?.Value<int>() ?? 10;
limit = Math.Clamp(limit, 1, MaxHistoryEntries);
if (_history.Count == 0)
return new SuccessResponse("No execution history.", new { total = 0, entries = new object[0] });
var entries = _history.Skip(Math.Max(0, _history.Count - limit)).ToList();
return new SuccessResponse($"Returning {entries.Count} of {_history.Count} history entries.", new
{
total = _history.Count,
entries = entries.Select((e, i) => new
{
index = _history.Count - entries.Count + i,
codePreview = e.code.Length > MaxHistoryCodePreview
? e.code.Substring(0, MaxHistoryCodePreview) + "..."
: e.code,
e.success,
e.resultPreview,
e.elapsedMs,
e.timestamp,
e.safetyChecksEnabled,
e.compiler,
}).ToList(),
});
}
private static object HandleClearHistory()
{
int count = _history.Count;
_history.Clear();
return new SuccessResponse($"Cleared {count} history entries.");
}
private static object HandleReplay(JObject @params)
{
if (_history.Count == 0)
return new ErrorResponse("No execution history to replay.");
int? index = @params["index"]?.Value<int>();
if (index == null || index < 0 || index >= _history.Count)
return new ErrorResponse($"Invalid history index. Valid range: 0-{_history.Count - 1}");
var entry = _history[index.Value];
var replayParams = JObject.FromObject(new
{
action = ActionExecute,
code = entry.code,
safety_checks = entry.safetyChecksEnabled,
compiler = entry.compiler ?? "auto",
});
return HandleExecute(replayParams);
}
// ──────────────────── Compilation ────────────────────
private static object CompileAndExecute(string code, string compiler)
{
string wrappedSource = WrapUserCode(code);
string[] assemblyPaths = GetAssemblyPaths();
Assembly compiled;
string usedCompiler;
switch (compiler)
{
case "roslyn":
if (!RoslynCompiler.IsAvailable)
return new ErrorResponse("Roslyn (Microsoft.CodeAnalysis) is not available. Install it via NuGet or use compiler='codedom'.");
compiled = RoslynCompiler.Compile(wrappedSource, assemblyPaths, out var roslynErrors);
if (compiled == null)
return new ErrorResponse("Compilation failed", new { errors = OffsetErrors(roslynErrors), compiler = "roslyn" });
usedCompiler = "roslyn";
break;
case "codedom":
compiled = CodeDomCompile(wrappedSource, assemblyPaths, out var codedomErrors);
if (compiled == null)
return new ErrorResponse("Compilation failed", new { errors = OffsetErrors(codedomErrors), compiler = "codedom" });
usedCompiler = "codedom";
break;
default: // "auto"
if (RoslynCompiler.IsAvailable)
{
compiled = RoslynCompiler.Compile(wrappedSource, assemblyPaths, out var autoErrors);
if (compiled == null)
return new ErrorResponse("Compilation failed", new { errors = OffsetErrors(autoErrors), compiler = "roslyn" });
usedCompiler = "roslyn";
}
else
{
compiled = CodeDomCompile(wrappedSource, assemblyPaths, out var autoFallbackErrors);
if (compiled == null)
return new ErrorResponse("Compilation failed", new { errors = OffsetErrors(autoFallbackErrors), compiler = "codedom" });
usedCompiler = "codedom";
}
break;
}
return InvokeCompiled(compiled, usedCompiler);
}
private static object InvokeCompiled(Assembly assembly, string compilerUsed)
{
var type = assembly.GetType(WrapperClassName);
if (type == null)
return new ErrorResponse("Internal error: failed to find compiled type.");
var method = type.GetMethod(WrapperMethodName, BindingFlags.Public | BindingFlags.Static);
if (method == null)
return new ErrorResponse("Internal error: failed to find Execute method.");
object result = null;
Exception executionError = null;
try
{
result = method.Invoke(null, null);
}
catch (TargetInvocationException tie)
{
executionError = tie.InnerException ?? tie;
}
catch (Exception e)
{
executionError = e;
}
if (executionError != null)
return new ErrorResponse($"Runtime error: {executionError.Message}",
new { exceptionType = executionError.GetType().Name, stackTrace = executionError.StackTrace, compiler = compilerUsed });
if (result != null)
return new SuccessResponse("Code executed successfully.",
new { result = SerializeResult(result), compiler = compilerUsed });
return new SuccessResponse("Code executed successfully.", new { compiler = compilerUsed });
}
private static List<string> OffsetErrors(List<string> errors)
{
// Errors already have line numbers adjusted by the compiler-specific code
return errors;
}
// ──────────────────── CodeDom compiler ────────────────────
private static Assembly CodeDomCompile(string source, string[] assemblyPaths, out List<string> errors)
{
errors = new List<string>();
// CodeDom needs the netstandard-aware filtered paths
var filtered = FilterAssemblyPathsForCodeDom(assemblyPaths);
// CSharpCodeProvider turns every ReferencedAssemblies entry into a literal /r:"..." flag
// on the csc command line. Projects with ~100+ asmdefs blow past Windows' 32 KB
// CreateProcess argument limit and fail with "The filename or extension is too long."
// Route references through a response file (@responsefile is supported by both mcs and
// Roslyn csc) so we pass exactly one short argument regardless of reference count.
string responseFilePath = Path.Combine(Path.GetTempPath(), $"mcp-codedom-{Guid.NewGuid():N}.rsp");
try
{
using (var writer = new StreamWriter(responseFilePath, append: false, Encoding.UTF8))
{
foreach (var path in filtered)
{
writer.Write("/r:\"");
writer.Write(path);
writer.WriteLine("\"");
}
}
using (var provider = new CSharpCodeProvider())
{
var parameters = new CompilerParameters
{
GenerateInMemory = true,
GenerateExecutable = false,
TreatWarningsAsErrors = false,
CompilerOptions = "@\"" + responseFilePath + "\"",
};
var results = provider.CompileAssemblyFromSource(parameters, source);
if (results.Errors.HasErrors)
{
foreach (CompilerError error in results.Errors)
{
if (!error.IsWarning)
{
int userLine = Math.Max(1, error.Line - WrapperLineOffset);
errors.Add($"Line {userLine}: {error.ErrorText}");
}
}
return null;
}
return results.CompiledAssembly;
}
}
finally
{
try { if (File.Exists(responseFilePath)) File.Delete(responseFilePath); }
catch { /* best effort */ }
}
}
// CSharpCodeProvider can't resolve type-forwarding, so when netstandard.dll is loaded
// alongside mscorlib/System.Runtime/System.Collections, types like List<T> appear in
// multiple assemblies causing "type defined multiple times" errors.
private static readonly HashSet<string> _codedomDuplicateAssemblies = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
"mscorlib",
"System.Runtime",
"System.Private.CoreLib",
"System.Collections",
};
private static string[] FilterAssemblyPathsForCodeDom(string[] allPaths)
{
bool hasNetstandard = allPaths.Any(p =>
string.Equals(Path.GetFileNameWithoutExtension(p), "netstandard", StringComparison.OrdinalIgnoreCase));
if (!hasNetstandard)
return allPaths;
return allPaths.Where(p =>
!_codedomDuplicateAssemblies.Contains(Path.GetFileNameWithoutExtension(p))).ToArray();
}
// ──────────────────── Shared helpers ────────────────────
private static string WrapUserCode(string code)
{
var sb = new StringBuilder();
sb.AppendLine("using System;");
sb.AppendLine("using System.Collections.Generic;");
sb.AppendLine("using System.Linq;");
sb.AppendLine("using System.Reflection;");
sb.AppendLine("using UnityEngine;");
sb.AppendLine("using UnityEditor;");
sb.AppendLine($"public static class {WrapperClassName}");
sb.AppendLine("{");
sb.AppendLine($" public static object {WrapperMethodName}()");
sb.AppendLine(" {");
sb.AppendLine(code);
sb.AppendLine(" }");
sb.AppendLine("}");
return sb.ToString();
}
private static string[] GetAssemblyPaths()
{
if (_cachedAssemblyPaths == null)
_cachedAssemblyPaths = ResolveAssemblyPaths();
return _cachedAssemblyPaths;
}
private static string[] ResolveAssemblyPaths()
{
var paths = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var assembly in UnityAssembliesCompat.GetLoadedAssemblies())
{
try
{
if (assembly.IsDynamic) continue;
var location = assembly.Location;
if (string.IsNullOrEmpty(location)) continue;
if (!File.Exists(location)) continue;
paths.Add(location);
}
catch (NotSupportedException)
{
// Some assemblies don't support Location property
}
}
var result = new string[paths.Count];
paths.CopyTo(result);
return result;
}
private static string CheckBlockedPatterns(string code)
{
foreach (var pattern in _blockedPatterns)
{
if (code.IndexOf(pattern, StringComparison.OrdinalIgnoreCase) >= 0)
return $"Code contains blocked pattern: '{pattern}'. Disable safety checks with safety_checks=false if this is intentional.";
}
return null;
}
private static void AddToHistory(string code, object result, double elapsedMs, bool safetyChecks, string compiler = "auto")
{
string preview;
if (result is SuccessResponse sr)
preview = sr.Data?.ToString() ?? sr.Message;
else if (result is ErrorResponse er)
preview = er.Error;
else
preview = result?.ToString() ?? "null";
if (preview != null && preview.Length > 200)
preview = preview.Substring(0, 200) + "...";
_history.Add(new HistoryEntry
{
code = code,
success = result is SuccessResponse,
resultPreview = preview,
elapsedMs = Math.Round(elapsedMs, 1),
timestamp = DateTime.UtcNow.ToString("yyyy-MM-ddTHH:mm:ssZ"),
safetyChecksEnabled = safetyChecks,
compiler = compiler,
});
while (_history.Count > MaxHistoryEntries)
_history.RemoveAt(0);
}
private static object SerializeResult(object result)
{
if (result == null) return null;
var type = result.GetType();
if (type.IsPrimitive || result is string || result is decimal)
return result;
try
{
return JToken.FromObject(result);
}
catch
{
return result.ToString();
}
}
private class HistoryEntry
{
public string code;
public bool success;
public string resultPreview;
public double elapsedMs;
public string timestamp;
public bool safetyChecksEnabled;
public string compiler;
}
}
/// <summary>
/// Roslyn compiler backend accessed entirely via reflection.
/// No compile-time dependency on Microsoft.CodeAnalysis — works only if the package is installed.
/// </summary>
internal static class RoslynCompiler
{
private static bool? _isAvailable;
private static Type _syntaxTreeType;
private static Type _compilationType;
private static Type _compilationOptionsType;
private static Type _parseOptionsType;
private static Type _metadataReferenceType;
private static Type _outputKindEnum;
private static Type _languageVersionEnum;
private static MethodInfo _parseText;
private static MethodInfo _createCompilation;
private static MethodInfo _createFromFile;
private static MethodInfo _emit;
private static object _parseOptions;
private static object _compilationOptions;
public static bool IsAvailable
{
get
{
if (_isAvailable == null)
_isAvailable = Initialize();
return _isAvailable.Value;
}
}
public static void ResetCache()
{
_isAvailable = null;
}
private static bool Initialize()
{
try
{
_syntaxTreeType = Type.GetType("Microsoft.CodeAnalysis.CSharp.CSharpSyntaxTree, Microsoft.CodeAnalysis.CSharp");
_compilationType = Type.GetType("Microsoft.CodeAnalysis.CSharp.CSharpCompilation, Microsoft.CodeAnalysis.CSharp");
_compilationOptionsType = Type.GetType("Microsoft.CodeAnalysis.CSharp.CSharpCompilationOptions, Microsoft.CodeAnalysis.CSharp");
_parseOptionsType = Type.GetType("Microsoft.CodeAnalysis.CSharp.CSharpParseOptions, Microsoft.CodeAnalysis.CSharp");
_metadataReferenceType = Type.GetType("Microsoft.CodeAnalysis.MetadataReference, Microsoft.CodeAnalysis");
_outputKindEnum = Type.GetType("Microsoft.CodeAnalysis.OutputKind, Microsoft.CodeAnalysis");
_languageVersionEnum = Type.GetType("Microsoft.CodeAnalysis.CSharp.LanguageVersion, Microsoft.CodeAnalysis.CSharp");
if (_syntaxTreeType == null || _compilationType == null || _compilationOptionsType == null ||
_parseOptionsType == null || _metadataReferenceType == null || _outputKindEnum == null ||
_languageVersionEnum == null)
return false;
// CSharpSyntaxTree.ParseText(string, CSharpParseOptions, string, Encoding, CancellationToken)
var syntaxTreeBase = Type.GetType("Microsoft.CodeAnalysis.SyntaxTree, Microsoft.CodeAnalysis");
_parseText = _syntaxTreeType.GetMethod("ParseText", new[] { typeof(string), _parseOptionsType, typeof(string), typeof(Encoding), typeof(System.Threading.CancellationToken) });
if (_parseText == null)
return false;
// CSharpCompilation.Create(string, IEnumerable<SyntaxTree>, IEnumerable<MetadataReference>, CSharpCompilationOptions)
var metadataRefBase = _metadataReferenceType;
var syntaxTreeEnumerable = typeof(IEnumerable<>).MakeGenericType(syntaxTreeBase);
var metadataRefEnumerable = typeof(IEnumerable<>).MakeGenericType(metadataRefBase);
_createCompilation = _compilationType.GetMethod("Create", new[] { typeof(string), syntaxTreeEnumerable, metadataRefEnumerable, _compilationOptionsType });
if (_createCompilation == null)
return false;
// MetadataReference.CreateFromFile(string, MetadataReferenceProperties, DocumentationProvider)
_createFromFile = _metadataReferenceType.GetMethods(BindingFlags.Public | BindingFlags.Static)
.FirstOrDefault(m => m.Name == "CreateFromFile");
if (_createFromFile == null)
return false;
// Emit has no single-param overload; the simplest is
// Emit(Stream, Stream, Stream, Stream, IEnumerable<ResourceDescription>, EmitOptions, CancellationToken)
var compilationBase = Type.GetType("Microsoft.CodeAnalysis.Compilation, Microsoft.CodeAnalysis");
if (compilationBase == null) return false;
_emit = compilationBase.GetMethods(BindingFlags.Public | BindingFlags.Instance)
.Where(m => m.Name == "Emit")
.OrderBy(m => m.GetParameters().Length)
.FirstOrDefault();
if (_emit == null)
return false;
// Build CSharpParseOptions — constructor has optional params, use reflection
var latestValue = Enum.Parse(_languageVersionEnum, "Latest");
var parseOptionsCtor = _parseOptionsType.GetConstructors(BindingFlags.Public | BindingFlags.Instance)[0];
var parseCtorParams = parseOptionsCtor.GetParameters();
var parseArgs = new object[parseCtorParams.Length];
for (int i = 0; i < parseCtorParams.Length; i++)
{
if (parseCtorParams[i].Name == "languageVersion")
parseArgs[i] = latestValue;
else if (parseCtorParams[i].HasDefaultValue)
parseArgs[i] = parseCtorParams[i].DefaultValue;
else
parseArgs[i] = null;
}
_parseOptions = parseOptionsCtor.Invoke(parseArgs);
// Build CSharpCompilationOptions — use the first constructor (has most defaults)
var dllKind = Enum.Parse(_outputKindEnum, "DynamicallyLinkedLibrary");
var compOptionsCtor = _compilationOptionsType.GetConstructors(BindingFlags.Public | BindingFlags.Instance)[0];
var compCtorParams = compOptionsCtor.GetParameters();
var compArgs = new object[compCtorParams.Length];
for (int i = 0; i < compCtorParams.Length; i++)
{
if (compCtorParams[i].Name == "outputKind")
compArgs[i] = dllKind;
else if (compCtorParams[i].HasDefaultValue)
compArgs[i] = compCtorParams[i].DefaultValue;
else
compArgs[i] = null;
}
_compilationOptions = compOptionsCtor.Invoke(compArgs);
return true;
}
catch (Exception e)
{
McpLog.Warn($"[ExecuteCode] Roslyn initialization failed: {e.Message}");
return false;
}
}
public static Assembly Compile(string source, string[] assemblyPaths, out List<string> errors)
{
errors = new List<string>();
try
{
// Parse source
var syntaxTree = _parseText.Invoke(null, new object[] { source, _parseOptions, null, null, default(System.Threading.CancellationToken) });
// Build metadata references
var metadataRefBase = _metadataReferenceType;
var listType = typeof(List<>).MakeGenericType(metadataRefBase);
var refs = (System.Collections.IList)Activator.CreateInstance(listType);
foreach (var path in assemblyPaths)
{
try
{
var cfParams = _createFromFile.GetParameters();
var cfArgs = new object[cfParams.Length];
cfArgs[0] = path; // string path
for (int i = 1; i < cfParams.Length; i++)
cfArgs[i] = cfParams[i].HasDefaultValue ? cfParams[i].DefaultValue : null;
var metaRef = _createFromFile.Invoke(null, cfArgs);
refs.Add(metaRef);
}
catch
{
// Skip assemblies that can't be loaded as metadata
}
}
// Build syntax tree array
var syntaxTreeBase = Type.GetType("Microsoft.CodeAnalysis.SyntaxTree, Microsoft.CodeAnalysis");
var treeArray = Array.CreateInstance(syntaxTreeBase, 1);
treeArray.SetValue(syntaxTree, 0);
// Create compilation
var compilation = _createCompilation.Invoke(null, new object[] { "MCPDynamic", treeArray, refs, _compilationOptions });
// Emit to memory
using (var ms = new MemoryStream())
{
// Build args for the Emit overload (fill non-stream params with defaults)
var emitParams = _emit.GetParameters();
var emitArgs = new object[emitParams.Length];
emitArgs[0] = ms; // peStream
for (int i = 1; i < emitParams.Length; i++)
{
if (emitParams[i].HasDefaultValue)
emitArgs[i] = emitParams[i].DefaultValue;
else
emitArgs[i] = null;
}
var emitResult = _emit.Invoke(compilation, emitArgs);
// Check emitResult.Success
var successProp = emitResult.GetType().GetProperty("Success");
bool success = (bool)successProp.GetValue(emitResult);
if (!success)
{
// Read emitResult.Diagnostics
var diagProp = emitResult.GetType().GetProperty("Diagnostics");
var diagnostics = (System.Collections.IEnumerable)diagProp.GetValue(emitResult);
var severityError = Enum.Parse(Type.GetType("Microsoft.CodeAnalysis.DiagnosticSeverity, Microsoft.CodeAnalysis"), "Error");
foreach (var diag in diagnostics)
{
var sevProp = diag.GetType().GetProperty("Severity");
var severity = sevProp.GetValue(diag);
if (!severity.Equals(severityError)) continue;
var locProp = diag.GetType().GetProperty("Location");
var loc = locProp.GetValue(diag);
var spanProp = loc.GetType().GetMethod("GetLineSpan");
var lineSpan = spanProp.Invoke(loc, null);
var startProp = lineSpan.GetType().GetProperty("StartLinePosition");
var startPos = startProp.GetValue(lineSpan);
var lineProp = startPos.GetType().GetProperty("Line");
int line = (int)lineProp.GetValue(startPos);
var msgProp = diag.GetType().GetMethod("GetMessage", new[] { typeof(System.Globalization.CultureInfo) });
string msg = (string)msgProp.Invoke(diag, new object[] { null });
int userLine = Math.Max(1, line + 1 - ExecuteCode.WrapperLineOffset);
errors.Add($"Line {userLine}: {msg}");
}
return null;
}
ms.Seek(0, SeekOrigin.Begin);
return Assembly.Load(ms.ToArray());
}
}
catch (Exception e)
{
// Walk to the deepest cause: TargetInvocationException (and friends) wrap the real
// failure inside .InnerException, and reporting only e.Message hides everything
// useful (e.g. a missing transitive dep manifests as the generic "Exception has been
// thrown by the target of an invocation.").
Exception root = e;
while (root.InnerException != null) root = root.InnerException;
errors.Add($"Roslyn compilation error: {root.GetType().Name}: {root.Message}");
return null;
}
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 8ebfb8c53994b4750ad9c4f284e7126e
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,53 @@
using System;
using System.Collections.Generic;
using MCPForUnity.Editor.Helpers;
using Newtonsoft.Json.Linq;
using UnityEditor;
namespace MCPForUnity.Editor.Tools
{
[McpForUnityTool("execute_menu_item", AutoRegister = false)]
/// <summary>
/// Tool to execute a Unity Editor menu item by its path.
/// </summary>
public static class ExecuteMenuItem
{
// Basic blacklist to prevent execution of disruptive menu items.
private static readonly HashSet<string> _menuPathBlacklist = new HashSet<string>(
StringComparer.OrdinalIgnoreCase)
{
"File/Quit",
};
public static object HandleCommand(JObject @params)
{
McpLog.Info("[ExecuteMenuItem] Handling menu item command");
string menuPath = @params["menu_path"]?.ToString() ?? @params["menuPath"]?.ToString();
if (string.IsNullOrWhiteSpace(menuPath))
{
return new ErrorResponse("Required parameter 'menu_path' or 'menuPath' is missing or empty.");
}
if (_menuPathBlacklist.Contains(menuPath))
{
return new ErrorResponse($"Execution of menu item '{menuPath}' is blocked for safety reasons.");
}
try
{
bool executed = EditorApplication.ExecuteMenuItem(menuPath);
if (!executed)
{
McpLog.Error($"[MenuItemExecutor] Failed to execute menu item '{menuPath}'. It might be invalid, disabled, or context-dependent.");
return new ErrorResponse($"Failed to execute menu item '{menuPath}'. It might be invalid, disabled, or context-dependent.");
}
return new SuccessResponse($"Attempted to execute menu item: '{menuPath}'. Check Unity logs for confirmation or errors.");
}
catch (Exception e)
{
McpLog.Error($"[MenuItemExecutor] Failed to setup execution for '{menuPath}': {e}");
return new ErrorResponse($"Error setting up execution for menu item '{menuPath}': {e.Message}");
}
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 269232350d16a464091aea9e9fcc9b55
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,81 @@
using System.Collections.Generic;
using System.Linq;
using MCPForUnity.Editor.Helpers;
using Newtonsoft.Json.Linq;
using UnityEngine;
namespace MCPForUnity.Editor.Tools
{
/// <summary>
/// Tool for searching GameObjects in the scene.
/// Returns only instance IDs with pagination support.
///
/// This is a focused search tool that returns lightweight results (IDs only).
/// For detailed GameObject data, use the unity://scene/gameobject/{id} resource.
/// </summary>
[McpForUnityTool("find_gameobjects")]
public static class FindGameObjects
{
/// <summary>
/// Handles the find_gameobjects command.
/// </summary>
/// <param name="params">Command parameters</param>
/// <returns>Paginated list of instance IDs</returns>
public static object HandleCommand(JObject @params)
{
if (@params == null)
{
return new ErrorResponse("Parameters cannot be null.");
}
var p = new ToolParams(@params);
// Parse search parameters
string searchMethod = p.Get("searchMethod", "by_name");
// Try searchTerm, search_term, or target (for backwards compatibility)
string searchTerm = p.Get("searchTerm");
if (string.IsNullOrEmpty(searchTerm))
{
searchTerm = p.Get("target");
}
if (string.IsNullOrEmpty(searchTerm))
{
return new ErrorResponse("'searchTerm' or 'target' parameter is required.");
}
// Pagination parameters using standard PaginationRequest
var pagination = PaginationRequest.FromParams(@params, defaultPageSize: 50);
pagination.PageSize = Mathf.Clamp(pagination.PageSize, 1, 500);
// Search options (supports multiple parameter name variants)
bool includeInactive = p.GetBool("includeInactive", false) ||
p.GetBool("searchInactive", false);
try
{
// Get all matching instance IDs
var allIds = GameObjectLookup.SearchGameObjects(searchMethod, searchTerm, includeInactive, 0);
// Use standard pagination response
var paginatedResult = PaginationResponse<int>.Create(allIds, pagination);
return new SuccessResponse("Found GameObjects", new
{
instanceIDs = paginatedResult.Items,
pageSize = paginatedResult.PageSize,
cursor = paginatedResult.Cursor,
nextCursor = paginatedResult.NextCursor,
totalCount = paginatedResult.TotalCount,
hasMore = paginatedResult.HasMore
});
}
catch (System.Exception ex)
{
McpLog.Error($"[FindGameObjects] Error searching GameObjects: {ex.Message}");
return new ErrorResponse($"Error searching GameObjects: {ex.Message}");
}
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 4511082b395b14922b34e90f7a23027e
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: b61d0e8082ed14c1fb500648007bba7a
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,142 @@
#nullable disable
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using MCPForUnity.Editor.Helpers;
using UnityEngine;
namespace MCPForUnity.Editor.Tools
{
/// <summary>
/// Component resolver that delegates to UnityTypeResolver.
/// Kept for backwards compatibility.
/// </summary>
internal static class ComponentResolver
{
/// <summary>
/// Resolve a Component/MonoBehaviour type by short or fully-qualified name.
/// Delegates to UnityTypeResolver.TryResolve with Component constraint.
/// </summary>
public static bool TryResolve(string nameOrFullName, out Type type, out string error)
{
return UnityTypeResolver.TryResolve(nameOrFullName, out type, out error, typeof(Component));
}
/// <summary>
/// Gets all accessible property and field names from a component type.
/// </summary>
public static List<string> GetAllComponentProperties(Type componentType)
{
if (componentType == null) return new List<string>();
var properties = componentType.GetProperties(BindingFlags.Public | BindingFlags.Instance)
.Where(p => p.CanRead && p.CanWrite)
.Select(p => p.Name);
var fields = componentType.GetFields(BindingFlags.Public | BindingFlags.Instance)
.Where(f => !f.IsInitOnly && !f.IsLiteral)
.Select(f => f.Name);
// Also include SerializeField private fields (common in Unity)
var serializeFields = componentType.GetFields(BindingFlags.NonPublic | BindingFlags.Instance)
.Where(f => f.GetCustomAttribute<SerializeField>() != null)
.Select(f => f.Name);
return properties.Concat(fields).Concat(serializeFields).Distinct().OrderBy(x => x).ToList();
}
/// <summary>
/// Suggests the most likely property matches for a user's input using fuzzy matching.
/// Uses Levenshtein distance, substring matching, and common naming pattern heuristics.
/// </summary>
public static List<string> GetFuzzyPropertySuggestions(string userInput, List<string> availableProperties)
{
if (string.IsNullOrWhiteSpace(userInput) || !availableProperties.Any())
return new List<string>();
var cacheKey = $"{userInput.ToLowerInvariant()}:{string.Join(",", availableProperties)}";
if (PropertySuggestionCache.TryGetValue(cacheKey, out var cached))
return cached;
try
{
var suggestions = GetRuleBasedSuggestions(userInput, availableProperties);
PropertySuggestionCache[cacheKey] = suggestions;
return suggestions;
}
catch (Exception ex)
{
McpLog.Warn($"[Property Matching] Error getting suggestions for '{userInput}': {ex.Message}");
return new List<string>();
}
}
private static readonly Dictionary<string, List<string>> PropertySuggestionCache = new();
/// <summary>
/// Rule-based suggestions that mimic AI behavior for property matching.
/// This provides immediate value while we could add real AI integration later.
/// </summary>
private static List<string> GetRuleBasedSuggestions(string userInput, List<string> availableProperties)
{
var suggestions = new List<string>();
var cleanedInput = userInput.ToLowerInvariant().Replace(" ", "").Replace("-", "").Replace("_", "");
foreach (var property in availableProperties)
{
var cleanedProperty = property.ToLowerInvariant().Replace(" ", "").Replace("-", "").Replace("_", "");
if (cleanedProperty == cleanedInput)
{
suggestions.Add(property);
continue;
}
var inputWords = userInput.ToLowerInvariant().Split(new[] { ' ', '-', '_' }, StringSplitOptions.RemoveEmptyEntries);
if (inputWords.All(word => cleanedProperty.Contains(word.ToLowerInvariant())))
{
suggestions.Add(property);
continue;
}
if (LevenshteinDistance(cleanedInput, cleanedProperty) <= Math.Max(2, cleanedInput.Length / 4))
{
suggestions.Add(property);
}
}
return suggestions.OrderBy(s => LevenshteinDistance(cleanedInput, s.ToLowerInvariant().Replace(" ", "")))
.Take(3)
.ToList();
}
/// <summary>
/// Calculates Levenshtein distance between two strings for similarity matching.
/// </summary>
private static int LevenshteinDistance(string s1, string s2)
{
if (string.IsNullOrEmpty(s1)) return s2?.Length ?? 0;
if (string.IsNullOrEmpty(s2)) return s1.Length;
var matrix = new int[s1.Length + 1, s2.Length + 1];
for (int i = 0; i <= s1.Length; i++) matrix[i, 0] = i;
for (int j = 0; j <= s2.Length; j++) matrix[0, j] = j;
for (int i = 1; i <= s1.Length; i++)
{
for (int j = 1; j <= s2.Length; j++)
{
int cost = (s2[j - 1] == s1[i - 1]) ? 0 : 1;
matrix[i, j] = Math.Min(Math.Min(
matrix[i - 1, j] + 1,
matrix[i, j - 1] + 1),
matrix[i - 1, j - 1] + cost);
}
}
return matrix[s1.Length, s2.Length];
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: f5e5a46bdebc040c68897fa4b5e689c7
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,431 @@
#nullable disable
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using MCPForUnity.Editor.Helpers;
using MCPForUnity.Editor.Tools;
using MCPForUnity.Runtime.Serialization;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using UnityEditor;
using UnityEngine;
namespace MCPForUnity.Editor.Tools.GameObjects
{
internal static class GameObjectComponentHelpers
{
internal static object AddComponentInternal(GameObject targetGo, string typeName, JObject properties)
{
Type componentType = FindType(typeName);
if (componentType == null)
{
return new ErrorResponse($"Component type '{typeName}' not found or is not a valid Component.");
}
if (!typeof(Component).IsAssignableFrom(componentType))
{
return new ErrorResponse($"Type '{typeName}' is not a Component.");
}
if (componentType == typeof(Transform))
{
return new ErrorResponse("Cannot add another Transform component.");
}
bool isAdding2DPhysics = typeof(Rigidbody2D).IsAssignableFrom(componentType) || typeof(Collider2D).IsAssignableFrom(componentType);
bool isAdding3DPhysics = typeof(Rigidbody).IsAssignableFrom(componentType) || typeof(Collider).IsAssignableFrom(componentType);
if (isAdding2DPhysics)
{
if (targetGo.GetComponent<Rigidbody>() != null || targetGo.GetComponent<Collider>() != null)
{
return new ErrorResponse($"Cannot add 2D physics component '{typeName}' because the GameObject '{targetGo.name}' already has a 3D Rigidbody or Collider.");
}
}
else if (isAdding3DPhysics)
{
if (targetGo.GetComponent<Rigidbody2D>() != null || targetGo.GetComponent<Collider2D>() != null)
{
return new ErrorResponse($"Cannot add 3D physics component '{typeName}' because the GameObject '{targetGo.name}' already has a 2D Rigidbody or Collider.");
}
}
Component existingComponent = targetGo.GetComponent(componentType);
if (existingComponent != null && !AllowsMultiple(componentType))
{
return new ErrorResponse(
$"Component '{typeName}' already exists on '{targetGo.name}' and this type does not allow multiple instances."
);
}
try
{
Component newComponent = Undo.AddComponent(targetGo, componentType);
if (newComponent == null)
{
if (targetGo.GetComponent(componentType) != null && !AllowsMultiple(componentType))
{
return new ErrorResponse(
$"Component '{typeName}' already exists on '{targetGo.name}' and this type does not allow multiple instances."
);
}
return new ErrorResponse(
$"Failed to add component '{typeName}' to '{targetGo.name}'. Unity may restrict this component on the current target."
);
}
if (newComponent is Light light)
{
light.type = LightType.Directional;
}
if (properties != null)
{
var setResult = SetComponentPropertiesInternal(targetGo, typeName, properties, newComponent);
if (setResult != null)
{
Undo.DestroyObjectImmediate(newComponent);
return setResult;
}
}
return null;
}
catch (Exception e)
{
return new ErrorResponse($"Error adding component '{typeName}' to '{targetGo.name}': {e.Message}");
}
}
private static bool AllowsMultiple(Type componentType)
{
if (componentType == null)
{
return false;
}
return !Attribute.IsDefined(componentType, typeof(DisallowMultipleComponent), inherit: true);
}
internal static object RemoveComponentInternal(GameObject targetGo, string typeName)
{
if (targetGo == null)
{
return new ErrorResponse("Target GameObject is null.");
}
Type componentType = FindType(typeName);
if (componentType == null)
{
return new ErrorResponse($"Component type '{typeName}' not found for removal.");
}
if (componentType == typeof(Transform))
{
return new ErrorResponse("Cannot remove the Transform component.");
}
Component componentToRemove = targetGo.GetComponent(componentType);
if (componentToRemove == null)
{
return new ErrorResponse($"Component '{typeName}' not found on '{targetGo.name}' to remove.");
}
try
{
Undo.DestroyObjectImmediate(componentToRemove);
return null;
}
catch (Exception e)
{
return new ErrorResponse($"Error removing component '{typeName}' from '{targetGo.name}': {e.Message}");
}
}
internal static object SetComponentPropertiesInternal(GameObject targetGo, string componentTypeName, JObject properties, Component targetComponentInstance = null)
{
Component targetComponent = targetComponentInstance;
if (targetComponent == null)
{
if (ComponentResolver.TryResolve(componentTypeName, out var compType, out var compError))
{
targetComponent = targetGo.GetComponent(compType);
}
else
{
targetComponent = targetGo.GetComponent(componentTypeName);
}
}
if (targetComponent == null)
{
return new ErrorResponse($"Component '{componentTypeName}' not found on '{targetGo.name}' to set properties.");
}
Undo.RecordObject(targetComponent, "Set Component Properties");
var failures = new List<string>();
foreach (var prop in properties.Properties())
{
string propName = prop.Name;
JToken propValue = prop.Value;
try
{
bool setResult;
string setError;
// Nested paths (e.g. "transform.position") need local handling
// since ComponentOps doesn't support dot/bracket notation.
if (propName.Contains('.') || propName.Contains('['))
{
setResult = SetNestedProperty(targetComponent, propName, propValue, InputSerializer, out setError);
}
else
{
// ComponentOps handles reflection + SerializedProperty fallback
setResult = ComponentOps.SetProperty(targetComponent, propName, propValue, out setError);
}
if (!setResult)
{
string msg = setError;
if (msg == null || msg.Contains("not found"))
{
var availableProperties = ComponentResolver.GetAllComponentProperties(targetComponent.GetType());
var suggestions = ComponentResolver.GetFuzzyPropertySuggestions(propName, availableProperties);
msg = suggestions.Any()
? $"Property '{propName}' not found. Did you mean: {string.Join(", ", suggestions)}? Available: [{string.Join(", ", availableProperties)}]"
: $"Property '{propName}' not found. Available: [{string.Join(", ", availableProperties)}]";
}
McpLog.Warn($"[ManageGameObject] {msg}");
failures.Add(msg);
}
}
catch (Exception e)
{
McpLog.Error($"[ManageGameObject] Error setting property '{propName}' on '{componentTypeName}': {e.Message}");
failures.Add($"Error setting '{propName}': {e.Message}");
}
}
EditorUtility.SetDirty(targetComponent);
return failures.Count == 0
? null
: new ErrorResponse($"One or more properties failed on '{componentTypeName}'.", new { errors = failures });
}
private static JsonSerializer InputSerializer => UnityJsonSerializer.Instance;
private static bool SetNestedProperty(object target, string path, JToken value, JsonSerializer inputSerializer, out string error)
{
error = null;
try
{
string[] pathParts = SplitPropertyPath(path);
if (pathParts.Length == 0)
{
error = $"Invalid nested property path '{path}'.";
return false;
}
object currentObject = target;
Type currentType = currentObject.GetType();
BindingFlags flags = BindingFlags.Public | BindingFlags.Instance | BindingFlags.IgnoreCase;
for (int i = 0; i < pathParts.Length - 1; i++)
{
string part = pathParts[i];
bool isArray = false;
int arrayIndex = -1;
if (part.Contains("["))
{
int startBracket = part.IndexOf('[');
int endBracket = part.IndexOf(']');
if (startBracket > 0 && endBracket > startBracket)
{
string indexStr = part.Substring(startBracket + 1, endBracket - startBracket - 1);
if (int.TryParse(indexStr, out arrayIndex))
{
isArray = true;
part = part.Substring(0, startBracket);
}
}
}
PropertyInfo propInfo = currentType.GetProperty(part, flags);
FieldInfo fieldInfo = null;
if (propInfo == null)
{
fieldInfo = currentType.GetField(part, flags);
if (fieldInfo == null)
{
error = $"Could not find property or field '{part}' on type '{currentType.Name}' in path '{path}'.";
return false;
}
}
currentObject = propInfo != null ? propInfo.GetValue(currentObject) : fieldInfo.GetValue(currentObject);
if (currentObject == null)
{
error = $"Property '{part}' is null in path '{path}', cannot access nested properties.";
return false;
}
if (isArray)
{
if (currentObject is Material[])
{
var materials = currentObject as Material[];
if (materials.Length == 0)
{
error = $"Material array is empty in path '{path}', cannot access index {arrayIndex}.";
return false;
}
if (arrayIndex < 0 || arrayIndex >= materials.Length)
{
error = $"Material index {arrayIndex} out of range (0-{materials.Length - 1}) in path '{path}'.";
return false;
}
currentObject = materials[arrayIndex];
}
else if (currentObject is System.Collections.IList)
{
var list = currentObject as System.Collections.IList;
if (list.Count == 0)
{
error = $"List is empty in path '{path}', cannot access index {arrayIndex}.";
return false;
}
if (arrayIndex < 0 || arrayIndex >= list.Count)
{
error = $"Index {arrayIndex} out of range (0-{list.Count - 1}) in path '{path}'.";
return false;
}
currentObject = list[arrayIndex];
}
else
{
error = $"Property '{part}' is not an array or list in path '{path}', cannot access by index.";
return false;
}
}
currentType = currentObject.GetType();
}
string finalPart = pathParts[pathParts.Length - 1];
if (currentObject is Material material && finalPart.StartsWith("_"))
{
if (!MaterialOps.TrySetShaderProperty(material, finalPart, value, inputSerializer))
{
error = $"Failed to set shader property '{finalPart}' on material '{material.name}' in path '{path}'.";
return false;
}
return true;
}
PropertyInfo finalPropInfo = currentType.GetProperty(finalPart, flags);
if (finalPropInfo != null && finalPropInfo.CanWrite)
{
object convertedValue = ConvertJTokenToType(value, finalPropInfo.PropertyType, inputSerializer);
if (convertedValue != null || value.Type == JTokenType.Null)
{
finalPropInfo.SetValue(currentObject, convertedValue);
return true;
}
error = $"Failed to convert value for '{finalPart}' to type '{finalPropInfo.PropertyType.Name}' in path '{path}'.";
return false;
}
FieldInfo finalFieldInfo = currentType.GetField(finalPart, flags);
if (finalFieldInfo != null)
{
object convertedValue = ConvertJTokenToType(value, finalFieldInfo.FieldType, inputSerializer);
if (convertedValue != null || value.Type == JTokenType.Null)
{
finalFieldInfo.SetValue(currentObject, convertedValue);
return true;
}
error = $"Failed to convert value for '{finalPart}' to type '{finalFieldInfo.FieldType.Name}' in path '{path}'.";
return false;
}
// Try non-public [SerializeField] fields (nested paths need this too)
FieldInfo serializedField = ComponentOps.FindSerializedFieldInHierarchy(currentType, finalPart);
if (serializedField != null)
{
object convertedValue = ConvertJTokenToType(value, serializedField.FieldType, inputSerializer);
if (convertedValue != null || value.Type == JTokenType.Null)
{
serializedField.SetValue(currentObject, convertedValue);
return true;
}
error = $"Failed to convert value for '{finalPart}' to type '{serializedField.FieldType.Name}' in path '{path}'.";
return false;
}
error = $"Property or field '{finalPart}' not found on type '{currentType.Name}' in path '{path}'.";
}
catch (Exception ex)
{
error = $"Error setting nested property '{path}': {ex.Message}";
}
return false;
}
private static string[] SplitPropertyPath(string path)
{
List<string> parts = new List<string>();
int startIndex = 0;
bool inBrackets = false;
for (int i = 0; i < path.Length; i++)
{
char c = path[i];
if (c == '[')
{
inBrackets = true;
}
else if (c == ']')
{
inBrackets = false;
}
else if (c == '.' && !inBrackets)
{
parts.Add(path.Substring(startIndex, i - startIndex));
startIndex = i + 1;
}
}
if (startIndex < path.Length)
{
parts.Add(path.Substring(startIndex));
}
return parts.ToArray();
}
private static object ConvertJTokenToType(JToken token, Type targetType, JsonSerializer inputSerializer)
{
return PropertyConversion.ConvertToType(token, targetType);
}
private static Type FindType(string typeName)
{
if (ComponentResolver.TryResolve(typeName, out Type resolvedType, out string error))
{
return resolvedType;
}
if (!string.IsNullOrEmpty(error))
{
McpLog.Warn($"[FindType] {error}");
}
return null;
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: b580af06e2d3a4788960f3f779edac54
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,338 @@
#nullable disable
using System;
using System.Linq;
using MCPForUnity.Editor.Helpers;
using Newtonsoft.Json.Linq;
using UnityEditor;
using UnityEditor.SceneManagement;
using UnityEditorInternal;
using UnityEngine;
namespace MCPForUnity.Editor.Tools.GameObjects
{
internal static class GameObjectCreate
{
internal static object Handle(JObject @params)
{
string name = @params["name"]?.ToString();
if (string.IsNullOrEmpty(name))
{
return new ErrorResponse("'name' parameter is required for 'create' action.");
}
// Get prefab creation parameters
bool saveAsPrefab = @params["saveAsPrefab"]?.ToObject<bool>() ?? false;
string prefabPath = @params["prefabPath"]?.ToString();
string tag = @params["tag"]?.ToString();
string primitiveType = @params["primitiveType"]?.ToString();
GameObject newGo = null;
// --- Try Instantiating Prefab First ---
string originalPrefabPath = prefabPath;
if (!saveAsPrefab && !string.IsNullOrEmpty(prefabPath))
{
string extension = System.IO.Path.GetExtension(prefabPath);
if (!prefabPath.Contains("/") && (string.IsNullOrEmpty(extension) || extension.Equals(".prefab", StringComparison.OrdinalIgnoreCase)))
{
string prefabNameOnly = prefabPath;
McpLog.Info($"[ManageGameObject.Create] Searching for prefab named: '{prefabNameOnly}'");
string[] guids = AssetDatabase.FindAssets($"t:Prefab {prefabNameOnly}");
if (guids.Length == 0)
{
return new ErrorResponse($"Prefab named '{prefabNameOnly}' not found anywhere in the project.");
}
else if (guids.Length > 1)
{
string foundPaths = string.Join(", ", guids.Select(g => AssetDatabase.GUIDToAssetPath(g)));
return new ErrorResponse($"Multiple prefabs found matching name '{prefabNameOnly}': {foundPaths}. Please provide a more specific path.");
}
else
{
prefabPath = AssetDatabase.GUIDToAssetPath(guids[0]);
McpLog.Info($"[ManageGameObject.Create] Found unique prefab at path: '{prefabPath}'");
}
}
else if (prefabPath.Contains("/") && string.IsNullOrEmpty(extension))
{
McpLog.Warn($"[ManageGameObject.Create] Provided prefabPath '{prefabPath}' has no extension. Assuming it's a prefab and appending .prefab.");
prefabPath += ".prefab";
}
else if (!prefabPath.Contains("/") && !string.IsNullOrEmpty(extension) && !extension.Equals(".prefab", StringComparison.OrdinalIgnoreCase))
{
string fileName = prefabPath;
string fileNameWithoutExtension = System.IO.Path.GetFileNameWithoutExtension(fileName);
McpLog.Info($"[ManageGameObject.Create] Searching for asset file named: '{fileName}'");
string[] guids = AssetDatabase.FindAssets(fileNameWithoutExtension);
var matches = guids
.Select(g => AssetDatabase.GUIDToAssetPath(g))
.Where(p => p.EndsWith("/" + fileName, StringComparison.OrdinalIgnoreCase) || p.Equals(fileName, StringComparison.OrdinalIgnoreCase))
.ToArray();
if (matches.Length == 0)
{
return new ErrorResponse($"Asset file '{fileName}' not found anywhere in the project.");
}
else if (matches.Length > 1)
{
string foundPaths = string.Join(", ", matches);
return new ErrorResponse($"Multiple assets found matching file name '{fileName}': {foundPaths}. Please provide a more specific path.");
}
else
{
prefabPath = matches[0];
McpLog.Info($"[ManageGameObject.Create] Found unique asset at path: '{prefabPath}'");
}
}
GameObject prefabAsset = AssetDatabase.LoadAssetAtPath<GameObject>(prefabPath);
if (prefabAsset != null)
{
try
{
newGo = PrefabUtility.InstantiatePrefab(prefabAsset) as GameObject;
if (newGo == null)
{
McpLog.Error($"[ManageGameObject.Create] Failed to instantiate prefab at '{prefabPath}', asset might be corrupted or not a GameObject.");
return new ErrorResponse($"Failed to instantiate prefab at '{prefabPath}'.");
}
if (!string.IsNullOrEmpty(name))
{
newGo.name = name;
}
Undo.RegisterCreatedObjectUndo(newGo, $"Instantiate Prefab '{prefabAsset.name}' as '{newGo.name}'");
McpLog.Info($"[ManageGameObject.Create] Instantiated prefab '{prefabAsset.name}' from path '{prefabPath}' as '{newGo.name}'.");
}
catch (Exception e)
{
return new ErrorResponse($"Error instantiating prefab '{prefabPath}': {e.Message}");
}
}
else
{
return new ErrorResponse($"Asset not found or not a GameObject at path: '{prefabPath}'.");
}
}
// --- Fallback: Create Primitive or Empty GameObject ---
bool createdNewObject = false;
if (newGo == null)
{
if (!string.IsNullOrEmpty(primitiveType))
{
try
{
PrimitiveType type = (PrimitiveType)Enum.Parse(typeof(PrimitiveType), primitiveType, true);
newGo = GameObject.CreatePrimitive(type);
if (!string.IsNullOrEmpty(name))
{
newGo.name = name;
}
else
{
UnityEngine.Object.DestroyImmediate(newGo);
return new ErrorResponse("'name' parameter is required when creating a primitive.");
}
createdNewObject = true;
}
catch (ArgumentException)
{
return new ErrorResponse($"Invalid primitive type: '{primitiveType}'. Valid types: {string.Join(", ", Enum.GetNames(typeof(PrimitiveType)))}");
}
catch (Exception e)
{
return new ErrorResponse($"Failed to create primitive '{primitiveType}': {e.Message}");
}
}
else
{
if (string.IsNullOrEmpty(name))
{
return new ErrorResponse("'name' parameter is required for 'create' action when not instantiating a prefab or creating a primitive.");
}
newGo = new GameObject(name);
createdNewObject = true;
}
if (createdNewObject)
{
Undo.RegisterCreatedObjectUndo(newGo, $"Create GameObject '{newGo.name}'");
}
}
if (newGo == null)
{
return new ErrorResponse("Failed to create or instantiate the GameObject.");
}
Undo.RecordObject(newGo.transform, "Set GameObject Transform");
Undo.RecordObject(newGo, "Set GameObject Properties");
// Set Parent
JToken parentToken = @params["parent"];
if (parentToken != null)
{
GameObject parentGo = ManageGameObjectCommon.FindObjectInternal(parentToken, "by_id_or_name_or_path");
if (parentGo == null)
{
UnityEngine.Object.DestroyImmediate(newGo);
return new ErrorResponse($"Parent specified ('{parentToken}') but not found.");
}
newGo.transform.SetParent(parentGo.transform, true);
}
// Set Transform
Vector3? position = VectorParsing.ParseVector3(@params["position"]);
Vector3? rotation = VectorParsing.ParseVector3(@params["rotation"]);
Vector3? scale = VectorParsing.ParseVector3(@params["scale"]);
if (position.HasValue) newGo.transform.localPosition = position.Value;
if (rotation.HasValue) newGo.transform.localEulerAngles = rotation.Value;
if (scale.HasValue) newGo.transform.localScale = scale.Value;
// Set Tag
if (!string.IsNullOrEmpty(tag))
{
if (tag != "Untagged" && !System.Linq.Enumerable.Contains(InternalEditorUtility.tags, tag))
{
McpLog.Info($"[ManageGameObject.Create] Tag '{tag}' not found. Creating it.");
try
{
InternalEditorUtility.AddTag(tag);
}
catch (Exception ex)
{
UnityEngine.Object.DestroyImmediate(newGo);
return new ErrorResponse($"Failed to create tag '{tag}': {ex.Message}.");
}
}
try
{
newGo.tag = tag;
}
catch (Exception ex)
{
UnityEngine.Object.DestroyImmediate(newGo);
return new ErrorResponse($"Failed to set tag to '{tag}' during creation: {ex.Message}.");
}
}
// Set Layer
string layerName = @params["layer"]?.ToString();
if (!string.IsNullOrEmpty(layerName))
{
int layerId = LayerMask.NameToLayer(layerName);
if (layerId != -1)
{
newGo.layer = layerId;
}
else
{
McpLog.Warn($"[ManageGameObject.Create] Layer '{layerName}' not found. Using default layer.");
}
}
// Add Components
if (@params["componentsToAdd"] is JArray componentsToAddArray)
{
foreach (var compToken in componentsToAddArray)
{
string typeName = null;
JObject properties = null;
if (compToken.Type == JTokenType.String)
{
typeName = compToken.ToString();
}
else if (compToken is JObject compObj)
{
typeName = compObj["typeName"]?.ToString();
properties = compObj["properties"] as JObject;
}
if (!string.IsNullOrEmpty(typeName))
{
var addResult = GameObjectComponentHelpers.AddComponentInternal(newGo, typeName, properties);
if (addResult != null)
{
UnityEngine.Object.DestroyImmediate(newGo);
return addResult;
}
}
else
{
McpLog.Warn($"[ManageGameObject] Invalid component format in componentsToAdd: {compToken}");
}
}
}
// Save as Prefab ONLY if we *created* a new object AND saveAsPrefab is true
GameObject finalInstance = newGo;
if (createdNewObject && saveAsPrefab)
{
string finalPrefabPath = prefabPath;
if (string.IsNullOrEmpty(finalPrefabPath))
{
UnityEngine.Object.DestroyImmediate(newGo);
return new ErrorResponse("'prefabPath' is required when 'saveAsPrefab' is true and creating a new object.");
}
if (!finalPrefabPath.EndsWith(".prefab", StringComparison.OrdinalIgnoreCase))
{
McpLog.Info($"[ManageGameObject.Create] Appending .prefab extension to save path: '{finalPrefabPath}' -> '{finalPrefabPath}.prefab'");
finalPrefabPath += ".prefab";
}
try
{
string directoryPath = System.IO.Path.GetDirectoryName(finalPrefabPath);
if (!string.IsNullOrEmpty(directoryPath) && !System.IO.Directory.Exists(directoryPath))
{
System.IO.Directory.CreateDirectory(directoryPath);
AssetDatabase.Refresh(ImportAssetOptions.ForceSynchronousImport);
McpLog.Info($"[ManageGameObject.Create] Created directory for prefab: {directoryPath}");
}
finalInstance = PrefabUtility.SaveAsPrefabAssetAndConnect(newGo, finalPrefabPath, InteractionMode.UserAction);
if (finalInstance == null)
{
UnityEngine.Object.DestroyImmediate(newGo);
return new ErrorResponse($"Failed to save GameObject '{name}' as prefab at '{finalPrefabPath}'. Check path and permissions.");
}
McpLog.Info($"[ManageGameObject.Create] GameObject '{name}' saved as prefab to '{finalPrefabPath}' and instance connected.");
}
catch (Exception e)
{
UnityEngine.Object.DestroyImmediate(newGo);
return new ErrorResponse($"Error saving prefab '{finalPrefabPath}': {e.Message}");
}
}
Selection.activeGameObject = finalInstance;
string messagePrefabPath =
finalInstance == null
? originalPrefabPath
: AssetDatabase.GetAssetPath(PrefabUtility.GetCorrespondingObjectFromSource(finalInstance) ?? (UnityEngine.Object)finalInstance);
string successMessage;
if (!createdNewObject && !string.IsNullOrEmpty(messagePrefabPath))
{
successMessage = $"Prefab '{messagePrefabPath}' instantiated successfully as '{finalInstance.name}'.";
}
else if (createdNewObject && saveAsPrefab && !string.IsNullOrEmpty(messagePrefabPath))
{
successMessage = $"GameObject '{finalInstance.name}' created and saved as prefab to '{messagePrefabPath}'.";
}
else
{
successMessage = $"GameObject '{finalInstance.name}' created successfully in scene.";
}
return new SuccessResponse(successMessage, Helpers.GameObjectSerializer.GetGameObjectData(finalInstance));
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 0931774a07e4b4626b4261dd8d0974c2
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,49 @@
#nullable disable
using System.Collections.Generic;
using MCPForUnity.Editor.Helpers;
using Newtonsoft.Json.Linq;
using UnityEditor;
using UnityEngine;
using MCPForUnity.Runtime.Helpers;
namespace MCPForUnity.Editor.Tools.GameObjects
{
internal static class GameObjectDelete
{
internal static object Handle(JToken targetToken, string searchMethod)
{
List<GameObject> targets = ManageGameObjectCommon.FindObjectsInternal(targetToken, searchMethod, true);
if (targets.Count == 0)
{
return new ErrorResponse($"Target GameObject(s) ('{targetToken}') not found using method '{searchMethod ?? "default"}'.");
}
List<object> deletedObjects = new List<object>();
foreach (var targetGo in targets)
{
if (targetGo != null)
{
string goName = targetGo.name;
int goId = targetGo.GetInstanceIDCompat();
// Note: Undo.DestroyObjectImmediate doesn't work reliably in test context,
// so we use Object.DestroyImmediate. This means delete isn't undoable.
// TODO: Investigate Undo.DestroyObjectImmediate behavior in Unity 2022+
Object.DestroyImmediate(targetGo);
deletedObjects.Add(new { name = goName, instanceID = goId });
}
}
if (deletedObjects.Count > 0)
{
string message =
targets.Count == 1
? $"GameObject '{((dynamic)deletedObjects[0]).name}' deleted successfully."
: $"{deletedObjects.Count} GameObjects deleted successfully.";
return new SuccessResponse(message, deletedObjects);
}
return new ErrorResponse("Failed to delete target GameObject(s).");
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 505a482aaf60b415abd794737a630b10
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,87 @@
#nullable disable
using MCPForUnity.Editor.Helpers;
using Newtonsoft.Json.Linq;
using UnityEditor;
using UnityEditor.SceneManagement;
using UnityEngine;
using MCPForUnity.Runtime.Helpers;
namespace MCPForUnity.Editor.Tools.GameObjects
{
internal static class GameObjectDuplicate
{
internal static object Handle(JObject @params, JToken targetToken, string searchMethod)
{
GameObject sourceGo = ManageGameObjectCommon.FindObjectInternal(targetToken, searchMethod);
if (sourceGo == null)
{
return new ErrorResponse($"Target GameObject ('{targetToken}') not found using method '{searchMethod ?? "default"}'.");
}
string newName = @params["new_name"]?.ToString();
Vector3? position = VectorParsing.ParseVector3(@params["position"]);
Vector3? offset = VectorParsing.ParseVector3(@params["offset"]);
JToken parentToken = @params["parent"];
GameObject duplicatedGo = UnityEngine.Object.Instantiate(sourceGo);
Undo.RegisterCreatedObjectUndo(duplicatedGo, $"Duplicate {sourceGo.name}");
if (!string.IsNullOrEmpty(newName))
{
duplicatedGo.name = newName;
}
else
{
duplicatedGo.name = sourceGo.name.Replace("(Clone)", "").Trim() + "_Copy";
}
if (position.HasValue)
{
duplicatedGo.transform.position = position.Value;
}
else if (offset.HasValue)
{
duplicatedGo.transform.position = sourceGo.transform.position + offset.Value;
}
if (parentToken != null)
{
if (parentToken.Type == JTokenType.Null || (parentToken.Type == JTokenType.String && string.IsNullOrEmpty(parentToken.ToString())))
{
duplicatedGo.transform.SetParent(null);
}
else
{
GameObject newParent = ManageGameObjectCommon.FindObjectInternal(parentToken, "by_id_or_name_or_path");
if (newParent != null)
{
duplicatedGo.transform.SetParent(newParent.transform, true);
}
else
{
McpLog.Warn($"[ManageGameObject.Duplicate] Parent '{parentToken}' not found. Object will remain at root level.");
}
}
}
else
{
duplicatedGo.transform.SetParent(sourceGo.transform.parent, true);
}
EditorUtility.SetDirty(duplicatedGo);
EditorSceneManager.MarkSceneDirty(EditorSceneManager.GetActiveScene());
Selection.activeGameObject = duplicatedGo;
return new SuccessResponse(
$"Duplicated '{sourceGo.name}' as '{duplicatedGo.name}'.",
new
{
originalName = sourceGo.name,
originalId = sourceGo.GetInstanceIDCompat(),
duplicatedObject = Helpers.GameObjectSerializer.GetGameObjectData(duplicatedGo)
}
);
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 698728d56425a47af92a45377031a48b
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,22 @@
#nullable disable
using Newtonsoft.Json.Linq;
namespace MCPForUnity.Editor.Tools.GameObjects
{
internal static class GameObjectHandlers
{
internal static object Create(JObject @params) => GameObjectCreate.Handle(@params);
internal static object Modify(JObject @params, JToken targetToken, string searchMethod)
=> GameObjectModify.Handle(@params, targetToken, searchMethod);
internal static object Delete(JToken targetToken, string searchMethod)
=> GameObjectDelete.Handle(targetToken, searchMethod);
internal static object Duplicate(JObject @params, JToken targetToken, string searchMethod)
=> GameObjectDuplicate.Handle(@params, targetToken, searchMethod);
internal static object MoveRelative(JObject @params, JToken targetToken, string searchMethod)
=> GameObjectMoveRelative.Handle(@params, targetToken, searchMethod);
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: f3cf2313460d44a09b258d2ee04c5ef0
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,64 @@
#nullable disable
using MCPForUnity.Editor.Helpers;
using Newtonsoft.Json.Linq;
using UnityEditor;
using UnityEngine;
using MCPForUnity.Runtime.Helpers;
namespace MCPForUnity.Editor.Tools.GameObjects
{
internal static class GameObjectLookAt
{
/// <summary>
/// Rotates a GameObject to face a world position or another GameObject.
/// Parameters:
/// target - The GO to rotate (name/path/instanceID)
/// look_at_target - World position [x,y,z] or GO reference (name/path/instanceID) to look at
/// look_at_up - Optional up vector [x,y,z], defaults to Vector3.up
/// </summary>
internal static object Handle(JObject @params, JToken targetToken, string searchMethod)
{
GameObject targetGo = ManageGameObjectCommon.FindObjectInternal(targetToken, searchMethod);
if (targetGo == null)
{
return new ErrorResponse($"Target GameObject ('{targetToken}') not found using method '{searchMethod ?? "default"}'.");
}
JToken lookAtToken = @params["look_at_target"] ?? @params["lookAtTarget"];
if (lookAtToken == null)
{
return new ErrorResponse("'look_at_target' parameter is required for 'look_at' action. Provide a world position [x,y,z] or a GameObject name/path/ID.");
}
// Try parsing as a position vector first
Vector3? lookAtPos = VectorParsing.ParseVector3(lookAtToken);
if (!lookAtPos.HasValue)
{
// Not a vector — treat as a GO reference, using the same search method as for the main target
GameObject lookAtGo = ManageGameObjectCommon.FindObjectInternal(lookAtToken, searchMethod);
if (lookAtGo == null)
{
return new ErrorResponse($"look_at_target '{lookAtToken}' could not be resolved as a position [x,y,z] or found as a GameObject.");
}
lookAtPos = lookAtGo.transform.position;
}
Vector3 upVector = VectorParsing.ParseVector3OrDefault(@params["look_at_up"] ?? @params["lookAtUp"], Vector3.up);
Undo.RecordObject(targetGo.transform, $"LookAt {targetGo.name}");
targetGo.transform.LookAt(lookAtPos.Value, upVector);
var euler = targetGo.transform.rotation.eulerAngles;
return new SuccessResponse(
$"'{targetGo.name}' now looking at ({lookAtPos.Value.x:F2}, {lookAtPos.Value.y:F2}, {lookAtPos.Value.z:F2}).",
new
{
name = targetGo.name,
instanceID = targetGo.GetInstanceIDCompat(),
rotation = new[] { euler.x, euler.y, euler.z },
lookAtPosition = new[] { lookAtPos.Value.x, lookAtPos.Value.y, lookAtPos.Value.z },
}
);
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: fede847680da4b11a1c9e01d98ffbf16
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,310 @@
#nullable disable
using System;
using System.Collections.Generic;
using System.Linq;
using MCPForUnity.Editor.Helpers;
using Newtonsoft.Json.Linq;
using UnityEditor;
using UnityEditor.SceneManagement;
using UnityEditorInternal;
using UnityEngine;
namespace MCPForUnity.Editor.Tools.GameObjects
{
internal static class GameObjectModify
{
internal static object Handle(JObject @params, JToken targetToken, string searchMethod)
{
// When setActive=true is specified, we need to search for inactive objects
// otherwise we can't find an inactive object to activate it
JObject findParams = null;
if (@params["setActive"]?.ToObject<bool?>() == true)
{
findParams = new JObject { ["searchInactive"] = true };
}
GameObject targetGo = ManageGameObjectCommon.FindObjectInternal(targetToken, searchMethod, findParams);
if (targetGo == null)
{
return new ErrorResponse($"Target GameObject ('{targetToken}') not found using method '{searchMethod ?? "default"}'.");
}
Undo.RecordObject(targetGo.transform, "Modify GameObject Transform");
Undo.RecordObject(targetGo, "Modify GameObject Properties");
bool modified = false;
string name = @params["name"]?.ToString() ?? @params["new_name"]?.ToString() ?? @params["newName"]?.ToString();
if (!string.IsNullOrEmpty(name) && targetGo.name != name)
{
// Check if we're renaming the root object of an open prefab stage
var prefabStageForRename = PrefabStageUtility.GetCurrentPrefabStage();
bool isRenamingPrefabRoot = prefabStageForRename != null &&
prefabStageForRename.prefabContentsRoot == targetGo;
if (isRenamingPrefabRoot)
{
// Rename the prefab asset file to match the new name (avoids Unity dialog)
string assetPath = prefabStageForRename.assetPath;
string directory = System.IO.Path.GetDirectoryName(assetPath);
string newAssetPath = AssetPathUtility.NormalizeSeparators(System.IO.Path.Combine(directory, name + ".prefab"));
// Only rename if the path actually changes
if (newAssetPath != assetPath)
{
// Check for collision using GUID comparison
string currentGuid = AssetDatabase.AssetPathToGUID(assetPath);
string existingGuid = AssetDatabase.AssetPathToGUID(newAssetPath);
// Collision only if there's a different asset at the new path
if (!string.IsNullOrEmpty(existingGuid) && existingGuid != currentGuid)
{
return new ErrorResponse($"Cannot rename prefab root to '{name}': a prefab already exists at '{newAssetPath}'.");
}
// Rename the asset file
string renameError = AssetDatabase.RenameAsset(assetPath, name);
if (!string.IsNullOrEmpty(renameError))
{
return new ErrorResponse($"Failed to rename prefab asset: {renameError}");
}
McpLog.Info($"[GameObjectModify] Renamed prefab asset from '{assetPath}' to '{newAssetPath}'");
}
}
targetGo.name = name;
modified = true;
}
JToken parentToken = @params["parent"];
if (parentToken != null)
{
GameObject newParentGo = ManageGameObjectCommon.FindObjectInternal(parentToken, "by_id_or_name_or_path");
if (
newParentGo == null
&& !(parentToken.Type == JTokenType.Null
|| (parentToken.Type == JTokenType.String && string.IsNullOrEmpty(parentToken.ToString())))
)
{
return new ErrorResponse($"New parent ('{parentToken}') not found.");
}
if (newParentGo != null && newParentGo.transform.IsChildOf(targetGo.transform))
{
return new ErrorResponse($"Cannot parent '{targetGo.name}' to '{newParentGo.name}', as it would create a hierarchy loop.");
}
if (targetGo.transform.parent != (newParentGo?.transform))
{
targetGo.transform.SetParent(newParentGo?.transform, true);
modified = true;
}
}
bool? setActive = @params["setActive"]?.ToObject<bool?>();
if (setActive.HasValue && targetGo.activeSelf != setActive.Value)
{
targetGo.SetActive(setActive.Value);
modified = true;
}
string tag = @params["tag"]?.ToString();
if (tag != null && targetGo.tag != tag)
{
string tagToSet = string.IsNullOrEmpty(tag) ? "Untagged" : tag;
if (tagToSet != "Untagged" && !System.Linq.Enumerable.Contains(InternalEditorUtility.tags, tagToSet))
{
McpLog.Info($"[ManageGameObject] Tag '{tagToSet}' not found. Creating it.");
try
{
InternalEditorUtility.AddTag(tagToSet);
}
catch (Exception ex)
{
return new ErrorResponse($"Failed to create tag '{tagToSet}': {ex.Message}.");
}
}
try
{
targetGo.tag = tagToSet;
modified = true;
}
catch (Exception ex)
{
return new ErrorResponse($"Failed to set tag to '{tagToSet}': {ex.Message}.");
}
}
string layerName = @params["layer"]?.ToString();
if (!string.IsNullOrEmpty(layerName))
{
int layerId = LayerMask.NameToLayer(layerName);
if (layerId == -1)
{
return new ErrorResponse($"Invalid layer specified: '{layerName}'. Use a valid layer name.");
}
if (layerId != -1 && targetGo.layer != layerId)
{
targetGo.layer = layerId;
modified = true;
}
}
bool? isStatic = @params["isStatic"]?.ToObject<bool?>();
if (isStatic.HasValue)
{
var desiredFlags = isStatic.Value ? (StaticEditorFlags)~0 : 0;
var currentFlags = GameObjectUtility.GetStaticEditorFlags(targetGo);
if (currentFlags != desiredFlags)
{
GameObjectUtility.SetStaticEditorFlags(targetGo, desiredFlags);
modified = true;
}
}
Vector3? position = VectorParsing.ParseVector3(@params["position"]);
Vector3? rotation = VectorParsing.ParseVector3(@params["rotation"]);
Vector3? scale = VectorParsing.ParseVector3(@params["scale"]);
if (position.HasValue && targetGo.transform.localPosition != position.Value)
{
targetGo.transform.localPosition = position.Value;
modified = true;
}
if (rotation.HasValue && targetGo.transform.localEulerAngles != rotation.Value)
{
targetGo.transform.localEulerAngles = rotation.Value;
modified = true;
}
if (scale.HasValue && targetGo.transform.localScale != scale.Value)
{
targetGo.transform.localScale = scale.Value;
modified = true;
}
if (@params["componentsToRemove"] is JArray componentsToRemoveArray)
{
foreach (var compToken in componentsToRemoveArray)
{
string typeName = compToken.ToString();
if (!string.IsNullOrEmpty(typeName))
{
var removeResult = GameObjectComponentHelpers.RemoveComponentInternal(targetGo, typeName);
if (removeResult != null)
return removeResult;
modified = true;
}
}
}
if (@params["componentsToAdd"] is JArray componentsToAddArrayModify)
{
foreach (var compToken in componentsToAddArrayModify)
{
string typeName = null;
JObject properties = null;
if (compToken.Type == JTokenType.String)
typeName = compToken.ToString();
else if (compToken is JObject compObj)
{
typeName = compObj["typeName"]?.ToString();
properties = compObj["properties"] as JObject;
}
if (!string.IsNullOrEmpty(typeName))
{
var addResult = GameObjectComponentHelpers.AddComponentInternal(targetGo, typeName, properties);
if (addResult != null)
return addResult;
modified = true;
}
}
}
var componentErrors = new List<object>();
if (@params["componentProperties"] is JObject componentPropertiesObj)
{
foreach (var prop in componentPropertiesObj.Properties())
{
string compName = prop.Name;
JObject propertiesToSet = prop.Value as JObject;
if (propertiesToSet != null)
{
var setResult = GameObjectComponentHelpers.SetComponentPropertiesInternal(targetGo, compName, propertiesToSet);
if (setResult != null)
{
componentErrors.Add(setResult);
}
else
{
modified = true;
}
}
}
}
if (componentErrors.Count > 0)
{
var aggregatedErrors = new List<string>();
foreach (var errorObj in componentErrors)
{
try
{
var dataProp = errorObj?.GetType().GetProperty("data");
var dataVal = dataProp?.GetValue(errorObj);
if (dataVal != null)
{
var errorsProp = dataVal.GetType().GetProperty("errors");
var errorsEnum = errorsProp?.GetValue(dataVal) as System.Collections.IEnumerable;
if (errorsEnum != null)
{
foreach (var item in errorsEnum)
{
var s = item?.ToString();
if (!string.IsNullOrEmpty(s)) aggregatedErrors.Add(s);
}
}
}
}
catch (Exception ex)
{
McpLog.Warn($"[GameObjectModify] Error aggregating component errors: {ex.Message}");
}
}
return new ErrorResponse(
$"One or more component property operations failed on '{targetGo.name}'.",
new { componentErrors = componentErrors, errors = aggregatedErrors }
);
}
if (!modified)
{
return new SuccessResponse(
$"No modifications applied to GameObject '{targetGo.name}'.",
Helpers.GameObjectSerializer.GetGameObjectData(targetGo)
);
}
EditorUtility.SetDirty(targetGo);
// Mark the appropriate scene as dirty (handles both regular scenes and prefab stages)
var prefabStage = PrefabStageUtility.GetCurrentPrefabStage();
if (prefabStage != null)
{
EditorSceneManager.MarkSceneDirty(prefabStage.scene);
}
else
{
EditorSceneManager.MarkSceneDirty(EditorSceneManager.GetActiveScene());
}
return new SuccessResponse(
$"GameObject '{targetGo.name}' modified successfully.",
Helpers.GameObjectSerializer.GetGameObjectData(targetGo)
);
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: ec5e33513bd094257a26ef6f75ea4574
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,119 @@
#nullable disable
using MCPForUnity.Editor.Helpers;
using Newtonsoft.Json.Linq;
using UnityEditor;
using UnityEditor.SceneManagement;
using UnityEngine;
namespace MCPForUnity.Editor.Tools.GameObjects
{
internal static class GameObjectMoveRelative
{
internal static object Handle(JObject @params, JToken targetToken, string searchMethod)
{
GameObject targetGo = ManageGameObjectCommon.FindObjectInternal(targetToken, searchMethod);
if (targetGo == null)
{
return new ErrorResponse($"Target GameObject ('{targetToken}') not found using method '{searchMethod ?? "default"}'.");
}
JToken referenceToken = @params["reference_object"];
if (referenceToken == null)
{
return new ErrorResponse("'reference_object' parameter is required for 'move_relative' action.");
}
GameObject referenceGo = ManageGameObjectCommon.FindObjectInternal(referenceToken, "by_id_or_name_or_path");
if (referenceGo == null)
{
return new ErrorResponse($"Reference object '{referenceToken}' not found.");
}
string direction = @params["direction"]?.ToString()?.ToLower();
float distance = @params["distance"]?.ToObject<float>() ?? 1f;
Vector3? customOffset = VectorParsing.ParseVector3(@params["offset"]);
bool useWorldSpace = @params["world_space"]?.ToObject<bool>() ?? true;
Undo.RecordObject(targetGo.transform, $"Move {targetGo.name} relative to {referenceGo.name}");
Vector3 newPosition;
if (customOffset.HasValue)
{
if (useWorldSpace)
{
newPosition = referenceGo.transform.position + customOffset.Value;
}
else
{
newPosition = referenceGo.transform.TransformPoint(customOffset.Value);
}
}
else if (!string.IsNullOrEmpty(direction))
{
Vector3 directionVector = GetDirectionVector(direction, referenceGo.transform, useWorldSpace);
newPosition = referenceGo.transform.position + directionVector * distance;
}
else
{
return new ErrorResponse("Either 'direction' or 'offset' parameter is required for 'move_relative' action.");
}
targetGo.transform.position = newPosition;
EditorUtility.SetDirty(targetGo);
EditorSceneManager.MarkSceneDirty(EditorSceneManager.GetActiveScene());
return new SuccessResponse(
$"Moved '{targetGo.name}' relative to '{referenceGo.name}'.",
new
{
movedObject = targetGo.name,
referenceObject = referenceGo.name,
newPosition = new[] { targetGo.transform.position.x, targetGo.transform.position.y, targetGo.transform.position.z },
direction = direction,
distance = distance,
gameObject = Helpers.GameObjectSerializer.GetGameObjectData(targetGo)
}
);
}
private static Vector3 GetDirectionVector(string direction, Transform referenceTransform, bool useWorldSpace)
{
if (useWorldSpace)
{
switch (direction)
{
case "right": return Vector3.right;
case "left": return Vector3.left;
case "up": return Vector3.up;
case "down": return Vector3.down;
case "forward":
case "front": return Vector3.forward;
case "back":
case "backward":
case "behind": return Vector3.back;
default:
McpLog.Warn($"[ManageGameObject.MoveRelative] Unknown direction '{direction}', defaulting to forward.");
return Vector3.forward;
}
}
switch (direction)
{
case "right": return referenceTransform.right;
case "left": return -referenceTransform.right;
case "up": return referenceTransform.up;
case "down": return -referenceTransform.up;
case "forward":
case "front": return referenceTransform.forward;
case "back":
case "backward":
case "behind": return -referenceTransform.forward;
default:
McpLog.Warn($"[ManageGameObject.MoveRelative] Unknown direction '{direction}', defaulting to forward.");
return referenceTransform.forward;
}
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 8b19997a165de45c2af3ada79a6d3f08
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,117 @@
#nullable disable
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using MCPForUnity.Editor.Helpers; // For Response class
using Newtonsoft.Json.Linq;
using UnityEngine;
using UnityEngine.SceneManagement;
namespace MCPForUnity.Editor.Tools.GameObjects
{
/// <summary>
/// Handles GameObject manipulation within the current scene (CRUD, find, components).
/// </summary>
[McpForUnityTool("manage_gameobject", AutoRegister = false)]
public static class ManageGameObject
{
// --- Main Handler ---
public static object HandleCommand(JObject @params)
{
if (@params == null)
{
return new ErrorResponse("Parameters cannot be null.");
}
string action = @params["action"]?.ToString().ToLower();
if (string.IsNullOrEmpty(action))
{
return new ErrorResponse("Action parameter is required.");
}
// Parameters used by various actions
JToken targetToken = @params["target"]; // Can be string (name/path) or int (instanceID)
string name = @params["name"]?.ToString();
// --- Usability Improvement: Alias 'name' to 'target' for modification actions ---
// If 'target' is missing but 'name' is provided, and we aren't creating a new object,
// assume the user meant "find object by name".
if (targetToken == null && !string.IsNullOrEmpty(name) && action != "create")
{
targetToken = name;
// We don't update @params["target"] because we use targetToken locally mostly,
// but some downstream methods might parse @params directly. Let's update @params too for safety.
@params["target"] = name;
}
// -------------------------------------------------------------------------------
string searchMethod = @params["searchMethod"]?.ToString().ToLower();
string tag = @params["tag"]?.ToString();
string layer = @params["layer"]?.ToString();
JToken parentToken = @params["parent"];
// Coerce string JSON to JObject for 'componentProperties' if provided as a JSON string
var componentPropsToken = @params["componentProperties"];
if (componentPropsToken != null && componentPropsToken.Type == JTokenType.String)
{
try
{
var parsed = JObject.Parse(componentPropsToken.ToString());
@params["componentProperties"] = parsed;
}
catch (Exception e)
{
McpLog.Warn($"[ManageGameObject] Could not parse 'componentProperties' JSON string: {e.Message}");
}
}
// --- Prefab Asset Check ---
// Prefab assets require different tools. Only 'create' (instantiation) is valid here.
string targetPath =
targetToken?.Type == JTokenType.String ? targetToken.ToString() : null;
if (
!string.IsNullOrEmpty(targetPath)
&& targetPath.EndsWith(".prefab", StringComparison.OrdinalIgnoreCase)
&& action != "create" // Allow prefab instantiation
)
{
return new ErrorResponse(
$"Target '{targetPath}' is a prefab asset. " +
$"Use 'manage_asset' with action='modify' for prefab asset modifications, " +
$"or 'manage_prefabs' with action='modify_contents' to edit the prefab headlessly, or 'manage_prefabs' with action='close_prefab_stage' to exit prefab editing mode."
);
}
// --- End Prefab Asset Check ---
try
{
switch (action)
{
// --- Primary lifecycle actions (kept in manage_gameobject) ---
case "create":
return GameObjectCreate.Handle(@params);
case "modify":
return GameObjectModify.Handle(@params, targetToken, searchMethod);
case "delete":
return GameObjectDelete.Handle(targetToken, searchMethod);
case "duplicate":
return GameObjectDuplicate.Handle(@params, targetToken, searchMethod);
case "move_relative":
return GameObjectMoveRelative.Handle(@params, targetToken, searchMethod);
case "look_at":
return GameObjectLookAt.Handle(@params, targetToken, searchMethod);
default:
return new ErrorResponse($"Unknown action: '{action}'.");
}
}
catch (Exception e)
{
McpLog.Error($"[ManageGameObject] Action '{action}' failed: {e}");
return new ErrorResponse($"Internal error processing action '{action}': {e.Message}");
}
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 7641d7388f0f6634b9d83d34de87b2ee
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,232 @@
#nullable disable
using System;
using System.Collections.Generic;
using System.Linq;
using MCPForUnity.Editor.Helpers;
using MCPForUnity.Editor.Tools;
using Newtonsoft.Json.Linq;
using UnityEditor.SceneManagement;
using UnityEngine;
using UnityEngine.SceneManagement;
using MCPForUnity.Runtime.Helpers;
namespace MCPForUnity.Editor.Tools.GameObjects
{
internal static class ManageGameObjectCommon
{
internal static GameObject FindObjectInternal(JToken targetToken, string searchMethod, JObject findParams = null)
{
bool findAll = findParams?["findAll"]?.ToObject<bool>() ?? false;
if (
targetToken?.Type == JTokenType.Integer
|| (searchMethod == "by_id" && int.TryParse(targetToken?.ToString(), out _))
)
{
findAll = false;
}
List<GameObject> results = FindObjectsInternal(targetToken, searchMethod, findAll, findParams);
return results.Count > 0 ? results[0] : null;
}
internal static List<GameObject> FindObjectsInternal(
JToken targetToken,
string searchMethod,
bool findAll,
JObject findParams = null
)
{
List<GameObject> results = new List<GameObject>();
string searchTerm = findParams?["searchTerm"]?.ToString() ?? targetToken?.ToString();
bool searchInChildren = findParams?["searchInChildren"]?.ToObject<bool>() ?? false;
bool searchInactive = findParams?["searchInactive"]?.ToObject<bool>() ?? false;
if (string.IsNullOrEmpty(searchMethod))
{
if (targetToken?.Type == JTokenType.Integer)
searchMethod = "by_id";
else if (!string.IsNullOrEmpty(searchTerm) && searchTerm.Contains('/'))
searchMethod = "by_path";
else
searchMethod = "by_name";
}
GameObject rootSearchObject = null;
if (searchInChildren && targetToken != null)
{
rootSearchObject = FindObjectInternal(targetToken, "by_id_or_name_or_path");
if (rootSearchObject == null)
{
McpLog.Warn($"[ManageGameObject.Find] Root object '{targetToken}' for child search not found.");
return results;
}
}
switch (searchMethod)
{
case "by_id":
if (int.TryParse(searchTerm, out int instanceId))
{
var allObjects = GetAllSceneObjects(searchInactive);
GameObject obj = allObjects.FirstOrDefault(go => go.GetInstanceIDCompat() == instanceId);
if (obj != null)
results.Add(obj);
}
break;
case "by_name":
var searchPoolName = rootSearchObject
? rootSearchObject
.GetComponentsInChildren<Transform>(searchInactive)
.Select(t => t.gameObject)
: GetAllSceneObjects(searchInactive);
results.AddRange(searchPoolName.Where(go => go.name == searchTerm));
break;
case "by_path":
if (rootSearchObject != null)
{
Transform foundTransform = rootSearchObject.transform.Find(searchTerm);
if (foundTransform != null)
results.Add(foundTransform.gameObject);
}
else
{
var prefabStage = PrefabStageUtility.GetCurrentPrefabStage();
if (prefabStage != null || searchInactive)
{
// In Prefab Stage, GameObject.Find() doesn't work, need to search manually
var allObjects = GetAllSceneObjects(searchInactive);
foreach (var go in allObjects)
{
if (GameObjectLookup.MatchesPath(go, searchTerm))
{
results.Add(go);
}
}
}
else
{
var found = GameObject.Find(searchTerm);
if (found != null)
results.Add(found);
}
}
break;
case "by_tag":
var searchPoolTag = rootSearchObject
? rootSearchObject
.GetComponentsInChildren<Transform>(searchInactive)
.Select(t => t.gameObject)
: GetAllSceneObjects(searchInactive);
results.AddRange(searchPoolTag.Where(go => go.CompareTag(searchTerm)));
break;
case "by_layer":
var searchPoolLayer = rootSearchObject
? rootSearchObject
.GetComponentsInChildren<Transform>(searchInactive)
.Select(t => t.gameObject)
: GetAllSceneObjects(searchInactive);
if (int.TryParse(searchTerm, out int layerIndex))
{
results.AddRange(searchPoolLayer.Where(go => go.layer == layerIndex));
}
else
{
int namedLayer = LayerMask.NameToLayer(searchTerm);
if (namedLayer != -1)
results.AddRange(searchPoolLayer.Where(go => go.layer == namedLayer));
}
break;
case "by_component":
Type componentType = FindType(searchTerm);
if (componentType != null)
{
IEnumerable<GameObject> searchPoolComp;
if (rootSearchObject)
{
searchPoolComp = rootSearchObject
.GetComponentsInChildren(componentType, searchInactive)
.Select(c => (c as Component).gameObject);
}
else
{
searchPoolComp = UnityFindObjectsCompat.FindAll(componentType, searchInactive)
.Cast<Component>()
.Select(c => c.gameObject);
}
results.AddRange(searchPoolComp.Where(go => go != null));
}
else
{
McpLog.Warn($"[ManageGameObject.Find] Component type not found: {searchTerm}");
}
break;
case "by_id_or_name_or_path":
if (int.TryParse(searchTerm, out int id))
{
var allObjectsId = GetAllSceneObjects(true);
GameObject objById = allObjectsId.FirstOrDefault(go => go.GetInstanceIDCompat() == id);
if (objById != null)
{
results.Add(objById);
break;
}
}
// Try path search - in Prefab Stage, GameObject.Find() doesn't work
var allObjectsForPath = GetAllSceneObjects(true);
GameObject objByPath = allObjectsForPath.FirstOrDefault(go =>
{
return GameObjectLookup.MatchesPath(go, searchTerm);
});
if (objByPath != null)
{
results.Add(objByPath);
break;
}
var allObjectsName = GetAllSceneObjects(true);
results.AddRange(allObjectsName.Where(go => go.name == searchTerm));
break;
default:
McpLog.Warn($"[ManageGameObject.Find] Unknown search method: {searchMethod}");
break;
}
if (!findAll && results.Count > 1)
{
return new List<GameObject> { results[0] };
}
return results.Distinct().ToList();
}
private static IEnumerable<GameObject> GetAllSceneObjects(bool includeInactive)
{
// Delegate to GameObjectLookup to avoid code duplication and ensure consistent behavior
return GameObjectLookup.GetAllSceneObjects(includeInactive);
}
private static Type FindType(string typeName)
{
if (ComponentResolver.TryResolve(typeName, out Type resolvedType, out string error))
{
return resolvedType;
}
if (!string.IsNullOrEmpty(error))
{
McpLog.Warn($"[FindType] {error}");
}
return null;
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 6bf0edf3cd2af46729294682cee3bee4
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
+36
View File
@@ -0,0 +1,36 @@
using System;
using MCPForUnity.Editor.Helpers;
using MCPForUnity.Editor.Services;
using Newtonsoft.Json.Linq;
namespace MCPForUnity.Editor.Tools
{
/// <summary>
/// Poll a previously started async test job by job_id.
/// </summary>
[McpForUnityTool("get_test_job", AutoRegister = false, Group = "testing")]
public static class GetTestJob
{
public static object HandleCommand(JObject @params)
{
string jobId = @params?["job_id"]?.ToString() ?? @params?["jobId"]?.ToString();
if (string.IsNullOrWhiteSpace(jobId))
{
return new ErrorResponse("Missing required parameter 'job_id'.");
}
var p = new ToolParams(@params);
bool includeDetails = p.GetBool("includeDetails");
bool includeFailedTests = p.GetBool("includeFailedTests");
var job = TestJobManager.GetJob(jobId);
if (job == null)
{
return new ErrorResponse("Unknown job_id.");
}
var payload = TestJobManager.ToSerializable(job, includeDetails, includeFailedTests);
return new SuccessResponse("Test job status retrieved.", payload);
}
}
}
@@ -0,0 +1,13 @@
fileFormatVersion: 2
guid: 7f92c2b67a2c4b5c9d1a3c0e6f9b2d10
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
+8
View File
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: fbd13c8334e847f58acef6bbae6d5c35
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,268 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using MCPForUnity.Editor.Helpers;
using MCPForUnity.Runtime.Helpers;
using Newtonsoft.Json.Linq;
using UnityEditor;
using UnityEngine;
namespace MCPForUnity.Editor.Tools.Graphics
{
internal static class GraphicsHelpers
{
private static bool? _hasVolumeSystem;
private static Type _volumeType;
private static Type _volumeProfileType;
private static Type _volumeComponentType;
private static Type _volumeParameterType;
internal static bool HasVolumeSystem
{
get
{
if (_hasVolumeSystem == null) DetectPackages();
return _hasVolumeSystem.Value;
}
}
internal static bool HasURP =>
RenderPipelineUtility.GetActivePipeline() == RenderPipelineUtility.PipelineKind.Universal;
internal static bool HasHDRP =>
RenderPipelineUtility.GetActivePipeline() == RenderPipelineUtility.PipelineKind.HighDefinition;
internal static Type VolumeType
{
get
{
if (_hasVolumeSystem == null) DetectPackages();
return _volumeType;
}
}
internal static Type VolumeProfileType
{
get
{
if (_hasVolumeSystem == null) DetectPackages();
return _volumeProfileType;
}
}
internal static Type VolumeComponentType
{
get
{
if (_hasVolumeSystem == null) DetectPackages();
return _volumeComponentType;
}
}
internal static Type VolumeParameterType
{
get
{
if (_hasVolumeSystem == null) DetectPackages();
return _volumeParameterType;
}
}
private static void DetectPackages()
{
_volumeType = Type.GetType("UnityEngine.Rendering.Volume, Unity.RenderPipelines.Core.Runtime");
_volumeProfileType = Type.GetType("UnityEngine.Rendering.VolumeProfile, Unity.RenderPipelines.Core.Runtime");
_volumeComponentType = Type.GetType("UnityEngine.Rendering.VolumeComponent, Unity.RenderPipelines.Core.Runtime");
_volumeParameterType = Type.GetType("UnityEngine.Rendering.VolumeParameter, Unity.RenderPipelines.Core.Runtime");
_hasVolumeSystem = _volumeType != null && _volumeProfileType != null;
}
internal static Type ResolveVolumeComponentType(string effectName)
{
if (string.IsNullOrEmpty(effectName) || VolumeComponentType == null)
return null;
var derivedTypes = TypeCache.GetTypesDerivedFrom(VolumeComponentType);
foreach (var t in derivedTypes)
{
if (t.IsAbstract) continue;
if (string.Equals(t.Name, effectName, StringComparison.OrdinalIgnoreCase))
return t;
}
return null;
}
internal static List<Type> GetAvailableEffectTypes()
{
if (VolumeComponentType == null)
return new List<Type>();
var derivedTypes = TypeCache.GetTypesDerivedFrom(VolumeComponentType);
return derivedTypes
.Where(t => !t.IsAbstract && !t.IsGenericType)
.OrderBy(t => t.Name)
.ToList();
}
internal static Component FindVolume(JObject @params)
{
var p = new ToolParams(@params);
string target = p.Get("target");
if (string.IsNullOrEmpty(target))
{
var allVolumes = UnityFindObjectsCompat.FindAll(VolumeType);
return allVolumes.Length > 0 ? allVolumes[0] as Component : null;
}
if (int.TryParse(target, out int instanceId))
{
var byId = GameObjectLookup.ResolveInstanceID(instanceId) as GameObject;
if (byId != null) return byId.GetComponent(VolumeType);
}
var go = GameObject.Find(target);
if (go != null) return go.GetComponent(VolumeType);
return null;
}
internal static string GetPipelineName()
{
return RenderPipelineUtility.GetActivePipeline() switch
{
RenderPipelineUtility.PipelineKind.Universal => "Universal (URP)",
RenderPipelineUtility.PipelineKind.HighDefinition => "High Definition (HDRP)",
RenderPipelineUtility.PipelineKind.BuiltIn => "Built-in",
RenderPipelineUtility.PipelineKind.Custom => "Custom",
_ => "Unknown"
};
}
internal static object ReadSerializedValue(SerializedProperty prop)
{
return prop.propertyType switch
{
SerializedPropertyType.Boolean => prop.boolValue,
SerializedPropertyType.Integer => prop.type == "long" ? prop.longValue : (object)prop.intValue,
SerializedPropertyType.Float => prop.floatValue,
SerializedPropertyType.String => prop.stringValue,
SerializedPropertyType.Enum => prop.enumValueIndex < prop.enumNames.Length
? prop.enumNames[prop.enumValueIndex]
: (object)prop.enumValueIndex,
SerializedPropertyType.ObjectReference => prop.objectReferenceValue != null
? (object)new
{
name = prop.objectReferenceValue.name,
path = AssetDatabase.GetAssetPath(prop.objectReferenceValue)
}
: null,
SerializedPropertyType.Color => new[] { prop.colorValue.r, prop.colorValue.g, prop.colorValue.b, prop.colorValue.a },
SerializedPropertyType.Vector2 => new[] { prop.vector2Value.x, prop.vector2Value.y },
SerializedPropertyType.Vector3 => new[] { prop.vector3Value.x, prop.vector3Value.y, prop.vector3Value.z },
SerializedPropertyType.LayerMask => prop.intValue,
_ => prop.propertyType.ToString()
};
}
internal static bool SetSerializedValue(SerializedProperty prop, JToken value)
{
try
{
switch (prop.propertyType)
{
case SerializedPropertyType.Boolean:
prop.boolValue = ParamCoercion.CoerceBool(value, false);
return true;
case SerializedPropertyType.Integer:
if (prop.type == "long")
prop.longValue = ParamCoercion.CoerceLong(value, 0);
else
prop.intValue = ParamCoercion.CoerceInt(value, 0);
return true;
case SerializedPropertyType.Float:
prop.floatValue = ParamCoercion.CoerceFloat(value, 0f);
return true;
case SerializedPropertyType.String:
prop.stringValue = value.ToString();
return true;
case SerializedPropertyType.Enum:
if (value.Type == JTokenType.String)
{
for (int i = 0; i < prop.enumNames.Length; i++)
{
if (string.Equals(prop.enumNames[i], value.ToString(), StringComparison.OrdinalIgnoreCase))
{ prop.enumValueIndex = i; return true; }
}
}
prop.enumValueIndex = ParamCoercion.CoerceInt(value, 0);
return true;
case SerializedPropertyType.ObjectReference:
if (value.Type == JTokenType.String)
{
string path = value.ToString();
var asset = AssetDatabase.LoadAssetAtPath<UnityEngine.Object>(path);
if (asset != null) { prop.objectReferenceValue = asset; return true; }
}
else if (value.Type == JTokenType.Object)
{
string path = value["path"]?.ToString();
if (!string.IsNullOrEmpty(path))
{
var asset = AssetDatabase.LoadAssetAtPath<UnityEngine.Object>(path);
if (asset != null) { prop.objectReferenceValue = asset; return true; }
}
}
else if (value.Type == JTokenType.Null)
{
prop.objectReferenceValue = null;
return true;
}
return false;
case SerializedPropertyType.Color:
if (value is JArray colorArr && colorArr.Count >= 3)
{
prop.colorValue = new Color(
(float)colorArr[0], (float)colorArr[1], (float)colorArr[2],
colorArr.Count >= 4 ? (float)colorArr[3] : 1f);
return true;
}
return false;
case SerializedPropertyType.Vector2:
if (value is JArray v2Arr && v2Arr.Count >= 2)
{
prop.vector2Value = new Vector2((float)v2Arr[0], (float)v2Arr[1]);
return true;
}
return false;
case SerializedPropertyType.Vector3:
if (value is JArray v3Arr && v3Arr.Count >= 3)
{
prop.vector3Value = new Vector3((float)v3Arr[0], (float)v3Arr[1], (float)v3Arr[2]);
return true;
}
return false;
case SerializedPropertyType.LayerMask:
prop.intValue = ParamCoercion.CoerceInt(value, 0);
return true;
default:
return false;
}
}
catch { return false; }
}
internal static void MarkDirty(UnityEngine.Object obj)
{
if (obj == null) return;
EditorUtility.SetDirty(obj);
if (obj is Component comp)
{
var prefabStage = UnityEditor.SceneManagement.PrefabStageUtility.GetCurrentPrefabStage();
if (prefabStage != null)
UnityEditor.SceneManagement.EditorSceneManager.MarkSceneDirty(prefabStage.scene);
else
UnityEditor.SceneManagement.EditorSceneManager.MarkSceneDirty(comp.gameObject.scene);
}
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 99e4b1a4aa03465ea2d55e2794537155
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,585 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using MCPForUnity.Editor.Helpers;
using Newtonsoft.Json.Linq;
using UnityEditor;
using UnityEngine;
using UnityEngine.Rendering;
using MCPForUnity.Runtime.Helpers;
namespace MCPForUnity.Editor.Tools.Graphics
{
internal static class LightBakingOps
{
// === bake_start ===
// Params: async (bool, default true)
internal static object StartBake(JObject @params)
{
if (Application.isPlaying)
return new ErrorResponse("Light baking requires Edit mode.");
var p = new ToolParams(@params);
bool async_ = p.GetBool("async", true);
if (async_)
{
Lightmapping.BakeAsync();
return new PendingResponse(
"Light bake started (async). Use bake_status to check progress.",
pollIntervalSeconds: 2.0,
data: new { mode = "async" }
);
}
Lightmapping.Bake();
return new
{
success = true,
message = "Light bake completed (synchronous).",
data = new
{
mode = "sync",
lightmapCount = LightmapSettings.lightmaps.Length
}
};
}
// === bake_cancel ===
internal static object CancelBake(JObject @params)
{
Lightmapping.Cancel();
return new
{
success = true,
message = "Light bake cancelled."
};
}
// === bake_get_status ===
internal static object GetStatus(JObject @params)
{
bool running = Lightmapping.isRunning;
return new
{
success = true,
message = running ? "Light bake in progress." : "No bake running.",
data = new
{
isRunning = running,
bakedGI = Lightmapping.bakedGI,
realtimeGI = Lightmapping.realtimeGI,
lightmapCount = LightmapSettings.lightmaps.Length
}
};
}
// === bake_clear ===
internal static object ClearBake(JObject @params)
{
Lightmapping.Clear();
Lightmapping.ClearLightingDataAsset();
return new
{
success = true,
message = "Cleared all baked lighting data and lighting data asset."
};
}
// === bake_reflection_probe ===
// Params: target (name or instanceID of GameObject with ReflectionProbe)
internal static object BakeReflectionProbe(JObject @params)
{
if (Application.isPlaying)
return new ErrorResponse("Reflection probe baking requires Edit mode.");
var p = new ToolParams(@params);
string target = p.Get("target");
if (string.IsNullOrEmpty(target))
return new ErrorResponse("'target' parameter is required (name or instanceID of a GameObject with ReflectionProbe).");
var go = FindGameObject(target);
if (go == null)
return new ErrorResponse($"GameObject '{target}' not found.");
var probe = go.GetComponent<ReflectionProbe>();
if (probe == null)
return new ErrorResponse($"GameObject '{go.name}' does not have a ReflectionProbe component.");
string dir = "Assets/Lightmaps";
if (!AssetDatabase.IsValidFolder(dir))
AssetDatabase.CreateFolder("Assets", "Lightmaps");
string outputPath = $"{dir}/{probe.name}_ReflectionProbe.exr";
bool result = Lightmapping.BakeReflectionProbe(probe, outputPath);
if (!result)
return new ErrorResponse($"Failed to bake reflection probe '{probe.name}'.");
return new
{
success = true,
message = $"Baked reflection probe '{probe.name}' to '{outputPath}'.",
data = new
{
probeName = probe.name,
outputPath,
instanceID = go.GetInstanceIDCompat()
}
};
}
// === bake_get_settings ===
internal static object GetSettings(JObject @params)
{
var settings = EnsureLightingSettings();
if (settings == null)
return new ErrorResponse(
"Failed to create LightingSettings. Open Window > Rendering > Lighting manually.");
var data = new Dictionary<string, object>
{
["name"] = settings.name,
["path"] = AssetDatabase.GetAssetPath(settings),
["bakedGI"] = settings.bakedGI,
["realtimeGI"] = settings.realtimeGI,
["lightmapper"] = settings.lightmapper.ToString(),
["lightmapResolution"] = settings.lightmapResolution,
["lightmapMaxSize"] = settings.lightmapMaxSize,
["directSampleCount"] = settings.directSampleCount,
["indirectSampleCount"] = settings.indirectSampleCount,
["environmentSampleCount"] = settings.environmentSampleCount,
["mixedBakeMode"] = settings.mixedBakeMode.ToString(),
["lightmapCompression"] = settings.lightmapCompression.ToString(),
["ao"] = settings.ao,
["aoMaxDistance"] = settings.aoMaxDistance
};
// bounceCount vs maxBounces — name varies by Unity version
ReadBounceCount(settings, data);
return new
{
success = true,
message = $"Lighting settings: {settings.lightmapper}, resolution {settings.lightmapResolution}.",
data
};
}
// === bake_set_settings ===
// Params: settings (dict of property name -> value)
internal static object SetSettings(JObject @params)
{
var p = new ToolParams(@params);
var settingsToken = p.GetRaw("settings") as JObject;
if (settingsToken == null || !settingsToken.HasValues)
return new ErrorResponse("'settings' parameter is required (dict of property name to value).");
var lightingSettings = EnsureLightingSettings();
if (lightingSettings == null)
return new ErrorResponse(
"Failed to create LightingSettings. Open Window > Rendering > Lighting manually.");
Undo.RecordObject(lightingSettings, "Modify Lighting Settings");
var changed = new List<string>();
var failed = new List<string>();
foreach (var prop in settingsToken.Properties())
{
string name = prop.Name;
JToken value = prop.Value;
try
{
if (TrySetLightingSetting(lightingSettings, name, value))
changed.Add(name);
else
failed.Add(name);
}
catch (Exception ex)
{
McpLog.Warn($"[LightBakingOps] Failed to set '{name}': {ex.Message}");
failed.Add(name);
}
}
if (changed.Count == 0 && failed.Count > 0)
return new ErrorResponse($"Failed to set any settings. Invalid properties: {string.Join(", ", failed)}");
EditorUtility.SetDirty(lightingSettings);
var msg = $"Updated {changed.Count} lighting setting(s)";
if (failed.Count > 0)
msg += $". Failed: {string.Join(", ", failed)}";
return new
{
success = true,
message = msg,
data = new { changed, failed }
};
}
// === bake_create_light_probe_group ===
// Params: name, position, grid_size, spacing
internal static object CreateLightProbeGroup(JObject @params)
{
var p = new ToolParams(@params);
string name = p.Get("name") ?? "Light Probes";
float spacing = p.GetFloat("spacing") ?? 2.0f;
var posToken = p.GetRaw("position") as JArray;
Vector3 position = posToken != null && posToken.Count >= 3
? new Vector3(posToken[0].Value<float>(), posToken[1].Value<float>(), posToken[2].Value<float>())
: Vector3.zero;
var gridToken = p.GetRaw("grid_size") as JArray;
int gridX = gridToken != null && gridToken.Count >= 1 ? gridToken[0].Value<int>() : 3;
int gridY = gridToken != null && gridToken.Count >= 2 ? gridToken[1].Value<int>() : 2;
int gridZ = gridToken != null && gridToken.Count >= 3 ? gridToken[2].Value<int>() : 3;
var go = new GameObject(name);
go.transform.position = position;
Undo.RegisterCreatedObjectUndo(go, $"Create Light Probe Group '{name}'");
var probeGroup = go.AddComponent<LightProbeGroup>();
var positions = new List<Vector3>();
float halfX = (gridX - 1) * spacing * 0.5f;
float halfY = (gridY - 1) * spacing * 0.5f;
float halfZ = (gridZ - 1) * spacing * 0.5f;
for (int x = 0; x < gridX; x++)
{
for (int y = 0; y < gridY; y++)
{
for (int z = 0; z < gridZ; z++)
{
positions.Add(new Vector3(
x * spacing - halfX,
y * spacing - halfY,
z * spacing - halfZ
));
}
}
}
probeGroup.probePositions = positions.ToArray();
GraphicsHelpers.MarkDirty(probeGroup);
return new
{
success = true,
message = $"Created Light Probe Group '{name}' with {positions.Count} probes ({gridX}x{gridY}x{gridZ} grid, spacing {spacing}).",
data = new
{
instanceID = go.GetInstanceIDCompat(),
probeCount = positions.Count,
gridSize = new[] { gridX, gridY, gridZ },
spacing,
position = new[] { position.x, position.y, position.z }
}
};
}
// === bake_create_reflection_probe ===
// Params: name, position, size, resolution, mode, hdr, box_projection
internal static object CreateReflectionProbe(JObject @params)
{
var p = new ToolParams(@params);
string name = p.Get("name") ?? "Reflection Probe";
int resolution = p.GetInt("resolution") ?? 256;
bool hdr = p.GetBool("hdr", true);
bool boxProjection = p.GetBool("box_projection", false);
string modeStr = p.Get("mode") ?? "Baked";
var posToken = p.GetRaw("position") as JArray;
Vector3 position = posToken != null && posToken.Count >= 3
? new Vector3(posToken[0].Value<float>(), posToken[1].Value<float>(), posToken[2].Value<float>())
: Vector3.zero;
var sizeToken = p.GetRaw("size") as JArray;
Vector3 size = sizeToken != null && sizeToken.Count >= 3
? new Vector3(sizeToken[0].Value<float>(), sizeToken[1].Value<float>(), sizeToken[2].Value<float>())
: new Vector3(10f, 10f, 10f);
if (!Enum.TryParse<ReflectionProbeMode>(modeStr, true, out var mode))
return new ErrorResponse(
$"Invalid mode '{modeStr}'. Valid values: Baked, Realtime, Custom.");
var go = new GameObject(name);
go.transform.position = position;
Undo.RegisterCreatedObjectUndo(go, $"Create Reflection Probe '{name}'");
var probe = go.AddComponent<ReflectionProbe>();
probe.size = size;
probe.resolution = resolution;
probe.mode = mode;
probe.hdr = hdr;
probe.boxProjection = boxProjection;
GraphicsHelpers.MarkDirty(probe);
return new
{
success = true,
message = $"Created Reflection Probe '{name}' (mode: {mode}, resolution: {resolution}, HDR: {hdr}).",
data = new
{
instanceID = go.GetInstanceIDCompat(),
mode = mode.ToString(),
resolution,
hdr,
boxProjection,
size = new[] { size.x, size.y, size.z },
position = new[] { position.x, position.y, position.z }
}
};
}
// === bake_set_probe_positions ===
// Params: target (name/instanceID), positions (array of [x,y,z])
internal static object SetProbePositions(JObject @params)
{
var p = new ToolParams(@params);
string target = p.Get("target");
if (string.IsNullOrEmpty(target))
return new ErrorResponse("'target' parameter is required (name or instanceID of a GameObject with LightProbeGroup).");
var go = FindGameObject(target);
if (go == null)
return new ErrorResponse($"GameObject '{target}' not found.");
var probeGroup = go.GetComponent<LightProbeGroup>();
if (probeGroup == null)
return new ErrorResponse($"GameObject '{go.name}' does not have a LightProbeGroup component.");
var positionsToken = p.GetRaw("positions") as JArray;
if (positionsToken == null || positionsToken.Count == 0)
return new ErrorResponse("'positions' parameter is required (array of [x,y,z] arrays).");
Undo.RecordObject(probeGroup, "Set Light Probe Positions");
var positions = new Vector3[positionsToken.Count];
for (int i = 0; i < positionsToken.Count; i++)
{
var arr = positionsToken[i] as JArray;
if (arr == null || arr.Count < 3)
return new ErrorResponse($"Position at index {i} must be an array of [x, y, z].");
positions[i] = new Vector3(
arr[0].Value<float>(),
arr[1].Value<float>(),
arr[2].Value<float>()
);
}
probeGroup.probePositions = positions;
GraphicsHelpers.MarkDirty(probeGroup);
return new
{
success = true,
message = $"Set {positions.Length} probe positions on '{go.name}'.",
data = new
{
instanceID = go.GetInstanceIDCompat(),
probeCount = positions.Length
}
};
}
// --- Helper: Ensure a LightingSettings asset exists ---
private static LightingSettings EnsureLightingSettings()
{
try
{
var settings = Lightmapping.lightingSettings;
if (settings != null) return settings;
}
catch { /* getter throws when no asset exists */ }
try
{
var settings = new LightingSettings { name = "LightingSettings" };
Lightmapping.lightingSettings = settings;
return Lightmapping.lightingSettings;
}
catch { return null; }
}
// --- Helper: Find a GameObject by name or instanceID ---
private static GameObject FindGameObject(string target)
{
if (string.IsNullOrEmpty(target))
return null;
if (int.TryParse(target, out int instanceId))
{
var byId = GameObjectLookup.ResolveInstanceID(instanceId) as GameObject;
if (byId != null) return byId;
}
return GameObject.Find(target);
}
// --- Helper: Read bounceCount with version fallback ---
private static void ReadBounceCount(LightingSettings settings, Dictionary<string, object> data)
{
var type = typeof(LightingSettings);
// Try bounceCount first (Unity 2022+)
var prop = type.GetProperty("bounceCount", BindingFlags.Public | BindingFlags.Instance);
if (prop != null)
{
data["bounceCount"] = prop.GetValue(settings);
return;
}
// Fallback to maxBounces (older Unity versions)
prop = type.GetProperty("maxBounces", BindingFlags.Public | BindingFlags.Instance);
if (prop != null)
data["maxBounces"] = prop.GetValue(settings);
}
// --- Helper: Set a single lighting setting by name ---
private static bool TrySetLightingSetting(LightingSettings settings, string name, JToken value)
{
switch (name.ToLowerInvariant())
{
case "bakedgi":
case "baked_gi":
settings.bakedGI = ParamCoercion.CoerceBool(value, settings.bakedGI);
return true;
case "realtimegi":
case "realtime_gi":
settings.realtimeGI = ParamCoercion.CoerceBool(value, settings.realtimeGI);
return true;
case "lightmapper":
if (TryParseEnum<LightingSettings.Lightmapper>(value, out var lm))
{
settings.lightmapper = lm;
return true;
}
return false;
case "lightmapresolution":
case "lightmap_resolution":
settings.lightmapResolution = ParamCoercion.CoerceFloat(value, settings.lightmapResolution);
return true;
case "lightmapmaxsize":
case "lightmap_max_size":
settings.lightmapMaxSize = ParamCoercion.CoerceInt(value, settings.lightmapMaxSize);
return true;
case "directsamplecount":
case "direct_sample_count":
settings.directSampleCount = ParamCoercion.CoerceInt(value, settings.directSampleCount);
return true;
case "indirectsamplecount":
case "indirect_sample_count":
settings.indirectSampleCount = ParamCoercion.CoerceInt(value, settings.indirectSampleCount);
return true;
case "environmentsamplecount":
case "environment_sample_count":
settings.environmentSampleCount = ParamCoercion.CoerceInt(value, settings.environmentSampleCount);
return true;
case "bouncecount":
case "bounce_count":
case "maxbounces":
case "max_bounces":
return TrySetBounceCount(settings, ParamCoercion.CoerceInt(value, 2));
case "mixedbakemode":
case "mixed_bake_mode":
if (TryParseEnum<MixedLightingMode>(value, out var mlm))
{
settings.mixedBakeMode = mlm;
return true;
}
return false;
case "compresslightmaps":
case "compress_lightmaps":
case "lightmapcompression":
case "lightmap_compression":
var strVal = value?.ToString() ?? "";
if (System.Enum.TryParse<LightmapCompression>(strVal, true, out var compression))
settings.lightmapCompression = compression;
else if (bool.TryParse(strVal, out var boolVal))
settings.lightmapCompression = boolVal
? LightmapCompression.NormalQuality : LightmapCompression.None;
else if (int.TryParse(strVal, out var intVal))
settings.lightmapCompression = (LightmapCompression)intVal;
else
return false;
return true;
case "ao":
settings.ao = ParamCoercion.CoerceBool(value, settings.ao);
return true;
case "aomaxdistance":
case "ao_max_distance":
settings.aoMaxDistance = ParamCoercion.CoerceFloat(value, settings.aoMaxDistance);
return true;
default:
return false;
}
}
// --- Helper: Set bounceCount with version fallback ---
private static bool TrySetBounceCount(LightingSettings settings, int value)
{
var type = typeof(LightingSettings);
// Try bounceCount first (Unity 2022+)
var prop = type.GetProperty("bounceCount", BindingFlags.Public | BindingFlags.Instance);
if (prop != null && prop.CanWrite)
{
prop.SetValue(settings, value);
return true;
}
// Fallback to maxBounces (older Unity versions)
prop = type.GetProperty("maxBounces", BindingFlags.Public | BindingFlags.Instance);
if (prop != null && prop.CanWrite)
{
prop.SetValue(settings, value);
return true;
}
return false;
}
// --- Helper: Parse enum from JToken (string name or int value) ---
private static bool TryParseEnum<T>(JToken value, out T result) where T : struct, Enum
{
result = default;
if (value == null || value.Type == JTokenType.Null) return false;
string str = value.ToString();
// Try parse by name
if (Enum.TryParse(str, true, out result))
return true;
// Try parse by int value
if (int.TryParse(str, out int intVal))
{
result = (T)Enum.ToObject(typeof(T), intVal);
return true;
}
return false;
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 4bf5a93fc3954f3e880e60794fa968de
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,175 @@
using System;
using Newtonsoft.Json.Linq;
using MCPForUnity.Editor.Helpers;
namespace MCPForUnity.Editor.Tools.Graphics
{
[McpForUnityTool("manage_graphics", AutoRegister = false, Group = "core")]
public static class ManageGraphics
{
public static object HandleCommand(JObject @params)
{
if (@params == null)
return new ErrorResponse("Parameters cannot be null.");
var p = new ToolParams(@params);
string action = p.Get("action")?.ToLowerInvariant();
if (string.IsNullOrEmpty(action))
return new ErrorResponse("'action' parameter is required.");
try
{
switch (action)
{
// --- Health check ---
case "ping":
var pipeName = GraphicsHelpers.GetPipelineName();
return new
{
success = true,
message = $"Graphics tool ready. Pipeline: {pipeName}",
data = new
{
pipeline = RenderPipelineUtility.GetActivePipeline().ToString(),
pipelineName = pipeName,
hasVolumeSystem = GraphicsHelpers.HasVolumeSystem,
hasURP = GraphicsHelpers.HasURP,
hasHDRP = GraphicsHelpers.HasHDRP,
availableEffects = GraphicsHelpers.HasVolumeSystem
? GraphicsHelpers.GetAvailableEffectTypes().Count : 0
}
};
// --- Volume actions (require Volume system = URP or HDRP) ---
case "volume_create":
case "volume_add_effect":
case "volume_set_effect":
case "volume_remove_effect":
case "volume_get_info":
case "volume_set_properties":
case "volume_list_effects":
case "volume_create_profile":
{
if (!GraphicsHelpers.HasVolumeSystem)
return new ErrorResponse(
"Volume system not available. Requires URP or HDRP (com.unity.render-pipelines.core).");
return action switch
{
"volume_create" => VolumeOps.CreateVolume(@params),
"volume_add_effect" => VolumeOps.AddEffect(@params),
"volume_set_effect" => VolumeOps.SetEffect(@params),
"volume_remove_effect" => VolumeOps.RemoveEffect(@params),
"volume_get_info" => VolumeOps.GetInfo(@params),
"volume_set_properties" => VolumeOps.SetProperties(@params),
"volume_list_effects" => VolumeOps.ListEffects(@params),
"volume_create_profile" => VolumeOps.CreateProfile(@params),
_ => new ErrorResponse($"Unknown volume action: '{action}'")
};
}
// --- Bake actions (always available, Edit mode only) ---
case "bake_start":
return LightBakingOps.StartBake(@params);
case "bake_cancel":
return LightBakingOps.CancelBake(@params);
case "bake_status":
return LightBakingOps.GetStatus(@params);
case "bake_clear":
return LightBakingOps.ClearBake(@params);
case "bake_reflection_probe":
return LightBakingOps.BakeReflectionProbe(@params);
case "bake_get_settings":
return LightBakingOps.GetSettings(@params);
case "bake_set_settings":
return LightBakingOps.SetSettings(@params);
case "bake_create_light_probe_group":
return LightBakingOps.CreateLightProbeGroup(@params);
case "bake_create_reflection_probe":
return LightBakingOps.CreateReflectionProbe(@params);
case "bake_set_probe_positions":
return LightBakingOps.SetProbePositions(@params);
// --- Stats actions (always available) ---
case "stats_get":
return RenderingStatsOps.GetStats(@params);
case "stats_list_counters":
return RenderingStatsOps.ListCounters(@params);
case "stats_set_scene_debug":
return RenderingStatsOps.SetSceneDebugMode(@params);
case "stats_get_memory":
return RenderingStatsOps.GetMemory(@params);
// --- Pipeline actions (always available) ---
case "pipeline_get_info":
return RenderPipelineOps.GetInfo(@params);
case "pipeline_set_quality":
return RenderPipelineOps.SetQuality(@params);
case "pipeline_get_settings":
return RenderPipelineOps.GetSettings(@params);
case "pipeline_set_settings":
return RenderPipelineOps.SetSettings(@params);
// --- Renderer feature actions (URP only) ---
case "feature_list":
case "feature_add":
case "feature_remove":
case "feature_configure":
case "feature_toggle":
case "feature_reorder":
{
if (!GraphicsHelpers.HasURP)
return new ErrorResponse("Renderer features require URP (Universal Render Pipeline).");
return action switch
{
"feature_list" => RendererFeatureOps.ListFeatures(@params),
"feature_add" => RendererFeatureOps.AddFeature(@params),
"feature_remove" => RendererFeatureOps.RemoveFeature(@params),
"feature_configure" => RendererFeatureOps.ConfigureFeature(@params),
"feature_toggle" => RendererFeatureOps.ToggleFeature(@params),
"feature_reorder" => RendererFeatureOps.ReorderFeatures(@params),
_ => new ErrorResponse($"Unknown feature action: '{action}'")
};
}
// --- Skybox / Environment actions (always available) ---
case "skybox_get":
return SkyboxOps.GetEnvironment(@params);
case "skybox_set_material":
return SkyboxOps.SetMaterial(@params);
case "skybox_set_properties":
return SkyboxOps.SetMaterialProperties(@params);
case "skybox_set_ambient":
return SkyboxOps.SetAmbient(@params);
case "skybox_set_fog":
return SkyboxOps.SetFog(@params);
case "skybox_set_reflection":
return SkyboxOps.SetReflection(@params);
case "skybox_set_sun":
return SkyboxOps.SetSun(@params);
default:
return new ErrorResponse(
$"Unknown action: '{action}'. Valid actions: ping, "
+ "volume_create, volume_add_effect, volume_set_effect, volume_remove_effect, "
+ "volume_get_info, volume_set_properties, volume_list_effects, volume_create_profile, "
+ "bake_start, bake_cancel, bake_status, bake_clear, bake_reflection_probe, "
+ "bake_get_settings, bake_set_settings, bake_create_light_probe_group, "
+ "bake_create_reflection_probe, bake_set_probe_positions, "
+ "stats_get, stats_list_counters, stats_set_scene_debug, stats_get_memory, "
+ "pipeline_get_info, pipeline_set_quality, pipeline_get_settings, pipeline_set_settings, "
+ "feature_list, feature_add, feature_remove, feature_configure, feature_toggle, feature_reorder, "
+ "skybox_get, skybox_set_material, skybox_set_properties, skybox_set_ambient, "
+ "skybox_set_fog, skybox_set_reflection, skybox_set_sun.");
}
}
catch (Exception ex)
{
McpLog.Error($"[ManageGraphics] Action '{action}' failed: {ex}");
return new ErrorResponse($"Error in action '{action}': {ex.Message}");
}
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: dafb0cedb22e465b8f7b19e68a636415
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,286 @@
using System;
using System.Collections.Generic;
using System.Reflection;
using MCPForUnity.Editor.Helpers;
using Newtonsoft.Json.Linq;
using UnityEditor;
using UnityEngine;
using UnityEngine.Rendering;
namespace MCPForUnity.Editor.Tools.Graphics
{
internal static class RenderPipelineOps
{
// === pipeline_get_info ===
// Returns: active pipeline, quality level, renderer info, key settings
internal static object GetInfo(JObject @params)
{
var pipeline = RenderPipelineUtility.GetActivePipeline();
var pipelineAsset = GraphicsSettings.currentRenderPipeline;
// Quality level info
int currentQuality = QualitySettings.GetQualityLevel();
string[] qualityNames = QualitySettings.names;
var data = new Dictionary<string, object>
{
["pipeline"] = pipeline.ToString(),
["pipelineName"] = GraphicsHelpers.GetPipelineName(),
["qualityLevel"] = currentQuality,
["qualityLevelName"] = currentQuality < qualityNames.Length ? qualityNames[currentQuality] : "Unknown",
["qualityLevels"] = qualityNames,
["colorSpace"] = QualitySettings.activeColorSpace.ToString(),
["hasVolumeSystem"] = GraphicsHelpers.HasVolumeSystem,
};
// If SRP, add pipeline asset info
if (pipelineAsset != null)
{
data["pipelineAsset"] = pipelineAsset.name;
data["pipelineAssetPath"] = AssetDatabase.GetAssetPath(pipelineAsset);
data["pipelineAssetType"] = pipelineAsset.GetType().Name;
// Read common public properties via reflection
var settings = new Dictionary<string, object>();
TryReadProperty(pipelineAsset, "renderScale", settings);
TryReadProperty(pipelineAsset, "supportsHDR", settings);
TryReadProperty(pipelineAsset, "msaaSampleCount", settings);
TryReadProperty(pipelineAsset, "shadowDistance", settings);
TryReadProperty(pipelineAsset, "shadowCascadeCount", settings);
TryReadProperty(pipelineAsset, "maxAdditionalLightsCount", settings);
TryReadProperty(pipelineAsset, "supportsSoftShadows", settings);
TryReadProperty(pipelineAsset, "colorGradingMode", settings);
if (settings.Count > 0)
data["settings"] = settings;
}
return new
{
success = true,
message = $"Pipeline: {GraphicsHelpers.GetPipelineName()}, Quality: {(currentQuality < qualityNames.Length ? qualityNames[currentQuality] : "?")}",
data
};
}
// === pipeline_set_quality ===
// Params: level (int or string name)
internal static object SetQuality(JObject @params)
{
var p = new ToolParams(@params);
string levelName = p.Get("level");
int? levelIndex = p.GetInt("level");
string[] names = QualitySettings.names;
int targetIndex = -1;
if (levelIndex.HasValue)
{
targetIndex = levelIndex.Value;
}
else if (!string.IsNullOrEmpty(levelName))
{
// Try exact match first
for (int i = 0; i < names.Length; i++)
{
if (string.Equals(names[i], levelName, StringComparison.OrdinalIgnoreCase))
{
targetIndex = i;
break;
}
}
// Try parse as int
if (targetIndex < 0 && int.TryParse(levelName, out int parsed))
targetIndex = parsed;
}
else
{
return new ErrorResponse($"'level' parameter required. Available: {string.Join(", ", names)}");
}
if (targetIndex < 0 || targetIndex >= names.Length)
return new ErrorResponse(
$"Invalid quality level. Available: {string.Join(", ", names)} (0-{names.Length - 1})");
QualitySettings.SetQualityLevel(targetIndex, true);
return new
{
success = true,
message = $"Quality level set to '{names[targetIndex]}' (index {targetIndex}).",
data = new
{
level = targetIndex,
name = names[targetIndex],
allLevels = names
}
};
}
// === pipeline_get_settings ===
// Detailed read of pipeline asset settings via public properties + SerializedObject fallback
internal static object GetSettings(JObject @params)
{
var pipelineAsset = GraphicsSettings.currentRenderPipeline;
if (pipelineAsset == null)
return new ErrorResponse("No render pipeline asset found (Built-in pipeline has no asset).");
var settings = new Dictionary<string, object>();
// Public properties (URP)
string[] publicProps = {
"renderScale", "supportsHDR", "msaaSampleCount", "shadowDistance",
"shadowCascadeCount", "mainLightShadowmapResolution",
"additionalLightsShadowmapResolution", "maxAdditionalLightsCount",
"supportsSoftShadows", "colorGradingMode", "colorGradingLutSize"
};
foreach (var propName in publicProps)
TryReadProperty(pipelineAsset, propName, settings);
// SerializedObject for non-public settings
var serializedSettings = new Dictionary<string, object>();
string[] serializedPaths = {
"m_DefaultRendererIndex", "m_MainLightRenderingMode",
"m_AdditionalLightsRenderingMode", "m_SupportsOpaqueTexture",
"m_SupportsDepthTexture"
};
using (var so = new SerializedObject(pipelineAsset))
{
foreach (var path in serializedPaths)
{
var prop = so.FindProperty(path);
if (prop != null)
serializedSettings[path] = GraphicsHelpers.ReadSerializedValue(prop);
}
}
if (serializedSettings.Count > 0)
settings["_serialized"] = serializedSettings;
return new
{
success = true,
message = $"Pipeline settings for '{pipelineAsset.name}'.",
data = new
{
assetName = pipelineAsset.name,
assetPath = AssetDatabase.GetAssetPath(pipelineAsset),
assetType = pipelineAsset.GetType().Name,
settings
}
};
}
// === pipeline_set_settings ===
// Write pipeline asset settings via public properties + SerializedObject fallback
internal static object SetSettings(JObject @params)
{
var p = new ToolParams(@params);
var settingsToken = p.GetRaw("settings") as JObject;
if (settingsToken == null)
return new ErrorResponse("'settings' dict is required.");
var pipelineAsset = GraphicsSettings.currentRenderPipeline;
if (pipelineAsset == null)
return new ErrorResponse("No render pipeline asset found.");
var changed = new List<string>();
var failed = new List<string>();
using (var so = new SerializedObject(pipelineAsset))
{
foreach (var prop in settingsToken.Properties())
{
string propName = prop.Name;
JToken value = prop.Value;
// Try public property first
var publicProp = pipelineAsset.GetType().GetProperty(propName,
BindingFlags.Public | BindingFlags.Instance);
if (publicProp != null && publicProp.CanWrite)
{
try
{
object converted = ConvertPropertyValue(value, publicProp.PropertyType);
publicProp.SetValue(pipelineAsset, converted);
changed.Add(propName);
continue;
}
catch (Exception ex)
{
McpLog.Warn($"[RenderPipelineOps] Failed to set '{propName}' via property: {ex.Message}");
}
}
// Try SerializedObject fallback (for m_ prefixed properties)
string serializedPath = propName.StartsWith("m_") ? propName : $"m_{char.ToUpper(propName[0])}{propName.Substring(1)}";
var sProp = so.FindProperty(serializedPath);
if (sProp == null && !propName.StartsWith("m_"))
sProp = so.FindProperty(propName);
if (sProp != null)
{
if (GraphicsHelpers.SetSerializedValue(sProp, value))
{
changed.Add(propName);
continue;
}
}
failed.Add(propName);
}
so.ApplyModifiedProperties();
}
EditorUtility.SetDirty(pipelineAsset);
AssetDatabase.SaveAssets();
var msg = $"Updated {changed.Count} pipeline setting(s)";
if (failed.Count > 0)
msg += $". Failed: {string.Join(", ", failed)}";
return new
{
success = true,
message = msg,
data = new { changed, failed }
};
}
// --- Helper: Convert JToken to target property type ---
private static object ConvertPropertyValue(JToken value, Type targetType)
{
if (targetType == typeof(bool)) return ParamCoercion.CoerceBool(value, false);
if (targetType == typeof(int)) return ParamCoercion.CoerceInt(value, 0);
if (targetType == typeof(float)) return ParamCoercion.CoerceFloat(value, 0f);
if (targetType == typeof(string)) return value.ToString();
if (targetType.IsEnum)
{
string str = value.ToString();
if (Enum.TryParse(targetType, str, true, out object enumVal))
return enumVal;
if (int.TryParse(str, out int intVal))
return Enum.ToObject(targetType, intVal);
}
return Convert.ChangeType(value.ToObject<object>(), targetType);
}
// --- Helper: Try to read a property value via reflection ---
private static void TryReadProperty(object obj, string propertyName, Dictionary<string, object> target)
{
if (obj == null) return;
var prop = obj.GetType().GetProperty(propertyName,
BindingFlags.Public | BindingFlags.Instance);
if (prop != null)
{
try
{
var val = prop.GetValue(obj);
target[propertyName] = val is Enum e ? e.ToString() : val;
}
catch { /* skip unreadable properties */ }
}
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 2e637fccb0c440e4b8cdb3bc6040c7c9
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,577 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using MCPForUnity.Editor.Helpers;
using Newtonsoft.Json.Linq;
using UnityEditor;
using UnityEngine;
using UnityEngine.Rendering;
using MCPForUnity.Runtime.Helpers;
namespace MCPForUnity.Editor.Tools.Graphics
{
internal static class RendererFeatureOps
{
// Cached URP types (resolved via reflection to avoid hard dependency)
private static Type _scriptableRendererDataType;
private static Type _scriptableRendererFeatureType;
private static Type _universalRenderPipelineAssetType;
private static bool _typesResolved;
private static void EnsureTypes()
{
if (_typesResolved) return;
_typesResolved = true;
_scriptableRendererDataType = Type.GetType(
"UnityEngine.Rendering.Universal.ScriptableRendererData, Unity.RenderPipelines.Universal.Runtime");
_scriptableRendererFeatureType = Type.GetType(
"UnityEngine.Rendering.Universal.ScriptableRendererFeature, Unity.RenderPipelines.Universal.Runtime");
_universalRenderPipelineAssetType = Type.GetType(
"UnityEngine.Rendering.Universal.UniversalRenderPipelineAsset, Unity.RenderPipelines.Universal.Runtime");
}
// === feature_list ===
internal static object ListFeatures(JObject @params)
{
var rendererData = GetRendererData(@params);
if (rendererData == null)
return new ErrorResponse("Could not find URP ScriptableRendererData. Ensure URP is active.");
var featuresProp = rendererData.GetType().GetProperty("rendererFeatures",
BindingFlags.Public | BindingFlags.Instance);
if (featuresProp == null)
return new ErrorResponse("rendererFeatures property not found on renderer data.");
var featuresList = featuresProp.GetValue(rendererData) as System.Collections.IList;
if (featuresList == null)
return new { success = true, message = "No renderer features.", data = new { features = new object[0] } };
var features = new List<object>();
for (int i = 0; i < featuresList.Count; i++)
{
var feature = featuresList[i] as ScriptableObject;
if (feature == null) continue;
var isActiveProp = feature.GetType().GetProperty("isActive",
BindingFlags.Public | BindingFlags.Instance);
features.Add(new
{
index = i,
name = feature.name,
type = feature.GetType().Name,
isActive = isActiveProp != null ? (bool)isActiveProp.GetValue(feature) : true,
properties = GetFeatureProperties(feature)
});
}
return new
{
success = true,
message = $"Found {features.Count} renderer feature(s).",
data = new
{
rendererDataName = (rendererData as ScriptableObject)?.name,
features
}
};
}
// === feature_add ===
internal static object AddFeature(JObject @params)
{
var p = new ToolParams(@params);
string typeName = p.Get("type");
if (string.IsNullOrEmpty(typeName))
return new ErrorResponse("'type' parameter required (e.g., 'FullScreenPassRendererFeature', 'RenderObjects').");
var rendererData = GetRendererData(@params);
if (rendererData == null)
return new ErrorResponse("Could not find URP ScriptableRendererData.");
EnsureTypes();
if (_scriptableRendererFeatureType == null)
return new ErrorResponse("ScriptableRendererFeature type not found. Is URP installed?");
// Resolve the feature type
var featureType = ResolveFeatureType(typeName);
if (featureType == null)
{
var available = GetAvailableFeatureTypes();
return new ErrorResponse(
$"Feature type '{typeName}' not found. Available: {string.Join(", ", available.Select(t => t.Name))}");
}
// Create the feature instance
var feature = ScriptableObject.CreateInstance(featureType);
if (feature == null)
return new ErrorResponse($"Failed to create instance of '{featureType.Name}'.");
string displayName = p.Get("name") ?? featureType.Name;
feature.name = displayName;
// Add to the renderer data asset
Undo.RecordObject(rendererData as UnityEngine.Object, "Add Renderer Feature");
AssetDatabase.AddObjectToAsset(feature, rendererData as UnityEngine.Object);
// Add to the features list via SerializedObject
using (var so = new SerializedObject(rendererData as UnityEngine.Object))
{
var rendererFeaturesProp = so.FindProperty("m_RendererFeatures");
if (rendererFeaturesProp != null)
{
rendererFeaturesProp.arraySize++;
var element = rendererFeaturesProp.GetArrayElementAtIndex(rendererFeaturesProp.arraySize - 1);
element.objectReferenceValue = feature;
so.ApplyModifiedProperties();
}
// Also update the map (m_RendererFeatureMap) if it exists
// Map stores persistent local file IDs, not transient instance IDs
var mapProp = so.FindProperty("m_RendererFeatureMap");
if (mapProp != null)
{
long localId = 0;
AssetDatabase.TryGetGUIDAndLocalFileIdentifier(feature, out _, out localId);
mapProp.arraySize++;
var mapElement = mapProp.GetArrayElementAtIndex(mapProp.arraySize - 1);
mapElement.longValue = localId;
so.ApplyModifiedProperties();
}
}
// Configure initial properties if provided
var propertiesToken = p.GetRaw("properties") as JObject;
if (propertiesToken != null)
ApplyFeatureProperties(feature, propertiesToken);
// Set material if provided (common for FullScreenPass)
string materialPath = p.Get("material");
if (!string.IsNullOrEmpty(materialPath))
TrySetMaterial(feature, materialPath);
EditorUtility.SetDirty(rendererData as UnityEngine.Object);
AssetDatabase.SaveAssets();
return new
{
success = true,
message = $"Added renderer feature '{displayName}' ({featureType.Name}).",
data = new
{
name = displayName,
type = featureType.Name,
instanceId = feature.GetInstanceIDCompat()
}
};
}
// === feature_remove ===
internal static object RemoveFeature(JObject @params)
{
var p = new ToolParams(@params);
int? index = p.GetInt("index");
string name = p.Get("name");
var rendererData = GetRendererData(@params);
if (rendererData == null)
return new ErrorResponse("Could not find URP ScriptableRendererData.");
var featuresProp = rendererData.GetType().GetProperty("rendererFeatures",
BindingFlags.Public | BindingFlags.Instance);
if (featuresProp == null)
return new ErrorResponse("rendererFeatures property not found.");
var featuresList = featuresProp.GetValue(rendererData) as System.Collections.IList;
if (featuresList == null || featuresList.Count == 0)
return new ErrorResponse("No renderer features to remove.");
int targetIndex = ResolveFeatureIndex(featuresList, index, name);
if (targetIndex < 0)
return new ErrorResponse($"Feature not found. Specify 'index' (0-{featuresList.Count - 1}) or 'name'.");
var feature = featuresList[targetIndex] as ScriptableObject;
string featureName = feature?.name ?? "Unknown";
Undo.RecordObject(rendererData as UnityEngine.Object, "Remove Renderer Feature");
// Remove from the list via SerializedObject
using (var so = new SerializedObject(rendererData as UnityEngine.Object))
{
var rendererFeaturesPropSo = so.FindProperty("m_RendererFeatures");
if (rendererFeaturesPropSo != null)
{
rendererFeaturesPropSo.DeleteArrayElementAtIndex(targetIndex);
// SerializedProperty.DeleteArrayElementAtIndex sets to null first for ObjectReference
if (rendererFeaturesPropSo.arraySize > targetIndex)
{
var element = rendererFeaturesPropSo.GetArrayElementAtIndex(targetIndex);
if (element.objectReferenceValue == null)
rendererFeaturesPropSo.DeleteArrayElementAtIndex(targetIndex);
}
so.ApplyModifiedProperties();
}
// Clean up the map
var mapProp = so.FindProperty("m_RendererFeatureMap");
if (mapProp != null && targetIndex < mapProp.arraySize)
{
mapProp.DeleteArrayElementAtIndex(targetIndex);
so.ApplyModifiedProperties();
}
}
// Remove the sub-asset
if (feature != null)
AssetDatabase.RemoveObjectFromAsset(feature);
EditorUtility.SetDirty(rendererData as UnityEngine.Object);
AssetDatabase.SaveAssets();
return new
{
success = true,
message = $"Removed renderer feature '{featureName}' at index {targetIndex}."
};
}
// === feature_configure ===
internal static object ConfigureFeature(JObject @params)
{
var p = new ToolParams(@params);
int? index = p.GetInt("index");
string name = p.Get("name");
var propertiesToken = (p.GetRaw("properties") ?? p.GetRaw("settings")) as JObject;
if (propertiesToken == null)
return new ErrorResponse("'properties' (or 'settings') dict is required.");
var rendererData = GetRendererData(@params);
if (rendererData == null)
return new ErrorResponse("Could not find URP ScriptableRendererData.");
var featuresProp = rendererData.GetType().GetProperty("rendererFeatures",
BindingFlags.Public | BindingFlags.Instance);
var featuresList = featuresProp?.GetValue(rendererData) as System.Collections.IList;
if (featuresList == null || featuresList.Count == 0)
return new ErrorResponse("No renderer features to configure.");
int targetIndex = ResolveFeatureIndex(featuresList, index, name);
if (targetIndex < 0)
return new ErrorResponse($"Feature not found. Specify 'index' (0-{featuresList.Count - 1}) or 'name'.");
var feature = featuresList[targetIndex] as ScriptableObject;
if (feature == null)
return new ErrorResponse($"Feature at index {targetIndex} is null.");
Undo.RecordObject(feature, "Configure Renderer Feature");
var result = ApplyFeatureProperties(feature, propertiesToken);
EditorUtility.SetDirty(feature);
EditorUtility.SetDirty(rendererData as UnityEngine.Object);
AssetDatabase.SaveAssets();
return new
{
success = true,
message = $"Configured '{feature.name}': {result.changed.Count} set, {result.failed.Count} failed.",
data = new { result.changed, result.failed }
};
}
// === feature_toggle ===
internal static object ToggleFeature(JObject @params)
{
var p = new ToolParams(@params);
int? index = p.GetInt("index");
string name = p.Get("name");
bool? active = p.GetBool("active");
var rendererData = GetRendererData(@params);
if (rendererData == null)
return new ErrorResponse("Could not find URP ScriptableRendererData.");
var featuresProp = rendererData.GetType().GetProperty("rendererFeatures",
BindingFlags.Public | BindingFlags.Instance);
var featuresList = featuresProp?.GetValue(rendererData) as System.Collections.IList;
if (featuresList == null || featuresList.Count == 0)
return new ErrorResponse("No renderer features.");
int targetIndex = ResolveFeatureIndex(featuresList, index, name);
if (targetIndex < 0)
return new ErrorResponse($"Feature not found. Specify 'index' or 'name'.");
var feature = featuresList[targetIndex] as ScriptableObject;
if (feature == null)
return new ErrorResponse($"Feature at index {targetIndex} is null.");
// ScriptableRendererFeature.SetActive(bool) is public
var setActiveMethod = feature.GetType().GetMethod("SetActive",
BindingFlags.Public | BindingFlags.Instance);
if (setActiveMethod == null)
return new ErrorResponse("SetActive method not found on feature.");
bool newState = active ?? true;
Undo.RecordObject(feature, "Toggle Renderer Feature");
setActiveMethod.Invoke(feature, new object[] { newState });
EditorUtility.SetDirty(feature);
EditorUtility.SetDirty(rendererData as UnityEngine.Object);
AssetDatabase.SaveAssets();
return new
{
success = true,
message = $"Feature '{feature.name}' {(newState ? "enabled" : "disabled")}."
};
}
// === feature_reorder ===
internal static object ReorderFeatures(JObject @params)
{
var p = new ToolParams(@params);
var orderToken = p.GetRaw("order") as JArray;
if (orderToken == null)
return new ErrorResponse("'order' parameter required (array of indices, e.g. [2, 0, 1]).");
var rendererData = GetRendererData(@params);
if (rendererData == null)
return new ErrorResponse("Could not find URP ScriptableRendererData.");
var featuresProp = rendererData.GetType().GetProperty("rendererFeatures",
BindingFlags.Public | BindingFlags.Instance);
var featuresList = featuresProp?.GetValue(rendererData) as System.Collections.IList;
if (featuresList == null || featuresList.Count == 0)
return new ErrorResponse("No renderer features to reorder.");
var newOrder = orderToken.Select(t => (int)t).ToList();
if (newOrder.Count != featuresList.Count)
return new ErrorResponse(
$"Order array length ({newOrder.Count}) must match feature count ({featuresList.Count}).");
// Validate all indices are present
var sorted = newOrder.OrderBy(x => x).ToList();
for (int i = 0; i < sorted.Count; i++)
{
if (sorted[i] != i)
return new ErrorResponse("Order array must contain each index exactly once (0 to N-1).");
}
Undo.RecordObject(rendererData as UnityEngine.Object, "Reorder Renderer Features");
using (var so = new SerializedObject(rendererData as UnityEngine.Object))
{
var rendererFeaturesPropSo = so.FindProperty("m_RendererFeatures");
if (rendererFeaturesPropSo == null)
return new ErrorResponse("m_RendererFeatures property not found.");
// Read current features
var current = new UnityEngine.Object[featuresList.Count];
for (int i = 0; i < featuresList.Count; i++)
current[i] = rendererFeaturesPropSo.GetArrayElementAtIndex(i).objectReferenceValue;
// Apply new order
for (int i = 0; i < newOrder.Count; i++)
rendererFeaturesPropSo.GetArrayElementAtIndex(i).objectReferenceValue = current[newOrder[i]];
// Also reorder the feature map to keep it in sync
var mapProp = so.FindProperty("m_RendererFeatureMap");
if (mapProp != null && mapProp.arraySize == featuresList.Count)
{
var currentMap = new long[featuresList.Count];
for (int i = 0; i < featuresList.Count; i++)
currentMap[i] = mapProp.GetArrayElementAtIndex(i).longValue;
for (int i = 0; i < newOrder.Count; i++)
mapProp.GetArrayElementAtIndex(i).longValue = currentMap[newOrder[i]];
}
so.ApplyModifiedProperties();
}
EditorUtility.SetDirty(rendererData as UnityEngine.Object);
AssetDatabase.SaveAssets();
return new
{
success = true,
message = $"Reordered {featuresList.Count} renderer features."
};
}
// ==================== Helpers ====================
private static object GetRendererData(JObject @params)
{
EnsureTypes();
if (_universalRenderPipelineAssetType == null || _scriptableRendererDataType == null)
return null;
var pipelineAsset = GraphicsSettings.currentRenderPipeline;
if (pipelineAsset == null || !_universalRenderPipelineAssetType.IsInstanceOfType(pipelineAsset))
return null;
var p = new ToolParams(@params);
int rendererIndex = p.GetInt("renderer_index") ?? -1;
// Get renderer data from the URP asset
// Try scriptableRendererData property or GetRenderer method
if (rendererIndex >= 0)
{
// Use SerializedObject to get specific renderer
using (var so = new SerializedObject(pipelineAsset))
{
var renderersProp = so.FindProperty("m_RendererDataList");
if (renderersProp == null || rendererIndex >= renderersProp.arraySize)
return null;
var element = renderersProp.GetArrayElementAtIndex(rendererIndex);
return element.objectReferenceValue;
}
}
// Default: get the active renderer (index from m_DefaultRendererIndex)
using (var so = new SerializedObject(pipelineAsset))
{
var defaultIndex = so.FindProperty("m_DefaultRendererIndex");
int idx = defaultIndex != null ? defaultIndex.intValue : 0;
var renderersProp = so.FindProperty("m_RendererDataList");
if (renderersProp != null && idx < renderersProp.arraySize)
{
var element = renderersProp.GetArrayElementAtIndex(idx);
return element.objectReferenceValue;
}
}
return null;
}
private static Type ResolveFeatureType(string typeName)
{
EnsureTypes();
if (_scriptableRendererFeatureType == null) return null;
var derivedTypes = TypeCache.GetTypesDerivedFrom(_scriptableRendererFeatureType);
foreach (var t in derivedTypes)
{
if (t.IsAbstract) continue;
if (string.Equals(t.Name, typeName, StringComparison.OrdinalIgnoreCase))
return t;
}
// Try partial match (e.g., "FullScreenPass" matches "FullScreenPassRendererFeature")
foreach (var t in derivedTypes)
{
if (t.IsAbstract) continue;
if (t.Name.StartsWith(typeName, StringComparison.OrdinalIgnoreCase))
return t;
}
return null;
}
private static List<Type> GetAvailableFeatureTypes()
{
EnsureTypes();
if (_scriptableRendererFeatureType == null) return new List<Type>();
return TypeCache.GetTypesDerivedFrom(_scriptableRendererFeatureType)
.Where(t => !t.IsAbstract && !t.IsGenericType)
.OrderBy(t => t.Name)
.ToList();
}
private static int ResolveFeatureIndex(System.Collections.IList featuresList, int? index, string name)
{
if (index.HasValue && index.Value >= 0 && index.Value < featuresList.Count)
return index.Value;
if (!string.IsNullOrEmpty(name))
{
for (int i = 0; i < featuresList.Count; i++)
{
var feature = featuresList[i] as ScriptableObject;
if (feature == null) continue;
if (string.Equals(feature.name, name, StringComparison.OrdinalIgnoreCase) ||
string.Equals(feature.GetType().Name, name, StringComparison.OrdinalIgnoreCase))
return i;
}
}
return -1;
}
private static Dictionary<string, object> GetFeatureProperties(ScriptableObject feature)
{
var props = new Dictionary<string, object>();
using (var so = new SerializedObject(feature))
{
var iterator = so.GetIterator();
if (iterator.NextVisible(true)) // Enter children
{
do
{
// Skip Unity internal properties
if (iterator.name == "m_Script" || iterator.name == "m_ObjectHideFlags" || iterator.name == "m_Name")
continue;
props[iterator.name] = GraphicsHelpers.ReadSerializedValue(iterator);
} while (iterator.NextVisible(false));
}
}
return props;
}
private static (List<string> changed, List<string> failed) ApplyFeatureProperties(
ScriptableObject feature, JObject propertiesToken)
{
var changed = new List<string>();
var failed = new List<string>();
using (var so = new SerializedObject(feature))
{
foreach (var prop in propertiesToken.Properties())
{
var sProp = so.FindProperty(prop.Name);
if (sProp != null)
{
if (GraphicsHelpers.SetSerializedValue(sProp, prop.Value))
changed.Add(prop.Name);
else
failed.Add(prop.Name);
}
else
{
// Try nested: "settings.fieldName"
string nested = $"settings.{prop.Name}";
sProp = so.FindProperty(nested);
if (sProp != null && GraphicsHelpers.SetSerializedValue(sProp, prop.Value))
changed.Add(prop.Name);
else
failed.Add(prop.Name);
}
}
so.ApplyModifiedProperties();
}
return (changed, failed);
}
private static void TrySetMaterial(ScriptableObject feature, string materialPath)
{
var mat = AssetDatabase.LoadAssetAtPath<Material>(materialPath);
if (mat == null) return;
using (var so = new SerializedObject(feature))
{
// FullScreenPassRendererFeature uses "m_PassMaterial" or "passMaterial"
var matProp = so.FindProperty("m_PassMaterial") ?? so.FindProperty("passMaterial");
if (matProp != null && matProp.propertyType == SerializedPropertyType.ObjectReference)
{
matProp.objectReferenceValue = mat;
so.ApplyModifiedProperties();
}
}
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 5a75368a2f6b478fbaa88a36c677b5af
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,171 @@
using System;
using System.Collections.Generic;
using System.Linq;
using MCPForUnity.Editor.Helpers;
using Newtonsoft.Json.Linq;
using UnityEditor;
using UnityEngine;
using Unity.Profiling;
using Unity.Profiling.LowLevel.Unsafe;
using UnityEngine.Profiling;
using UProfiler = UnityEngine.Profiling.Profiler;
namespace MCPForUnity.Editor.Tools.Graphics
{
internal static class RenderingStatsOps
{
private static readonly (string counterName, string jsonKey)[] COUNTER_MAP = new[]
{
("Draw Calls Count", "draw_calls"),
("Batches Count", "batches"),
("SetPass Calls Count", "set_pass_calls"),
("Triangles Count", "triangles"),
("Vertices Count", "vertices"),
("Dynamic Batches Count", "dynamic_batches"),
("Dynamic Batched Draw Calls Count", "dynamic_batched_draw_calls"),
("Static Batches Count", "static_batches"),
("Static Batched Draw Calls Count", "static_batched_draw_calls"),
("Instanced Batches Count", "instanced_batches"),
("Instanced Batched Draw Calls Count", "instanced_batched_draw_calls"),
("Shadow Casters Count", "shadow_casters"),
("Render Textures Count", "render_textures"),
("Render Textures Bytes", "render_textures_bytes"),
("Used Textures Count", "used_textures"),
("Used Textures Bytes", "used_textures_bytes"),
("Render Textures Changes Count", "render_target_changes"),
("Visible Skinned Meshes Count", "visible_skinned_meshes"),
};
// === stats_get ===
internal static object GetStats(JObject @params)
{
var stats = new Dictionary<string, object>();
foreach (var (counterName, jsonKey) in COUNTER_MAP)
{
using var recorder = ProfilerRecorder.StartNew(ProfilerCategory.Render, counterName);
stats[jsonKey] = recorder.Valid ? recorder.CurrentValue : 0;
}
return new
{
success = true,
message = "Rendering stats captured.",
data = stats
};
}
// === stats_list_counters ===
internal static object ListCounters(JObject @params)
{
var p = new ToolParams(@params);
string categoryName = p.Get("category");
// Default to "Render" category to avoid massive payloads (all categories = 300K+ chars)
ProfilerCategory category = ProfilerCategory.Render;
if (!string.IsNullOrEmpty(categoryName))
{
category = TryResolveCategory(categoryName);
}
var allHandles = new List<ProfilerRecorderHandle>();
ProfilerRecorderHandle.GetAvailable(allHandles);
var counters = allHandles
.Select(h => ProfilerRecorderHandle.GetDescription(h))
.Where(d => string.Equals(d.Category.Name, category.Name, StringComparison.OrdinalIgnoreCase))
.Select(d => new
{
name = d.Name,
category = d.Category.Name,
unit = d.UnitType.ToString()
})
.OrderBy(c => c.name).ToList();
return new
{
success = true,
message = $"Found {counters.Count} counters in category '{category.Name}'.",
data = new { counters }
};
}
// === stats_set_scene_debug_mode ===
internal static object SetSceneDebugMode(JObject @params)
{
var p = new ToolParams(@params);
string modeName = p.Get("mode");
if (string.IsNullOrEmpty(modeName))
{
var validModes = string.Join(", ", Enum.GetNames(typeof(DrawCameraMode)).Take(20));
return new ErrorResponse(
$"'mode' parameter required. Options: {validModes}");
}
if (!Enum.TryParse<DrawCameraMode>(modeName, true, out var drawMode))
{
var validModes = string.Join(", ", Enum.GetNames(typeof(DrawCameraMode)).Take(20));
return new ErrorResponse($"Unknown mode '{modeName}'. Valid: {validModes}");
}
var sceneView = SceneView.lastActiveSceneView;
if (sceneView == null)
return new ErrorResponse("No active Scene View found.");
sceneView.cameraMode = SceneView.GetBuiltinCameraMode(drawMode);
sceneView.Repaint();
return new
{
success = true,
message = $"Scene debug mode set to '{drawMode}'."
};
}
// === stats_get_memory ===
internal static object GetMemory(JObject @params)
{
var data = new Dictionary<string, object>
{
["totalAllocatedMB"] = Math.Round(UProfiler.GetTotalAllocatedMemoryLong() / (1024.0 * 1024.0), 2),
["totalReservedMB"] = Math.Round(UProfiler.GetTotalReservedMemoryLong() / (1024.0 * 1024.0), 2),
["totalUnusedReservedMB"] = Math.Round(UProfiler.GetTotalUnusedReservedMemoryLong() / (1024.0 * 1024.0), 2),
["monoUsedMB"] = Math.Round(UProfiler.GetMonoUsedSizeLong() / (1024.0 * 1024.0), 2),
["monoHeapMB"] = Math.Round(UProfiler.GetMonoHeapSizeLong() / (1024.0 * 1024.0), 2),
["graphicsDriverMB"] = Math.Round(UProfiler.GetAllocatedMemoryForGraphicsDriver() / (1024.0 * 1024.0), 2),
};
return new
{
success = true,
message = "Memory stats captured.",
data
};
}
// --- Helper: Try to resolve a ProfilerCategory by name ---
private static ProfilerCategory TryResolveCategory(string name)
{
// ProfilerCategory has static properties for well-known categories
switch (name.ToLowerInvariant())
{
case "render": return ProfilerCategory.Render;
case "scripts": return ProfilerCategory.Scripts;
case "memory": return ProfilerCategory.Memory;
case "physics": return ProfilerCategory.Physics;
case "animation": return ProfilerCategory.Animation;
case "audio": return ProfilerCategory.Audio;
case "lighting": return ProfilerCategory.Lighting;
case "network": return ProfilerCategory.Network;
case "gui": return ProfilerCategory.Gui;
case "ai": return ProfilerCategory.Ai;
case "video": return ProfilerCategory.Video;
case "loading": return ProfilerCategory.Loading;
case "input": return ProfilerCategory.Input;
case "vr": return ProfilerCategory.Vr;
case "internal": return ProfilerCategory.Internal;
default: return ProfilerCategory.Render;
}
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: c6a02f2bb19e450a9ddca18ee8d211bf
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,497 @@
using System;
using System.Collections.Generic;
using Newtonsoft.Json.Linq;
using MCPForUnity.Editor.Helpers;
using UnityEditor;
using UnityEngine;
using UnityEngine.Rendering;
using MCPForUnity.Runtime.Helpers;
namespace MCPForUnity.Editor.Tools.Graphics
{
internal static class SkyboxOps
{
static Texture CustomReflectionTexture
{
get =>
#if UNITY_2022_1_OR_NEWER
RenderSettings.customReflectionTexture;
#else
RenderSettings.customReflection;
#endif
set {
#if UNITY_2022_1_OR_NEWER
RenderSettings.customReflectionTexture = value;
#else
RenderSettings.customReflection = value;
#endif
}
}
// ---------------------------------------------------------------
// skybox_get — read all environment settings
// ---------------------------------------------------------------
public static object GetEnvironment(JObject @params)
{
var skyMat = RenderSettings.skybox;
var sun = RenderSettings.sun;
object matInfo = null;
if (skyMat != null)
{
var props = new List<object>();
int count = skyMat.shader.GetPropertyCount();
for (int i = 0; i < count; i++)
{
string propName = skyMat.shader.GetPropertyName(i);
var propType = skyMat.shader.GetPropertyType(i);
object val = ReadMaterialProperty(skyMat, propName, propType);
props.Add(new { name = propName, type = propType.ToString(), value = val });
}
matInfo = new
{
name = skyMat.name,
shader = skyMat.shader.name,
path = AssetDatabase.GetAssetPath(skyMat),
properties = props
};
}
return new
{
success = true,
message = "Environment settings retrieved.",
data = new
{
skybox = matInfo,
ambient = new
{
mode = RenderSettings.ambientMode.ToString(),
skyColor = ColorToArray(RenderSettings.ambientSkyColor),
equatorColor = ColorToArray(RenderSettings.ambientEquatorColor),
groundColor = ColorToArray(RenderSettings.ambientGroundColor),
ambientLight = ColorToArray(RenderSettings.ambientLight),
intensity = RenderSettings.ambientIntensity
},
fog = new
{
enabled = RenderSettings.fog,
mode = RenderSettings.fogMode.ToString(),
color = ColorToArray(RenderSettings.fogColor),
density = RenderSettings.fogDensity,
startDistance = RenderSettings.fogStartDistance,
endDistance = RenderSettings.fogEndDistance
},
reflection = new
{
intensity = RenderSettings.reflectionIntensity,
bounces = RenderSettings.reflectionBounces,
mode = RenderSettings.defaultReflectionMode.ToString(),
resolution = RenderSettings.defaultReflectionResolution,
customCubemap = CustomReflectionTexture != null
? AssetDatabase.GetAssetPath(CustomReflectionTexture)
: null
},
sun = sun != null
? (object)new { name = sun.gameObject.name, instanceID = sun.gameObject.GetInstanceIDCompat() }
: null,
subtractiveShadowColor = ColorToArray(RenderSettings.subtractiveShadowColor)
}
};
}
// ---------------------------------------------------------------
// skybox_set_material — assign a skybox material
// ---------------------------------------------------------------
public static object SetMaterial(JObject @params)
{
var p = new ToolParams(@params);
string materialPath = p.Get("material") ?? p.Get("path") ?? p.Get("material_path");
if (string.IsNullOrEmpty(materialPath))
return new ErrorResponse("'material' (asset path) is required.");
var mat = AssetDatabase.LoadAssetAtPath<Material>(materialPath);
if (mat == null)
return new ErrorResponse($"Material not found at '{materialPath}'.");
RenderSettings.skybox = mat;
MarkSceneDirty();
return new
{
success = true,
message = $"Skybox set to '{mat.name}' (shader: {mat.shader.name}).",
data = new
{
material = mat.name,
shader = mat.shader.name,
path = materialPath
}
};
}
// ---------------------------------------------------------------
// skybox_set_properties — modify properties on the current skybox material
// ---------------------------------------------------------------
public static object SetMaterialProperties(JObject @params)
{
var p = new ToolParams(@params);
var skyMat = RenderSettings.skybox;
if (skyMat == null)
return new ErrorResponse("No skybox material is set.");
var propsRaw = p.GetRaw("properties") ?? p.GetRaw("parameters");
if (propsRaw == null || propsRaw.Type != JTokenType.Object)
return new ErrorResponse("'properties' dict is required.");
var set = new List<string>();
var failed = new List<string>();
foreach (var kvp in (JObject)propsRaw)
{
string propName = kvp.Key;
if (!skyMat.HasProperty(propName))
{
string altName = "_" + propName;
if (skyMat.HasProperty(altName))
propName = altName;
else
{
failed.Add(kvp.Key);
continue;
}
}
if (SetMaterialProperty(skyMat, propName, kvp.Value))
set.Add(kvp.Key);
else
failed.Add(kvp.Key);
}
EditorUtility.SetDirty(skyMat);
AssetDatabase.SaveAssets();
MarkSceneDirty();
return new
{
success = true,
message = $"Set {set.Count} property(ies) on skybox material '{skyMat.name}'.",
data = new { material = skyMat.name, set, failed }
};
}
// ---------------------------------------------------------------
// skybox_set_ambient — set ambient lighting mode and colors
// ---------------------------------------------------------------
public static object SetAmbient(JObject @params)
{
var p = new ToolParams(@params);
string modeStr = p.Get("ambient_mode") ?? p.Get("mode");
if (!string.IsNullOrEmpty(modeStr))
{
if (Enum.TryParse<AmbientMode>(modeStr, true, out var mode))
RenderSettings.ambientMode = mode;
else
return new ErrorResponse(
$"Invalid ambient mode '{modeStr}'. Valid: Skybox, Trilight, Flat, Custom.");
}
var skyColor = ParseColorToken(p.GetRaw("color") ?? p.GetRaw("sky_color"));
if (skyColor.HasValue)
RenderSettings.ambientSkyColor = skyColor.Value;
var equatorColor = ParseColorToken(p.GetRaw("equator_color"));
if (equatorColor.HasValue)
RenderSettings.ambientEquatorColor = equatorColor.Value;
var groundColor = ParseColorToken(p.GetRaw("ground_color"));
if (groundColor.HasValue)
RenderSettings.ambientGroundColor = groundColor.Value;
var intensity = p.GetFloat("intensity");
if (intensity.HasValue)
RenderSettings.ambientIntensity = intensity.Value;
MarkSceneDirty();
return new
{
success = true,
message = $"Ambient lighting updated (mode: {RenderSettings.ambientMode}).",
data = new
{
mode = RenderSettings.ambientMode.ToString(),
skyColor = ColorToArray(RenderSettings.ambientSkyColor),
equatorColor = ColorToArray(RenderSettings.ambientEquatorColor),
groundColor = ColorToArray(RenderSettings.ambientGroundColor),
intensity = RenderSettings.ambientIntensity
}
};
}
// ---------------------------------------------------------------
// skybox_set_fog — enable/configure fog
// ---------------------------------------------------------------
public static object SetFog(JObject @params)
{
var p = new ToolParams(@params);
var enabledToken = p.GetRaw("fog_enabled") ?? p.GetRaw("enabled");
if (enabledToken != null && enabledToken.Type != JTokenType.Null)
RenderSettings.fog = ParamCoercion.CoerceBool(enabledToken, RenderSettings.fog);
string modeStr = p.Get("fog_mode") ?? p.Get("mode");
if (!string.IsNullOrEmpty(modeStr))
{
if (Enum.TryParse<FogMode>(modeStr, true, out var fogMode))
RenderSettings.fogMode = fogMode;
else
return new ErrorResponse(
$"Invalid fog mode '{modeStr}'. Valid: Linear, Exponential, ExponentialSquared.");
}
var fogColor = ParseColorToken(p.GetRaw("fog_color") ?? p.GetRaw("color"));
if (fogColor.HasValue)
RenderSettings.fogColor = fogColor.Value;
var density = p.GetFloat("fog_density") ?? p.GetFloat("density");
if (density.HasValue)
RenderSettings.fogDensity = density.Value;
var start = p.GetFloat("fog_start") ?? p.GetFloat("start");
if (start.HasValue)
RenderSettings.fogStartDistance = start.Value;
var end = p.GetFloat("fog_end") ?? p.GetFloat("end");
if (end.HasValue)
RenderSettings.fogEndDistance = end.Value;
MarkSceneDirty();
return new
{
success = true,
message = $"Fog settings updated (enabled: {RenderSettings.fog}, mode: {RenderSettings.fogMode}).",
data = new
{
enabled = RenderSettings.fog,
mode = RenderSettings.fogMode.ToString(),
color = ColorToArray(RenderSettings.fogColor),
density = RenderSettings.fogDensity,
startDistance = RenderSettings.fogStartDistance,
endDistance = RenderSettings.fogEndDistance
}
};
}
// ---------------------------------------------------------------
// skybox_set_reflection — configure environment reflections
// ---------------------------------------------------------------
public static object SetReflection(JObject @params)
{
var p = new ToolParams(@params);
var intensity = p.GetFloat("intensity");
if (intensity.HasValue)
RenderSettings.reflectionIntensity = intensity.Value;
var bounces = p.GetInt("bounces");
if (bounces.HasValue)
RenderSettings.reflectionBounces = bounces.Value;
string modeStr = p.Get("reflection_mode") ?? p.Get("mode");
if (!string.IsNullOrEmpty(modeStr))
{
if (Enum.TryParse<DefaultReflectionMode>(modeStr, true, out var mode))
RenderSettings.defaultReflectionMode = mode;
else
return new ErrorResponse(
$"Invalid reflection mode '{modeStr}'. Valid: Skybox, Custom.");
}
var resolution = p.GetInt("resolution");
if (resolution.HasValue)
RenderSettings.defaultReflectionResolution = resolution.Value;
string cubemapPath = p.Get("path") ?? p.Get("cubemap_path");
if (!string.IsNullOrEmpty(cubemapPath))
{
var cubemap = AssetDatabase.LoadAssetAtPath<Texture>(cubemapPath);
if (cubemap != null)
CustomReflectionTexture = cubemap;
else
return new ErrorResponse($"Cubemap not found at '{cubemapPath}'.");
}
MarkSceneDirty();
return new
{
success = true,
message = $"Reflection settings updated (intensity: {RenderSettings.reflectionIntensity}, bounces: {RenderSettings.reflectionBounces}).",
data = new
{
intensity = RenderSettings.reflectionIntensity,
bounces = RenderSettings.reflectionBounces,
mode = RenderSettings.defaultReflectionMode.ToString(),
resolution = RenderSettings.defaultReflectionResolution,
customCubemap = CustomReflectionTexture != null
? AssetDatabase.GetAssetPath(CustomReflectionTexture)
: null
}
};
}
// ---------------------------------------------------------------
// skybox_set_sun — set the sun source light
// ---------------------------------------------------------------
public static object SetSun(JObject @params)
{
var p = new ToolParams(@params);
string target = p.Get("target") ?? p.Get("name");
if (string.IsNullOrEmpty(target))
return new ErrorResponse("'target' (light GameObject name or instance ID) is required.");
GameObject go = null;
if (int.TryParse(target, out int instanceId))
go = GameObjectLookup.ResolveInstanceID(instanceId) as GameObject;
if (go == null)
go = GameObject.Find(target);
if (go == null)
return new ErrorResponse($"GameObject '{target}' not found.");
var light = go.GetComponent<Light>();
if (light == null)
return new ErrorResponse($"'{go.name}' does not have a Light component.");
RenderSettings.sun = light;
MarkSceneDirty();
return new
{
success = true,
message = $"Sun source set to '{go.name}'.",
data = new
{
name = go.name,
instanceID = go.GetInstanceIDCompat(),
lightType = light.type.ToString()
}
};
}
// ---------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------
private static float[] ColorToArray(Color c)
{
return new[] { c.r, c.g, c.b, c.a };
}
private static Color ArrayToColor(float[] arr)
{
return new Color(
arr[0], arr[1], arr[2],
arr.Length >= 4 ? arr[3] : 1f);
}
private static Color? ParseColorToken(JToken token)
{
if (token == null || token.Type == JTokenType.Null) return null;
if (token is JArray arr && arr.Count >= 3)
{
return new Color(
(float)arr[0], (float)arr[1], (float)arr[2],
arr.Count >= 4 ? (float)arr[3] : 1f);
}
return null;
}
private static object ReadMaterialProperty(Material mat, string propName, ShaderPropertyType propType)
{
switch (propType)
{
case ShaderPropertyType.Color:
return ColorToArray(mat.GetColor(propName));
case ShaderPropertyType.Float:
case ShaderPropertyType.Range:
return mat.GetFloat(propName);
case ShaderPropertyType.Int:
return mat.GetInt(propName);
case ShaderPropertyType.Vector:
var v = mat.GetVector(propName);
return new[] { v.x, v.y, v.z, v.w };
case ShaderPropertyType.Texture:
var tex = mat.GetTexture(propName);
return tex != null ? AssetDatabase.GetAssetPath(tex) : null;
default:
return null;
}
}
private static bool SetMaterialProperty(Material mat, string propName, JToken value)
{
int propIdx = mat.shader.FindPropertyIndex(propName);
if (propIdx < 0) return false;
var propType = mat.shader.GetPropertyType(propIdx);
try
{
switch (propType)
{
case ShaderPropertyType.Color:
if (value is JArray colorArr && colorArr.Count >= 3)
{
mat.SetColor(propName, new Color(
(float)colorArr[0], (float)colorArr[1], (float)colorArr[2],
colorArr.Count >= 4 ? (float)colorArr[3] : 1f));
return true;
}
return false;
case ShaderPropertyType.Float:
case ShaderPropertyType.Range:
mat.SetFloat(propName, (float)value);
return true;
case ShaderPropertyType.Int:
mat.SetInt(propName, (int)value);
return true;
case ShaderPropertyType.Vector:
if (value is JArray vecArr && vecArr.Count >= 2)
{
mat.SetVector(propName, new Vector4(
(float)vecArr[0], (float)vecArr[1],
vecArr.Count >= 3 ? (float)vecArr[2] : 0f,
vecArr.Count >= 4 ? (float)vecArr[3] : 0f));
return true;
}
return false;
case ShaderPropertyType.Texture:
if (value.Type == JTokenType.String)
{
var tex = AssetDatabase.LoadAssetAtPath<Texture>(value.ToString());
if (tex != null) { mat.SetTexture(propName, tex); return true; }
}
else if (value.Type == JTokenType.Null)
{
mat.SetTexture(propName, null);
return true;
}
return false;
default:
return false;
}
}
catch
{
return false;
}
}
private static void MarkSceneDirty()
{
UnityEditor.SceneManagement.EditorSceneManager.MarkSceneDirty(
UnityEditor.SceneManagement.EditorSceneManager.GetActiveScene());
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 93b73ba8237dee84088417864958084a
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,705 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using MCPForUnity.Editor.Helpers;
using Newtonsoft.Json.Linq;
using UnityEditor;
using UnityEngine;
using MCPForUnity.Runtime.Helpers;
namespace MCPForUnity.Editor.Tools.Graphics
{
internal static class VolumeOps
{
// === volume_create ===
// Params: name (string), is_global (bool, default true), weight (float, default 1),
// priority (float, default 0), profile_path (string, optional - path to save VolumeProfile asset),
// effects (array of {type, ...params}, optional - effects to add immediately)
internal static object CreateVolume(JObject @params)
{
var p = new ToolParams(@params);
string name = p.Get("name") ?? "Volume";
bool isGlobal = p.GetBool("is_global", true);
float weight = p.GetFloat("weight") ?? 1.0f;
float priority = p.GetFloat("priority") ?? 0f;
string profilePath = p.Get("profile_path");
if (!string.IsNullOrEmpty(profilePath))
{
if (!profilePath.StartsWith("Assets/", StringComparison.OrdinalIgnoreCase) &&
!profilePath.StartsWith("Assets\\", StringComparison.OrdinalIgnoreCase))
profilePath = "Assets/" + profilePath;
if (!profilePath.EndsWith(".asset", StringComparison.OrdinalIgnoreCase))
profilePath += ".asset";
}
var go = new GameObject(name);
Undo.RegisterCreatedObjectUndo(go, $"Create Volume '{name}'");
// Add Volume component via reflection
var volumeComp = go.AddComponent(GraphicsHelpers.VolumeType);
// Set properties via reflection
SetProperty(volumeComp, "isGlobal", isGlobal);
SetProperty(volumeComp, "weight", weight);
SetProperty(volumeComp, "priority", priority);
// Create or load VolumeProfile
object profile;
if (!string.IsNullOrEmpty(profilePath))
{
// Load existing or create new profile asset
profile = AssetDatabase.LoadAssetAtPath(profilePath, GraphicsHelpers.VolumeProfileType);
if (profile == null)
{
profile = ScriptableObject.CreateInstance(GraphicsHelpers.VolumeProfileType);
// Ensure directory exists
var dir = System.IO.Path.GetDirectoryName(profilePath);
if (!string.IsNullOrEmpty(dir) && !System.IO.Directory.Exists(dir))
System.IO.Directory.CreateDirectory(dir);
AssetDatabase.CreateAsset((UnityEngine.Object)profile, profilePath);
}
}
else
{
// Create embedded profile (not saved as asset)
profile = ScriptableObject.CreateInstance(GraphicsHelpers.VolumeProfileType);
}
// Assign profile (sharedProfile is a public field, handled by SetProperty's field fallback)
SetProperty(volumeComp, "sharedProfile", profile);
// Add initial effects if provided
var effectsToken = p.GetRaw("effects") as JArray;
var addedEffects = new List<string>();
if (effectsToken != null)
{
foreach (var effectDef in effectsToken)
{
if (effectDef is JObject effectObj)
{
string effectType = ParamCoercion.CoerceString(effectObj["type"], null);
if (string.IsNullOrEmpty(effectType)) continue;
var type = GraphicsHelpers.ResolveVolumeComponentType(effectType);
if (type == null) continue;
// profile.Add(type, true)
var addMethod = GraphicsHelpers.VolumeProfileType.GetMethod("Add",
new[] { typeof(Type), typeof(bool) });
if (addMethod != null)
{
var component = addMethod.Invoke(profile, new object[] { type, true });
if (component != null)
{
// Set parameters — support both nested {"parameters": {...}} and flat fields
var paramObj = effectObj["parameters"] as JObject;
if (paramObj != null)
{
foreach (var pp in paramObj.Properties())
SetVolumeParameter(component, pp.Name, pp.Value);
}
else
{
foreach (var prop in effectObj.Properties())
{
if (prop.Name == "type") continue;
SetVolumeParameter(component, prop.Name, prop.Value);
}
}
addedEffects.Add(effectType);
}
}
}
}
if (profile is UnityEngine.Object profileObj)
EditorUtility.SetDirty(profileObj);
}
GraphicsHelpers.MarkDirty(volumeComp);
return new
{
success = true,
message = $"Created {(isGlobal ? "global" : "local")} Volume '{name}'" +
(addedEffects.Count > 0 ? $" with effects: {string.Join(", ", addedEffects)}" : ""),
data = new
{
instanceID = go.GetInstanceIDCompat(),
isGlobal,
weight,
priority,
profilePath = profilePath ?? "(embedded)",
effects = addedEffects
}
};
}
// === volume_add_effect ===
// Params: target (string/int), effect (string - type name like "Bloom")
internal static object AddEffect(JObject @params)
{
var p = new ToolParams(@params);
string effectName = p.Get("effect");
if (string.IsNullOrEmpty(effectName))
return new ErrorResponse("'effect' parameter is required (e.g., 'Bloom', 'Vignette').");
var volume = GraphicsHelpers.FindVolume(@params);
if (volume == null)
return new ErrorResponse("Volume not found. Specify 'target' (name or instance ID).");
var effectType = GraphicsHelpers.ResolveVolumeComponentType(effectName);
if (effectType == null)
{
var available = GraphicsHelpers.GetAvailableEffectTypes()
.Select(t => t.Name).ToList();
return new ErrorResponse(
$"Effect type '{effectName}' not found. Available: {string.Join(", ", available.Take(20))}");
}
var profile = GetProperty(volume, "sharedProfile");
if (profile == null)
return new ErrorResponse("Volume has no profile assigned.");
// Check if effect already exists
var components = GetProperty(profile, "components") as System.Collections.IList;
if (components != null)
{
foreach (var comp in components)
{
if (comp != null && comp.GetType() == effectType)
return new ErrorResponse($"Effect '{effectName}' already exists on this Volume. Use volume_set_effect to modify it.");
}
}
// profile.Add(effectType, true) -- 'true' means override all params
var addMethod = GraphicsHelpers.VolumeProfileType.GetMethod("Add",
new[] { typeof(Type), typeof(bool) });
if (addMethod == null)
return new ErrorResponse("Could not find VolumeProfile.Add method.");
var component = addMethod.Invoke(profile, new object[] { effectType, true });
if (component == null)
return new ErrorResponse($"Failed to add effect '{effectName}'.");
if (profile is UnityEngine.Object profileObj)
EditorUtility.SetDirty(profileObj);
GraphicsHelpers.MarkDirty(volume);
return new
{
success = true,
message = $"Added '{effectName}' to Volume '{(volume as Component)?.gameObject.name}'.",
data = new { effect = effectName, volumeInstanceID = (volume as Component)?.gameObject.GetInstanceIDCompat() }
};
}
// === volume_set_effect ===
// Params: target (string/int), effect (string), parameters (dict of field->value)
internal static object SetEffect(JObject @params)
{
var p = new ToolParams(@params);
string effectName = p.Get("effect");
if (string.IsNullOrEmpty(effectName))
return new ErrorResponse("'effect' parameter is required.");
var volume = GraphicsHelpers.FindVolume(@params);
if (volume == null)
return new ErrorResponse("Volume not found. Specify 'target'.");
var profile = GetProperty(volume, "sharedProfile");
if (profile == null)
return new ErrorResponse("Volume has no profile assigned.");
var effectType = GraphicsHelpers.ResolveVolumeComponentType(effectName);
if (effectType == null)
return new ErrorResponse($"Effect type '{effectName}' not found.");
// Find the effect component in the profile
var components = GetProperty(profile, "components") as System.Collections.IList;
if (components == null)
return new ErrorResponse("Could not read profile components.");
object targetComponent = null;
foreach (var comp in components)
{
if (comp != null && comp.GetType() == effectType)
{
targetComponent = comp;
break;
}
}
if (targetComponent == null)
return new ErrorResponse($"Effect '{effectName}' not found on this Volume. Use volume_add_effect first.");
// Set parameters
var parameters = p.GetRaw("parameters") as JObject;
if (parameters == null)
return new ErrorResponse("'parameters' dict is required.");
var setParams = new List<string>();
var failedParams = new List<string>();
foreach (var prop in parameters.Properties())
{
if (SetVolumeParameter(targetComponent, prop.Name, prop.Value))
setParams.Add(prop.Name);
else
failedParams.Add(prop.Name);
}
if (profile is UnityEngine.Object profileObj)
EditorUtility.SetDirty(profileObj);
var msg = $"Set {setParams.Count} parameter(s) on '{effectName}'";
if (failedParams.Count > 0)
msg += $". Failed: {string.Join(", ", failedParams)}";
return new
{
success = true,
message = msg,
data = new { effect = effectName, set = setParams, failed = failedParams }
};
}
// === volume_remove_effect ===
// Params: target, effect
internal static object RemoveEffect(JObject @params)
{
var p = new ToolParams(@params);
string effectName = p.Get("effect");
if (string.IsNullOrEmpty(effectName))
return new ErrorResponse("'effect' parameter is required.");
var volume = GraphicsHelpers.FindVolume(@params);
if (volume == null)
return new ErrorResponse("Volume not found.");
var effectType = GraphicsHelpers.ResolveVolumeComponentType(effectName);
if (effectType == null)
return new ErrorResponse($"Effect type '{effectName}' not found.");
var profile = GetProperty(volume, "sharedProfile");
if (profile == null)
return new ErrorResponse("Volume has no profile.");
// Check if effect exists before removing
bool found = false;
var components = GetProperty(profile, "components") as System.Collections.IList;
if (components != null)
{
foreach (var comp in components)
{
if (comp != null && comp.GetType() == effectType)
{
found = true;
break;
}
}
}
if (!found)
return new ErrorResponse($"Effect '{effectName}' not found on this Volume.");
var removeMethod = GraphicsHelpers.VolumeProfileType.GetMethod("Remove",
new[] { typeof(Type) });
if (removeMethod == null)
return new ErrorResponse("Could not find VolumeProfile.Remove method.");
removeMethod.Invoke(profile, new object[] { effectType });
if (profile is UnityEngine.Object profileObj)
EditorUtility.SetDirty(profileObj);
GraphicsHelpers.MarkDirty(volume);
return new
{
success = true,
message = $"Removed '{effectName}' from Volume.",
data = new { effect = effectName }
};
}
// === volume_get_info ===
// Params: target (optional -- if omitted, returns info for all volumes)
internal static object GetInfo(JObject @params)
{
var volume = GraphicsHelpers.FindVolume(@params);
if (volume == null)
return new ErrorResponse("Volume not found.");
var info = BuildVolumeInfo(volume);
return new
{
success = true,
message = $"Volume info for '{(volume as Component)?.gameObject.name}'.",
data = info
};
}
// === volume_set_properties ===
// Params: target, weight, priority, is_global, blend_distance
// OR properties dict with those keys
internal static object SetProperties(JObject @params)
{
var p = new ToolParams(@params);
var volume = GraphicsHelpers.FindVolume(@params);
if (volume == null)
return new ErrorResponse("Volume not found.");
// Unpack "properties" dict into top-level params so callers can use either style
var propsDict = p.GetRaw("properties") as JObject;
if (propsDict != null)
{
foreach (var prop in propsDict.Properties())
{
if (@params[prop.Name] == null)
@params[prop.Name] = prop.Value;
}
p = new ToolParams(@params);
}
var changed = new List<string>();
var weight = p.GetFloat("weight");
if (weight.HasValue) { SetProperty(volume, "weight", weight.Value); changed.Add("weight"); }
var priority = p.GetFloat("priority");
if (priority.HasValue) { SetProperty(volume, "priority", priority.Value); changed.Add("priority"); }
if (p.Has("is_global")) { SetProperty(volume, "isGlobal", p.GetBool("is_global")); changed.Add("isGlobal"); }
var blendDist = p.GetFloat("blend_distance");
if (blendDist.HasValue) { SetProperty(volume, "blendDistance", blendDist.Value); changed.Add("blendDistance"); }
if (changed.Count == 0)
return new ErrorResponse("No properties specified. Use: weight, priority, is_global, blend_distance.");
GraphicsHelpers.MarkDirty(volume);
return new
{
success = true,
message = $"Updated Volume properties: {string.Join(", ", changed)}",
data = new { changed }
};
}
// === volume_list_effects ===
// No params needed -- lists all available VolumeComponent types
internal static object ListEffects(JObject @params)
{
var types = GraphicsHelpers.GetAvailableEffectTypes();
var effectList = types.Select(t => new
{
name = t.Name,
fullName = t.FullName,
ns = t.Namespace
}).ToList();
return new
{
success = true,
message = $"Found {effectList.Count} available volume effects.",
data = new { pipeline = GraphicsHelpers.GetPipelineName(), effects = effectList }
};
}
// === volume_create_profile ===
// Params: path (string -- asset path like "Assets/Settings/MyProfile.asset")
internal static object CreateProfile(JObject @params)
{
var p = new ToolParams(@params);
string path = p.Get("path");
if (string.IsNullOrEmpty(path))
return new ErrorResponse("'path' parameter is required (e.g., 'Settings/MyProfile' or 'Assets/Settings/MyProfile.asset').");
// Auto-prepend Assets/ if missing (paths are relative to Assets/ by convention)
if (!path.StartsWith("Assets/", StringComparison.OrdinalIgnoreCase) &&
!path.StartsWith("Assets\\", StringComparison.OrdinalIgnoreCase))
path = "Assets/" + path;
if (!path.EndsWith(".asset", StringComparison.OrdinalIgnoreCase))
path += ".asset";
// Ensure directory exists
var dir = System.IO.Path.GetDirectoryName(path);
if (!string.IsNullOrEmpty(dir) && !AssetDatabase.IsValidFolder(dir))
{
// Create folders recursively
var parts = dir.Replace("\\", "/").Split('/');
string current = parts[0];
for (int i = 1; i < parts.Length; i++)
{
string next = current + "/" + parts[i];
if (!AssetDatabase.IsValidFolder(next))
AssetDatabase.CreateFolder(current, parts[i]);
current = next;
}
}
var profile = ScriptableObject.CreateInstance(GraphicsHelpers.VolumeProfileType);
AssetDatabase.CreateAsset(profile, path);
AssetDatabase.SaveAssets();
return new
{
success = true,
message = $"Created VolumeProfile at '{path}'.",
data = new { path }
};
}
// === ListVolumes (used by VolumesResource) ===
internal static object ListVolumes(JObject @params)
{
if (!GraphicsHelpers.HasVolumeSystem)
return new { success = true, message = "Volume system not available.", data = new { volumes = new List<object>() } };
var allVolumes = UnityFindObjectsCompat.FindAll(GraphicsHelpers.VolumeType);
var volumeList = new List<object>();
foreach (Component vol in allVolumes)
{
volumeList.Add(BuildVolumeInfo(vol));
}
return new
{
success = true,
message = $"Found {volumeList.Count} volume(s).",
data = new { pipeline = GraphicsHelpers.GetPipelineName(), volumes = volumeList }
};
}
// --- Helper: Build info object for a single Volume ---
private static object BuildVolumeInfo(object volumeComponent)
{
var comp = volumeComponent as Component;
if (comp == null) return null;
bool isGlobal = GetPropertyValue<bool>(volumeComponent, "isGlobal", true);
float weight = GetPropertyValue<float>(volumeComponent, "weight", 1f);
float priority = GetPropertyValue<float>(volumeComponent, "priority", 0f);
float blendDistance = GetPropertyValue<float>(volumeComponent, "blendDistance", 0f);
var profile = GetProperty(volumeComponent, "sharedProfile");
string profileName = profile is UnityEngine.Object profileObj2 ? profileObj2.name : null;
string profilePath = profile is UnityEngine.Object po ? AssetDatabase.GetAssetPath(po) : null;
var effectsList = new List<object>();
if (profile != null)
{
var components = GetProperty(profile, "components") as System.Collections.IList;
if (components != null)
{
foreach (var effect in components)
{
if (effect == null) continue;
var effectType = effect.GetType();
bool active = GetPropertyValue<bool>(effect, "active", true);
// Collect overridden parameters
var overriddenParams = new List<string>();
foreach (var field in effectType.GetFields(BindingFlags.Public | BindingFlags.Instance))
{
var fieldValue = field.GetValue(effect);
if (fieldValue == null) continue;
var overrideProp = fieldValue.GetType().GetProperty("overrideState");
if (overrideProp != null)
{
bool overridden = (bool)overrideProp.GetValue(fieldValue);
if (overridden)
overriddenParams.Add(field.Name);
}
}
effectsList.Add(new
{
type = effectType.Name,
active,
overridden_params = overriddenParams
});
}
}
}
return new
{
name = comp.gameObject.name,
instance_id = comp.gameObject.GetInstanceIDCompat(),
is_global = isGlobal,
weight,
priority,
blend_distance = blendDistance,
profile = profileName,
profile_path = profilePath ?? "",
effects = effectsList
};
}
// --- Helper: Set a VolumeParameter field value via reflection ---
// VolumeParameter<T> has: overrideState (bool), value (T)
internal static bool SetVolumeParameter(object component, string fieldName, JToken value)
{
if (component == null || string.IsNullOrEmpty(fieldName)) return false;
var field = component.GetType().GetField(fieldName,
BindingFlags.Public | BindingFlags.Instance);
if (field == null)
{
// Try camelCase conversion from snake_case
string camelCase = StringCaseUtility.ToCamelCase(fieldName);
field = component.GetType().GetField(camelCase,
BindingFlags.Public | BindingFlags.Instance);
}
if (field == null) return false;
var param = field.GetValue(component);
if (param == null) return false;
// Set value with type conversion, then enable override on success
var valueProp = param.GetType().GetProperty("value");
if (valueProp == null) return false;
try
{
object converted = ConvertToParameterType(value, valueProp.PropertyType);
valueProp.SetValue(param, converted);
var overrideProp = param.GetType().GetProperty("overrideState");
if (overrideProp != null)
overrideProp.SetValue(param, true);
return true;
}
catch (Exception ex)
{
McpLog.Warn($"[VolumeOps] Failed to set '{fieldName}': {ex.Message}");
return false;
}
}
// --- Helper: Convert JToken to target parameter type ---
private static object ConvertToParameterType(JToken value, Type targetType)
{
if (value == null || value.Type == JTokenType.Null) return null;
// Handle Color
if (targetType == typeof(Color))
{
if (value is JArray arr && arr.Count >= 3)
{
float r = arr[0].Value<float>();
float g = arr[1].Value<float>();
float b = arr[2].Value<float>();
float a = arr.Count >= 4 ? arr[3].Value<float>() : 1f;
return new Color(r, g, b, a);
}
// Try hex string
if (value.Type == JTokenType.String)
{
if (ColorUtility.TryParseHtmlString(value.ToString(), out Color c))
return c;
}
}
// Handle Vector2
if (targetType == typeof(Vector2))
{
if (value is JArray arr && arr.Count >= 2)
return new Vector2(arr[0].Value<float>(), arr[1].Value<float>());
}
// Handle Vector3
if (targetType == typeof(Vector3))
{
if (value is JArray arr && arr.Count >= 3)
return new Vector3(arr[0].Value<float>(), arr[1].Value<float>(), arr[2].Value<float>());
}
// Handle Vector4
if (targetType == typeof(Vector4))
{
if (value is JArray arr && arr.Count >= 4)
return new Vector4(arr[0].Value<float>(), arr[1].Value<float>(),
arr[2].Value<float>(), arr[3].Value<float>());
}
// Handle enums
if (targetType.IsEnum)
{
string str = value.ToString();
if (Enum.TryParse(targetType, str, true, out object enumVal))
return enumVal;
// Try as int
if (int.TryParse(str, out int intVal))
return Enum.ToObject(targetType, intVal);
}
// Handle bool
if (targetType == typeof(bool))
return ParamCoercion.CoerceBool(value, false);
// Handle float
if (targetType == typeof(float))
return ParamCoercion.CoerceFloat(value, 0f);
// Handle int
if (targetType == typeof(int))
return ParamCoercion.CoerceInt(value, 0);
// Handle Texture2D (by asset path)
if (targetType == typeof(Texture2D) || targetType == typeof(Texture))
{
string path = value.ToString();
return AssetDatabase.LoadAssetAtPath<Texture2D>(path);
}
// Fallback: try Convert
try
{
return Convert.ChangeType(value.ToObject<object>(), targetType);
}
catch
{
return value.ToObject<object>();
}
}
// --- Reflection helpers (with field fallback for Volume.sharedProfile etc.) ---
private static object GetProperty(object obj, string name)
{
if (obj == null) return null;
var prop = obj.GetType().GetProperty(name,
BindingFlags.Public | BindingFlags.Instance);
if (prop != null) return prop.GetValue(obj);
// Fallback: try as a field (e.g., Volume.sharedProfile is a public field, not a property)
var field = obj.GetType().GetField(name,
BindingFlags.Public | BindingFlags.Instance);
return field?.GetValue(obj);
}
private static T GetPropertyValue<T>(object obj, string name, T defaultValue)
{
var val = GetProperty(obj, name);
if (val is T typed) return typed;
return defaultValue;
}
private static void SetProperty(object obj, string name, object value)
{
if (obj == null) return;
var prop = obj.GetType().GetProperty(name,
BindingFlags.Public | BindingFlags.Instance);
if (prop != null && prop.CanWrite)
{
prop.SetValue(obj, value);
return;
}
// Fallback: try as a field (e.g., Volume.sharedProfile is a public field, not a property)
var field = obj.GetType().GetField(name,
BindingFlags.Public | BindingFlags.Instance);
field?.SetValue(obj, value);
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: f9cc9cf9a8664f5bbefa277a02e020fc
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
+31
View File
@@ -0,0 +1,31 @@
using MCPForUnity.Editor.Helpers;
using Newtonsoft.Json.Linq;
using UnityEngine;
namespace MCPForUnity.Editor.Tools
{
internal static class JsonUtil
{
/// <summary>
/// If @params[paramName] is a JSON string, parse it to a JObject in-place.
/// Logs a warning on parse failure and leaves the original value.
/// </summary>
internal static void CoerceJsonStringParameter(JObject @params, string paramName)
{
if (@params == null || string.IsNullOrEmpty(paramName)) return;
var token = @params[paramName];
if (token != null && token.Type == JTokenType.String)
{
try
{
var parsed = JObject.Parse(token.ToString());
@params[paramName] = parsed;
}
catch (Newtonsoft.Json.JsonReaderException e)
{
McpLog.Warn($"[MCP] Could not parse '{paramName}' JSON string: {e.Message}");
}
}
}
}
}
+11
View File
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: d4b3b6009d53e4b8f97fe7ab57888c65
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

Some files were not shown because too many files have changed in this diff Show More