升级XR插件版本

This commit is contained in:
Sora丶kong
2026-03-02 17:56:21 +08:00
parent 8962657674
commit 60f512a9bc
1317 changed files with 110305 additions and 48249 deletions

View File

@@ -0,0 +1,221 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using MCPForUnity.Editor.Helpers;
using Newtonsoft.Json.Linq;
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
{
private const int MaxCommandsPerBatch = 25;
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'.");
}
if (commandsToken.Count > MaxCommandsPerBatch)
{
return new ErrorResponse($"A maximum of {MaxCommandsPerBatch} commands are allowed per batch.");
}
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;
}
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] = NormalizeToken(property.Value);
}
return normalized;
}
private static JArray NormalizeArray(JArray source)
{
var normalized = new JArray();
foreach (var token in source)
{
normalized.Add(NormalizeToken(token));
}
return normalized;
}
private static JToken NormalizeToken(JToken token)
{
return token switch
{
JObject obj => NormalizeParameterKeys(obj),
JArray arr => NormalizeArray(arr),
_ => token.DeepClone()
};
}
private static string ToCamelCase(string key) => StringCaseUtility.ToCamelCase(key);
}
}

View File

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

View File

@@ -0,0 +1,426 @@
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 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()
{
try
{
var allTypes = AppDomain.CurrentDomain.GetAssemblies()
.Where(a => !a.IsDynamic)
.SelectMany(a =>
{
try { return a.GetTypes(); }
catch { return new Type[0]; }
})
.ToList();
// Discover tools
var toolTypes = allTypes.Where(t => t.GetCustomAttribute<McpForUnityToolAttribute>() != null);
int toolCount = 0;
foreach (var type in toolTypes)
{
if (RegisterCommandType(type, isResource: false))
toolCount++;
}
// Discover resources
var resourceTypes = allTypes.Where(t => t.GetCustomAttribute<McpForUnityResourceAttribute>() != null);
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}");
}
}
/// <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");
}
}
}
}

View File

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

View File

@@ -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}");
}
}
}
}

View File

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

View File

@@ -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}");
}
}
}
}

View File

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

View File

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

View File

@@ -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];
}
}
}

View File

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

View File

@@ -0,0 +1,410 @@
#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.");
}
}
try
{
Component newComponent = Undo.AddComponent(targetGo, componentType);
if (newComponent == null)
{
return new ErrorResponse($"Failed to add component '{typeName}' to '{targetGo.name}'. It might be disallowed (e.g., adding script twice)."
);
}
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}");
}
}
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 = SetProperty(targetComponent, propName, propValue);
if (!setResult)
{
var availableProperties = ComponentResolver.GetAllComponentProperties(targetComponent.GetType());
var suggestions = ComponentResolver.GetFuzzyPropertySuggestions(propName, availableProperties);
var 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 SetProperty(object target, string memberName, JToken value)
{
Type type = target.GetType();
BindingFlags flags = BindingFlags.Public | BindingFlags.Instance | BindingFlags.IgnoreCase;
string normalizedName = Helpers.ParamCoercion.NormalizePropertyName(memberName);
var inputSerializer = InputSerializer;
try
{
if (memberName.Contains('.') || memberName.Contains('['))
{
return SetNestedProperty(target, memberName, value, inputSerializer);
}
PropertyInfo propInfo = type.GetProperty(memberName, flags) ?? type.GetProperty(normalizedName, flags);
if (propInfo != null && propInfo.CanWrite)
{
object convertedValue = ConvertJTokenToType(value, propInfo.PropertyType, inputSerializer);
if (convertedValue != null || value.Type == JTokenType.Null)
{
propInfo.SetValue(target, convertedValue);
return true;
}
}
else
{
FieldInfo fieldInfo = type.GetField(memberName, flags) ?? type.GetField(normalizedName, flags);
if (fieldInfo != null)
{
object convertedValue = ConvertJTokenToType(value, fieldInfo.FieldType, inputSerializer);
if (convertedValue != null || value.Type == JTokenType.Null)
{
fieldInfo.SetValue(target, convertedValue);
return true;
}
}
else
{
var npField = type.GetField(memberName, BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.IgnoreCase)
?? type.GetField(normalizedName, BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.IgnoreCase);
if (npField != null && npField.GetCustomAttribute<SerializeField>() != null)
{
object convertedValue = ConvertJTokenToType(value, npField.FieldType, inputSerializer);
if (convertedValue != null || value.Type == JTokenType.Null)
{
npField.SetValue(target, convertedValue);
return true;
}
}
}
}
}
catch (Exception ex)
{
McpLog.Error($"[SetProperty] Failed to set '{memberName}' on {type.Name}: {ex.Message}\nToken: {value.ToString(Formatting.None)}");
}
return false;
}
private static bool SetNestedProperty(object target, string path, JToken value, JsonSerializer inputSerializer)
{
try
{
string[] pathParts = SplitPropertyPath(path);
if (pathParts.Length == 0)
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)
{
McpLog.Warn($"[SetNestedProperty] Could not find property or field '{part}' on type '{currentType.Name}'");
return false;
}
}
currentObject = propInfo != null ? propInfo.GetValue(currentObject) : fieldInfo.GetValue(currentObject);
if (currentObject == null)
{
McpLog.Warn($"[SetNestedProperty] Property '{part}' is null, cannot access nested properties.");
return false;
}
if (isArray)
{
if (currentObject is Material[])
{
var materials = currentObject as Material[];
if (arrayIndex < 0 || arrayIndex >= materials.Length)
{
McpLog.Warn($"[SetNestedProperty] Material index {arrayIndex} out of range (0-{materials.Length - 1})");
return false;
}
currentObject = materials[arrayIndex];
}
else if (currentObject is System.Collections.IList)
{
var list = currentObject as System.Collections.IList;
if (arrayIndex < 0 || arrayIndex >= list.Count)
{
McpLog.Warn($"[SetNestedProperty] Index {arrayIndex} out of range (0-{list.Count - 1})");
return false;
}
currentObject = list[arrayIndex];
}
else
{
McpLog.Warn($"[SetNestedProperty] Property '{part}' is not an array or list, cannot access by index.");
return false;
}
}
currentType = currentObject.GetType();
}
string finalPart = pathParts[pathParts.Length - 1];
if (currentObject is Material material && finalPart.StartsWith("_"))
{
return MaterialOps.TrySetShaderProperty(material, finalPart, value, inputSerializer);
}
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;
}
}
else
{
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;
}
}
}
}
catch (Exception ex)
{
McpLog.Error($"[SetNestedProperty] Error setting nested property '{path}': {ex.Message}\nToken: {value.ToString(Formatting.None)}");
}
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;
}
}
}

View File

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

View File

@@ -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));
}
}
}

View File

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

View File

@@ -0,0 +1,48 @@
#nullable disable
using System.Collections.Generic;
using MCPForUnity.Editor.Helpers;
using Newtonsoft.Json.Linq;
using UnityEditor;
using UnityEngine;
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.GetInstanceID();
// 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).");
}
}
}

View File

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

View File

@@ -0,0 +1,86 @@
#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 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.GetInstanceID(),
duplicatedObject = Helpers.GameObjectSerializer.GetGameObjectData(duplicatedGo)
}
);
}
}
}

View File

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

View File

@@ -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);
}
}

View File

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

View File

@@ -0,0 +1,297 @@
#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();
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;
}
}
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)
);
}
}
}

View File

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

View File

@@ -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;
}
}
}
}

View File

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

View File

@@ -0,0 +1,115 @@
#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='open_stage' to edit the prefab in isolation 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);
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}");
}
}
}
}

View File

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

View File

@@ -0,0 +1,238 @@
#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;
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.GetInstanceID() == 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
{
#if UNITY_2023_1_OR_NEWER
var inactive = searchInactive ? FindObjectsInactive.Include : FindObjectsInactive.Exclude;
searchPoolComp = UnityEngine.Object.FindObjectsByType(componentType, inactive, FindObjectsSortMode.None)
.Cast<Component>()
.Select(c => c.gameObject);
#else
searchPoolComp = UnityEngine.Object.FindObjectsOfType(componentType, searchInactive)
.Cast<Component>()
.Select(c => c.gameObject);
#endif
}
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.GetInstanceID() == 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;
}
}
}

View File

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

View File

@@ -0,0 +1,35 @@
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)]
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'.");
}
bool includeDetails = ParamCoercion.CoerceBool(@params?["includeDetails"], false);
bool includeFailedTests = ParamCoercion.CoerceBool(@params?["includeFailedTests"], false);
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);
}
}
}

View File

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

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}");
}
}
}
}
}

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -0,0 +1,351 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using MCPForUnity.Editor.Helpers;
using Newtonsoft.Json.Linq;
using UnityEditor;
using UnityEditor.SceneManagement;
using UnityEngine;
namespace MCPForUnity.Editor.Tools
{
/// <summary>
/// Tool for managing components on GameObjects.
/// Actions: add, remove, set_property
///
/// This is a focused tool for component lifecycle operations.
/// For reading component data, use the unity://scene/gameobject/{id}/components resource.
/// </summary>
[McpForUnityTool("manage_components")]
public static class ManageComponents
{
/// <summary>
/// Handles the manage_components command.
/// </summary>
/// <param name="params">Command parameters</param>
/// <returns>Result of the component operation</returns>
public static object HandleCommand(JObject @params)
{
if (@params == null)
{
return new ErrorResponse("Parameters cannot be null.");
}
string action = ParamCoercion.CoerceString(@params["action"], null)?.ToLowerInvariant();
if (string.IsNullOrEmpty(action))
{
return new ErrorResponse("'action' parameter is required (add, remove, set_property).");
}
// Target resolution
JToken targetToken = @params["target"];
string searchMethod = ParamCoercion.CoerceString(@params["searchMethod"] ?? @params["search_method"], null);
if (targetToken == null)
{
return new ErrorResponse("'target' parameter is required.");
}
try
{
return action switch
{
"add" => AddComponent(@params, targetToken, searchMethod),
"remove" => RemoveComponent(@params, targetToken, searchMethod),
"set_property" => SetProperty(@params, targetToken, searchMethod),
_ => new ErrorResponse($"Unknown action: '{action}'. Supported actions: add, remove, set_property")
};
}
catch (Exception e)
{
McpLog.Error($"[ManageComponents] Action '{action}' failed: {e}");
return new ErrorResponse($"Internal error processing action '{action}': {e.Message}");
}
}
#region Action Implementations
private static object AddComponent(JObject @params, JToken targetToken, string searchMethod)
{
GameObject targetGo = FindTarget(targetToken, searchMethod);
if (targetGo == null)
{
return new ErrorResponse($"Target GameObject ('{targetToken}') not found using method '{searchMethod ?? "default"}'.");
}
string componentTypeName = ParamCoercion.CoerceString(@params["componentType"] ?? @params["component_type"], null);
if (string.IsNullOrEmpty(componentTypeName))
{
return new ErrorResponse("'componentType' parameter is required for 'add' action.");
}
// Resolve component type using unified type resolver
Type type = UnityTypeResolver.ResolveComponent(componentTypeName);
if (type == null)
{
return new ErrorResponse($"Component type '{componentTypeName}' not found. Use a fully-qualified name if needed.");
}
// Use ComponentOps for the actual operation
Component newComponent = ComponentOps.AddComponent(targetGo, type, out string error);
if (newComponent == null)
{
return new ErrorResponse(error ?? $"Failed to add component '{componentTypeName}'.");
}
// Set properties if provided
JObject properties = @params["properties"] as JObject ?? @params["componentProperties"] as JObject;
if (properties != null && properties.HasValues)
{
// Record for undo before modifying properties
Undo.RecordObject(newComponent, "Modify Component Properties");
SetPropertiesOnComponent(newComponent, properties);
}
EditorUtility.SetDirty(targetGo);
MarkOwningSceneDirty(targetGo);
return new
{
success = true,
message = $"Component '{componentTypeName}' added to '{targetGo.name}'.",
data = new
{
instanceID = targetGo.GetInstanceID(),
componentType = type.FullName,
componentInstanceID = newComponent.GetInstanceID()
}
};
}
private static object RemoveComponent(JObject @params, JToken targetToken, string searchMethod)
{
GameObject targetGo = FindTarget(targetToken, searchMethod);
if (targetGo == null)
{
return new ErrorResponse($"Target GameObject ('{targetToken}') not found using method '{searchMethod ?? "default"}'.");
}
string componentTypeName = ParamCoercion.CoerceString(@params["componentType"] ?? @params["component_type"], null);
if (string.IsNullOrEmpty(componentTypeName))
{
return new ErrorResponse("'componentType' parameter is required for 'remove' action.");
}
// Resolve component type using unified type resolver
Type type = UnityTypeResolver.ResolveComponent(componentTypeName);
if (type == null)
{
return new ErrorResponse($"Component type '{componentTypeName}' not found.");
}
// Use ComponentOps for the actual operation
bool removed = ComponentOps.RemoveComponent(targetGo, type, out string error);
if (!removed)
{
return new ErrorResponse(error ?? $"Failed to remove component '{componentTypeName}'.");
}
EditorUtility.SetDirty(targetGo);
MarkOwningSceneDirty(targetGo);
return new
{
success = true,
message = $"Component '{componentTypeName}' removed from '{targetGo.name}'.",
data = new
{
instanceID = targetGo.GetInstanceID()
}
};
}
private static object SetProperty(JObject @params, JToken targetToken, string searchMethod)
{
GameObject targetGo = FindTarget(targetToken, searchMethod);
if (targetGo == null)
{
return new ErrorResponse($"Target GameObject ('{targetToken}') not found using method '{searchMethod ?? "default"}'.");
}
string componentType = ParamCoercion.CoerceString(@params["componentType"] ?? @params["component_type"], null);
if (string.IsNullOrEmpty(componentType))
{
return new ErrorResponse("'componentType' parameter is required for 'set_property' action.");
}
// Resolve component type using unified type resolver
Type type = UnityTypeResolver.ResolveComponent(componentType);
if (type == null)
{
return new ErrorResponse($"Component type '{componentType}' not found.");
}
Component component = targetGo.GetComponent(type);
if (component == null)
{
return new ErrorResponse($"Component '{componentType}' not found on '{targetGo.name}'.");
}
// Get property and value
string propertyName = ParamCoercion.CoerceString(@params["property"], null);
JToken valueToken = @params["value"];
// Support both single property or properties object
JObject properties = @params["properties"] as JObject;
if (string.IsNullOrEmpty(propertyName) && (properties == null || !properties.HasValues))
{
return new ErrorResponse("Either 'property'+'value' or 'properties' object is required for 'set_property' action.");
}
var errors = new List<string>();
try
{
Undo.RecordObject(component, $"Set property on {componentType}");
if (!string.IsNullOrEmpty(propertyName) && valueToken != null)
{
// Single property mode
var error = TrySetProperty(component, propertyName, valueToken);
if (error != null)
{
errors.Add(error);
}
}
if (properties != null && properties.HasValues)
{
// Multiple properties mode
foreach (var prop in properties.Properties())
{
var error = TrySetProperty(component, prop.Name, prop.Value);
if (error != null)
{
errors.Add(error);
}
}
}
EditorUtility.SetDirty(component);
MarkOwningSceneDirty(targetGo);
if (errors.Count > 0)
{
return new
{
success = false,
message = $"Some properties failed to set on '{componentType}'.",
data = new
{
instanceID = targetGo.GetInstanceID(),
errors = errors
}
};
}
return new
{
success = true,
message = $"Properties set on component '{componentType}' on '{targetGo.name}'.",
data = new
{
instanceID = targetGo.GetInstanceID()
}
};
}
catch (Exception e)
{
return new ErrorResponse($"Error setting properties on component '{componentType}': {e.Message}");
}
}
#endregion
#region Helpers
/// <summary>
/// Marks the appropriate scene as dirty for the given GameObject.
/// Handles both regular scenes and prefab stages.
/// </summary>
private static void MarkOwningSceneDirty(GameObject targetGo)
{
var prefabStage = PrefabStageUtility.GetCurrentPrefabStage();
if (prefabStage != null)
{
EditorSceneManager.MarkSceneDirty(prefabStage.scene);
}
else
{
EditorSceneManager.MarkSceneDirty(targetGo.scene);
}
}
private static GameObject FindTarget(JToken targetToken, string searchMethod)
{
if (targetToken == null)
return null;
// Try instance ID first
if (targetToken.Type == JTokenType.Integer)
{
int instanceId = targetToken.Value<int>();
return GameObjectLookup.FindById(instanceId);
}
string targetStr = targetToken.ToString();
// Try parsing as instance ID
if (int.TryParse(targetStr, out int parsedId))
{
var byId = GameObjectLookup.FindById(parsedId);
if (byId != null)
return byId;
}
// Use GameObjectLookup for search
return GameObjectLookup.FindByTarget(targetToken, searchMethod ?? "by_name", true);
}
private static void SetPropertiesOnComponent(Component component, JObject properties)
{
if (component == null || properties == null)
return;
var errors = new List<string>();
foreach (var prop in properties.Properties())
{
var error = TrySetProperty(component, prop.Name, prop.Value);
if (error != null)
errors.Add(error);
}
if (errors.Count > 0)
{
McpLog.Warn($"[ManageComponents] Some properties failed to set on {component.GetType().Name}: {string.Join(", ", errors)}");
}
}
/// <summary>
/// Attempts to set a property or field on a component.
/// Delegates to ComponentOps.SetProperty for unified implementation.
/// </summary>
private static string TrySetProperty(Component component, string propertyName, JToken value)
{
if (component == null || string.IsNullOrEmpty(propertyName))
return "Invalid component or property name";
if (ComponentOps.SetProperty(component, propertyName, value, out string error))
{
return null; // Success
}
McpLog.Warn($"[ManageComponents] {error}");
return error;
}
#endregion
}
}

View File

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

View File

@@ -0,0 +1,393 @@
using System;
using MCPForUnity.Editor.Helpers;
using Newtonsoft.Json.Linq;
using UnityEditor;
using UnityEditorInternal; // Required for tag management
namespace MCPForUnity.Editor.Tools
{
/// <summary>
/// Handles editor control actions including play mode control, tool selection,
/// and tag/layer management. For reading editor state, use MCP resources instead.
/// </summary>
[McpForUnityTool("manage_editor", AutoRegister = false)]
public static class ManageEditor
{
// Constant for starting user layer index
private const int FirstUserLayerIndex = 8;
// Constant for total layer count
private const int TotalLayerCount = 32;
/// <summary>
/// Main handler for editor management actions.
/// </summary>
public static object HandleCommand(JObject @params)
{
// Step 1: Null parameter guard (consistent across all tools)
if (@params == null)
{
return new ErrorResponse("Parameters cannot be null.");
}
// Step 2: Wrap parameters
var p = new ToolParams(@params);
// Step 3: Extract and validate required parameters
var actionResult = p.GetRequired("action");
if (!actionResult.IsSuccess)
{
return new ErrorResponse(actionResult.ErrorMessage);
}
string action = actionResult.Value.ToLowerInvariant();
// Parameters for specific actions
string tagName = p.Get("tagName");
string layerName = p.Get("layerName");
bool waitForCompletion = p.GetBool("waitForCompletion", false);
// Route action
switch (action)
{
// Play Mode Control
case "play":
try
{
if (!EditorApplication.isPlaying)
{
EditorApplication.isPlaying = true;
return new SuccessResponse("Entered play mode.");
}
return new SuccessResponse("Already in play mode.");
}
catch (Exception e)
{
return new ErrorResponse($"Error entering play mode: {e.Message}");
}
case "pause":
try
{
if (EditorApplication.isPlaying)
{
EditorApplication.isPaused = !EditorApplication.isPaused;
return new SuccessResponse(
EditorApplication.isPaused ? "Game paused." : "Game resumed."
);
}
return new ErrorResponse("Cannot pause/resume: Not in play mode.");
}
catch (Exception e)
{
return new ErrorResponse($"Error pausing/resuming game: {e.Message}");
}
case "stop":
try
{
if (EditorApplication.isPlaying)
{
EditorApplication.isPlaying = false;
return new SuccessResponse("Exited play mode.");
}
return new SuccessResponse("Already stopped (not in play mode).");
}
catch (Exception e)
{
return new ErrorResponse($"Error stopping play mode: {e.Message}");
}
// Tool Control
case "set_active_tool":
var toolNameResult = p.GetRequired("toolName", "'toolName' parameter required for set_active_tool.");
if (!toolNameResult.IsSuccess)
return new ErrorResponse(toolNameResult.ErrorMessage);
return SetActiveTool(toolNameResult.Value);
// Tag Management
case "add_tag":
var addTagResult = p.GetRequired("tagName", "'tagName' parameter required for add_tag.");
if (!addTagResult.IsSuccess)
return new ErrorResponse(addTagResult.ErrorMessage);
return AddTag(addTagResult.Value);
case "remove_tag":
var removeTagResult = p.GetRequired("tagName", "'tagName' parameter required for remove_tag.");
if (!removeTagResult.IsSuccess)
return new ErrorResponse(removeTagResult.ErrorMessage);
return RemoveTag(removeTagResult.Value);
// Layer Management
case "add_layer":
var addLayerResult = p.GetRequired("layerName", "'layerName' parameter required for add_layer.");
if (!addLayerResult.IsSuccess)
return new ErrorResponse(addLayerResult.ErrorMessage);
return AddLayer(addLayerResult.Value);
case "remove_layer":
var removeLayerResult = p.GetRequired("layerName", "'layerName' parameter required for remove_layer.");
if (!removeLayerResult.IsSuccess)
return new ErrorResponse(removeLayerResult.ErrorMessage);
return RemoveLayer(removeLayerResult.Value);
// --- Settings (Example) ---
// case "set_resolution":
// int? width = @params["width"]?.ToObject<int?>();
// int? height = @params["height"]?.ToObject<int?>();
// if (!width.HasValue || !height.HasValue) return new ErrorResponse("'width' and 'height' parameters required.");
// return SetGameViewResolution(width.Value, height.Value);
// case "set_quality":
// // Handle string name or int index
// return SetQualityLevel(@params["qualityLevel"]);
default:
return new ErrorResponse(
$"Unknown action: '{action}'. Supported actions: play, pause, stop, set_active_tool, add_tag, remove_tag, add_layer, remove_layer. Use MCP resources for reading editor state, project info, tags, layers, selection, windows, prefab stage, and active tool."
);
}
}
// --- Tool Control Methods ---
private static object SetActiveTool(string toolName)
{
try
{
Tool targetTool;
if (Enum.TryParse<Tool>(toolName, true, out targetTool)) // Case-insensitive parse
{
// Check if it's a valid built-in tool
if (targetTool != Tool.None && targetTool <= Tool.Custom) // Tool.Custom is the last standard tool
{
UnityEditor.Tools.current = targetTool;
return new SuccessResponse($"Set active tool to '{targetTool}'.");
}
else
{
return new ErrorResponse(
$"Cannot directly set tool to '{toolName}'. It might be None, Custom, or invalid."
);
}
}
else
{
// Potentially try activating a custom tool by name here if needed
// This often requires specific editor scripting knowledge for that tool.
return new ErrorResponse(
$"Could not parse '{toolName}' as a standard Unity Tool (View, Move, Rotate, Scale, Rect, Transform, Custom)."
);
}
}
catch (Exception e)
{
return new ErrorResponse($"Error setting active tool: {e.Message}");
}
}
// --- Tag Management Methods ---
private static object AddTag(string tagName)
{
if (string.IsNullOrWhiteSpace(tagName))
return new ErrorResponse("Tag name cannot be empty or whitespace.");
// Check if tag already exists
if (System.Linq.Enumerable.Contains(InternalEditorUtility.tags, tagName))
{
return new ErrorResponse($"Tag '{tagName}' already exists.");
}
try
{
// Add the tag using the internal utility
InternalEditorUtility.AddTag(tagName);
// Force save assets to ensure the change persists in the TagManager asset
AssetDatabase.SaveAssets();
return new SuccessResponse($"Tag '{tagName}' added successfully.");
}
catch (Exception e)
{
return new ErrorResponse($"Failed to add tag '{tagName}': {e.Message}");
}
}
private static object RemoveTag(string tagName)
{
if (string.IsNullOrWhiteSpace(tagName))
return new ErrorResponse("Tag name cannot be empty or whitespace.");
if (tagName.Equals("Untagged", StringComparison.OrdinalIgnoreCase))
return new ErrorResponse("Cannot remove the built-in 'Untagged' tag.");
// Check if tag exists before attempting removal
if (!System.Linq.Enumerable.Contains(InternalEditorUtility.tags, tagName))
{
return new ErrorResponse($"Tag '{tagName}' does not exist.");
}
try
{
// Remove the tag using the internal utility
InternalEditorUtility.RemoveTag(tagName);
// Force save assets
AssetDatabase.SaveAssets();
return new SuccessResponse($"Tag '{tagName}' removed successfully.");
}
catch (Exception e)
{
// Catch potential issues if the tag is somehow in use or removal fails
return new ErrorResponse($"Failed to remove tag '{tagName}': {e.Message}");
}
}
// --- Layer Management Methods ---
private static object AddLayer(string layerName)
{
if (string.IsNullOrWhiteSpace(layerName))
return new ErrorResponse("Layer name cannot be empty or whitespace.");
// Access the TagManager asset
SerializedObject tagManager = GetTagManager();
if (tagManager == null)
return new ErrorResponse("Could not access TagManager asset.");
SerializedProperty layersProp = tagManager.FindProperty("layers");
if (layersProp == null || !layersProp.isArray)
return new ErrorResponse("Could not find 'layers' property in TagManager.");
// Check if layer name already exists (case-insensitive check recommended)
for (int i = 0; i < TotalLayerCount; i++)
{
SerializedProperty layerSP = layersProp.GetArrayElementAtIndex(i);
if (
layerSP != null
&& layerName.Equals(layerSP.stringValue, StringComparison.OrdinalIgnoreCase)
)
{
return new ErrorResponse($"Layer '{layerName}' already exists at index {i}.");
}
}
// Find the first empty user layer slot (indices 8 to 31)
int firstEmptyUserLayer = -1;
for (int i = FirstUserLayerIndex; i < TotalLayerCount; i++)
{
SerializedProperty layerSP = layersProp.GetArrayElementAtIndex(i);
if (layerSP != null && string.IsNullOrEmpty(layerSP.stringValue))
{
firstEmptyUserLayer = i;
break;
}
}
if (firstEmptyUserLayer == -1)
{
return new ErrorResponse("No empty User Layer slots available (8-31 are full).");
}
// Assign the name to the found slot
try
{
SerializedProperty targetLayerSP = layersProp.GetArrayElementAtIndex(
firstEmptyUserLayer
);
targetLayerSP.stringValue = layerName;
// Apply the changes to the TagManager asset
tagManager.ApplyModifiedProperties();
// Save assets to make sure it's written to disk
AssetDatabase.SaveAssets();
return new SuccessResponse(
$"Layer '{layerName}' added successfully to slot {firstEmptyUserLayer}."
);
}
catch (Exception e)
{
return new ErrorResponse($"Failed to add layer '{layerName}': {e.Message}");
}
}
private static object RemoveLayer(string layerName)
{
if (string.IsNullOrWhiteSpace(layerName))
return new ErrorResponse("Layer name cannot be empty or whitespace.");
// Access the TagManager asset
SerializedObject tagManager = GetTagManager();
if (tagManager == null)
return new ErrorResponse("Could not access TagManager asset.");
SerializedProperty layersProp = tagManager.FindProperty("layers");
if (layersProp == null || !layersProp.isArray)
return new ErrorResponse("Could not find 'layers' property in TagManager.");
// Find the layer by name (must be user layer)
int layerIndexToRemove = -1;
for (int i = FirstUserLayerIndex; i < TotalLayerCount; i++) // Start from user layers
{
SerializedProperty layerSP = layersProp.GetArrayElementAtIndex(i);
// Case-insensitive comparison is safer
if (
layerSP != null
&& layerName.Equals(layerSP.stringValue, StringComparison.OrdinalIgnoreCase)
)
{
layerIndexToRemove = i;
break;
}
}
if (layerIndexToRemove == -1)
{
return new ErrorResponse($"User layer '{layerName}' not found.");
}
// Clear the name for that index
try
{
SerializedProperty targetLayerSP = layersProp.GetArrayElementAtIndex(
layerIndexToRemove
);
targetLayerSP.stringValue = string.Empty; // Set to empty string to remove
// Apply the changes
tagManager.ApplyModifiedProperties();
// Save assets
AssetDatabase.SaveAssets();
return new SuccessResponse(
$"Layer '{layerName}' (slot {layerIndexToRemove}) removed successfully."
);
}
catch (Exception e)
{
return new ErrorResponse($"Failed to remove layer '{layerName}': {e.Message}");
}
}
// --- Helper Methods ---
/// <summary>
/// Gets the SerializedObject for the TagManager asset.
/// </summary>
private static SerializedObject GetTagManager()
{
try
{
// Load the TagManager asset from the ProjectSettings folder
UnityEngine.Object[] tagManagerAssets = AssetDatabase.LoadAllAssetsAtPath(
"ProjectSettings/TagManager.asset"
);
if (tagManagerAssets == null || tagManagerAssets.Length == 0)
{
McpLog.Error("[ManageEditor] TagManager.asset not found in ProjectSettings.");
return null;
}
// The first object in the asset file should be the TagManager
return new SerializedObject(tagManagerAssets[0]);
}
catch (Exception e)
{
McpLog.Error($"[ManageEditor] Error accessing TagManager.asset: {e.Message}");
return null;
}
}
// --- Example Implementations for Settings ---
/*
private static object SetGameViewResolution(int width, int height) { ... }
private static object SetQualityLevel(JToken qualityLevelToken) { ... }
*/
}
}

View File

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

View File

@@ -0,0 +1,596 @@
using System;
using System.Collections.Generic;
using MCPForUnity.Editor.Helpers;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using UnityEditor;
using UnityEngine;
namespace MCPForUnity.Editor.Tools
{
[McpForUnityTool("manage_material", AutoRegister = false)]
public static class ManageMaterial
{
public static object HandleCommand(JObject @params)
{
string action = @params["action"]?.ToString()?.ToLowerInvariant();
if (string.IsNullOrEmpty(action))
{
return new ErrorResponse("Action is required");
}
try
{
switch (action)
{
case "ping":
return new SuccessResponse("pong", new { tool = "manage_material" });
case "create":
return CreateMaterial(@params);
case "set_material_shader_property":
return SetMaterialShaderProperty(@params);
case "set_material_color":
return SetMaterialColor(@params);
case "assign_material_to_renderer":
return AssignMaterialToRenderer(@params);
case "set_renderer_color":
return SetRendererColor(@params);
case "get_material_info":
return GetMaterialInfo(@params);
default:
return new ErrorResponse($"Unknown action: {action}");
}
}
catch (Exception ex)
{
return new ErrorResponse(ex.Message, new { stackTrace = ex.StackTrace });
}
}
private static string NormalizePath(string path)
{
if (string.IsNullOrEmpty(path)) return path;
// Normalize separators and ensure Assets/ root
path = AssetPathUtility.SanitizeAssetPath(path);
// Ensure .mat extension
if (!path.EndsWith(".mat", StringComparison.OrdinalIgnoreCase))
{
path += ".mat";
}
return path;
}
private static object SetMaterialShaderProperty(JObject @params)
{
string materialPath = NormalizePath(@params["materialPath"]?.ToString());
string property = @params["property"]?.ToString();
JToken value = @params["value"];
if (string.IsNullOrEmpty(materialPath) || string.IsNullOrEmpty(property) || value == null)
{
return new ErrorResponse("materialPath, property, and value are required");
}
// Find material
var findInstruction = new JObject { ["find"] = materialPath };
Material mat = ObjectResolver.Resolve(findInstruction, typeof(Material)) as Material;
if (mat == null)
{
return new ErrorResponse($"Could not find material at path: {materialPath}");
}
Undo.RecordObject(mat, "Set Material Property");
// Normalize alias/casing once for all code paths
property = MaterialOps.ResolvePropertyName(mat, property);
// 1. Try handling Texture instruction explicitly (ManageMaterial special feature)
if (value.Type == JTokenType.Object)
{
// Check if it looks like an instruction
if (value is JObject obj && (obj.ContainsKey("find") || obj.ContainsKey("method")))
{
Texture tex = ObjectResolver.Resolve(obj, typeof(Texture)) as Texture;
if (tex != null && mat.HasProperty(property))
{
mat.SetTexture(property, tex);
EditorUtility.SetDirty(mat);
return new SuccessResponse($"Set texture property {property} on {mat.name}");
}
}
}
// 2. Fallback to standard logic via MaterialOps (handles Colors, Floats, Strings->Path)
bool success = MaterialOps.TrySetShaderProperty(mat, property, value, UnityJsonSerializer.Instance);
if (success)
{
EditorUtility.SetDirty(mat);
return new SuccessResponse($"Set property {property} on {mat.name}");
}
else
{
return new ErrorResponse($"Failed to set property {property}. Value format might be unsupported or texture not found.");
}
}
private static object SetMaterialColor(JObject @params)
{
string materialPath = NormalizePath(@params["materialPath"]?.ToString());
JToken colorToken = @params["color"];
string property = @params["property"]?.ToString();
if (string.IsNullOrEmpty(materialPath) || colorToken == null)
{
return new ErrorResponse("materialPath and color are required");
}
var findInstruction = new JObject { ["find"] = materialPath };
Material mat = ObjectResolver.Resolve(findInstruction, typeof(Material)) as Material;
if (mat == null)
{
return new ErrorResponse($"Could not find material at path: {materialPath}");
}
Color color;
try
{
color = MaterialOps.ParseColor(colorToken, UnityJsonSerializer.Instance);
}
catch (Exception e)
{
return new ErrorResponse($"Invalid color format: {e.Message}");
}
Undo.RecordObject(mat, "Set Material Color");
bool foundProp = false;
if (!string.IsNullOrEmpty(property))
{
if (mat.HasProperty(property))
{
mat.SetColor(property, color);
foundProp = true;
}
}
else
{
// Fallback logic: _BaseColor (URP/HDRP) then _Color (Built-in)
if (mat.HasProperty("_BaseColor"))
{
mat.SetColor("_BaseColor", color);
foundProp = true;
property = "_BaseColor";
}
else if (mat.HasProperty("_Color"))
{
mat.SetColor("_Color", color);
foundProp = true;
property = "_Color";
}
}
if (foundProp)
{
EditorUtility.SetDirty(mat);
return new SuccessResponse($"Set color on {property}");
}
else
{
return new ErrorResponse("Could not find suitable color property (_BaseColor or _Color) or specified property does not exist.");
}
}
private static object AssignMaterialToRenderer(JObject @params)
{
string target = @params["target"]?.ToString();
string searchMethod = @params["searchMethod"]?.ToString();
string materialPath = NormalizePath(@params["materialPath"]?.ToString());
int slot = @params["slot"]?.ToObject<int>() ?? 0;
if (string.IsNullOrEmpty(target) || string.IsNullOrEmpty(materialPath))
{
return new ErrorResponse("target and materialPath are required");
}
var goInstruction = new JObject { ["find"] = target };
if (!string.IsNullOrEmpty(searchMethod)) goInstruction["method"] = searchMethod;
GameObject go = ObjectResolver.Resolve(goInstruction, typeof(GameObject)) as GameObject;
if (go == null)
{
return new ErrorResponse($"Could not find target GameObject: {target}");
}
Renderer renderer = go.GetComponent<Renderer>();
if (renderer == null)
{
return new ErrorResponse($"GameObject {go.name} has no Renderer component");
}
var matInstruction = new JObject { ["find"] = materialPath };
Material mat = ObjectResolver.Resolve(matInstruction, typeof(Material)) as Material;
if (mat == null)
{
return new ErrorResponse($"Could not find material: {materialPath}");
}
Undo.RecordObject(renderer, "Assign Material");
Material[] sharedMats = renderer.sharedMaterials;
if (slot < 0 || slot >= sharedMats.Length)
{
return new ErrorResponse($"Slot {slot} out of bounds (count: {sharedMats.Length})");
}
sharedMats[slot] = mat;
renderer.sharedMaterials = sharedMats;
EditorUtility.SetDirty(renderer);
return new SuccessResponse($"Assigned material {mat.name} to {go.name} slot {slot}");
}
private static object SetRendererColor(JObject @params)
{
string target = @params["target"]?.ToString();
string searchMethod = @params["searchMethod"]?.ToString();
JToken colorToken = @params["color"];
int slot = @params["slot"]?.ToObject<int>() ?? 0;
string mode = @params["mode"]?.ToString() ?? "property_block";
if (string.IsNullOrEmpty(target) || colorToken == null)
{
return new ErrorResponse("target and color are required");
}
Color color;
try
{
color = MaterialOps.ParseColor(colorToken, UnityJsonSerializer.Instance);
}
catch (Exception e)
{
return new ErrorResponse($"Invalid color format: {e.Message}");
}
var goInstruction = new JObject { ["find"] = target };
if (!string.IsNullOrEmpty(searchMethod)) goInstruction["method"] = searchMethod;
GameObject go = ObjectResolver.Resolve(goInstruction, typeof(GameObject)) as GameObject;
if (go == null)
{
return new ErrorResponse($"Could not find target GameObject: {target}");
}
Renderer renderer = go.GetComponent<Renderer>();
if (renderer == null)
{
return new ErrorResponse($"GameObject {go.name} has no Renderer component");
}
if (mode == "property_block")
{
if (slot < 0 || slot >= renderer.sharedMaterials.Length)
{
return new ErrorResponse($"Slot {slot} out of bounds (count: {renderer.sharedMaterials.Length})");
}
MaterialPropertyBlock block = new MaterialPropertyBlock();
renderer.GetPropertyBlock(block, slot);
if (renderer.sharedMaterials[slot] != null)
{
Material mat = renderer.sharedMaterials[slot];
if (mat.HasProperty("_BaseColor")) block.SetColor("_BaseColor", color);
else if (mat.HasProperty("_Color")) block.SetColor("_Color", color);
else block.SetColor("_Color", color);
}
else
{
block.SetColor("_Color", color);
}
renderer.SetPropertyBlock(block, slot);
EditorUtility.SetDirty(renderer);
return new SuccessResponse($"Set renderer color (PropertyBlock) on slot {slot}");
}
else if (mode == "shared")
{
if (slot >= 0 && slot < renderer.sharedMaterials.Length)
{
Material mat = renderer.sharedMaterials[slot];
if (mat == null)
{
return new ErrorResponse($"No material in slot {slot}");
}
Undo.RecordObject(mat, "Set Material Color");
if (mat.HasProperty("_BaseColor")) mat.SetColor("_BaseColor", color);
else mat.SetColor("_Color", color);
EditorUtility.SetDirty(mat);
return new SuccessResponse("Set shared material color");
}
return new ErrorResponse("Invalid slot");
}
else if (mode == "instance")
{
if (slot >= 0 && slot < renderer.materials.Length)
{
Material mat = renderer.materials[slot];
if (mat == null)
{
return new ErrorResponse($"No material in slot {slot}");
}
// Note: Undo cannot fully revert material instantiation
Undo.RecordObject(mat, "Set Instance Material Color");
if (mat.HasProperty("_BaseColor")) mat.SetColor("_BaseColor", color);
else mat.SetColor("_Color", color);
return new SuccessResponse("Set instance material color", new { warning = "Material instance created; Undo cannot fully revert instantiation." });
}
return new ErrorResponse("Invalid slot");
}
return new ErrorResponse($"Unknown mode: {mode}");
}
private static object GetMaterialInfo(JObject @params)
{
string materialPath = NormalizePath(@params["materialPath"]?.ToString());
if (string.IsNullOrEmpty(materialPath))
{
return new ErrorResponse("materialPath is required");
}
var findInstruction = new JObject { ["find"] = materialPath };
Material mat = ObjectResolver.Resolve(findInstruction, typeof(Material)) as Material;
if (mat == null)
{
return new ErrorResponse($"Could not find material at path: {materialPath}");
}
Shader shader = mat.shader;
var properties = new List<object>();
#if UNITY_6000_0_OR_NEWER
int propertyCount = shader.GetPropertyCount();
for (int i = 0; i < propertyCount; i++)
{
string name = shader.GetPropertyName(i);
var type = shader.GetPropertyType(i);
string description = shader.GetPropertyDescription(i);
object currentValue = null;
try
{
if (mat.HasProperty(name))
{
switch (type)
{
case UnityEngine.Rendering.ShaderPropertyType.Color:
var c = mat.GetColor(name);
currentValue = new { r = c.r, g = c.g, b = c.b, a = c.a };
break;
case UnityEngine.Rendering.ShaderPropertyType.Vector:
var v = mat.GetVector(name);
currentValue = new { x = v.x, y = v.y, z = v.z, w = v.w };
break;
case UnityEngine.Rendering.ShaderPropertyType.Float:
case UnityEngine.Rendering.ShaderPropertyType.Range:
currentValue = mat.GetFloat(name);
break;
case UnityEngine.Rendering.ShaderPropertyType.Texture:
currentValue = mat.GetTexture(name)?.name ?? "null";
break;
}
}
}
catch (Exception ex)
{
currentValue = $"<error: {ex.Message}>";
}
properties.Add(new
{
name = name,
type = type.ToString(),
description = description,
value = currentValue
});
}
#else
int propertyCount = ShaderUtil.GetPropertyCount(shader);
for (int i = 0; i < propertyCount; i++)
{
string name = ShaderUtil.GetPropertyName(shader, i);
ShaderUtil.ShaderPropertyType type = ShaderUtil.GetPropertyType(shader, i);
string description = ShaderUtil.GetPropertyDescription(shader, i);
object currentValue = null;
try
{
if (mat.HasProperty(name))
{
switch (type)
{
case ShaderUtil.ShaderPropertyType.Color:
var c = mat.GetColor(name);
currentValue = new { r = c.r, g = c.g, b = c.b, a = c.a };
break;
case ShaderUtil.ShaderPropertyType.Vector:
var v = mat.GetVector(name);
currentValue = new { x = v.x, y = v.y, z = v.z, w = v.w };
break;
case ShaderUtil.ShaderPropertyType.Float: currentValue = mat.GetFloat(name); break;
case ShaderUtil.ShaderPropertyType.Range: currentValue = mat.GetFloat(name); break;
case ShaderUtil.ShaderPropertyType.TexEnv: currentValue = mat.GetTexture(name)?.name ?? "null"; break;
}
}
}
catch (Exception ex)
{
currentValue = $"<error: {ex.Message}>";
}
properties.Add(new
{
name = name,
type = type.ToString(),
description = description,
value = currentValue
});
}
#endif
return new SuccessResponse($"Retrieved material info for {mat.name}", new
{
material = mat.name,
shader = shader.name,
properties = properties
});
}
private static object CreateMaterial(JObject @params)
{
string materialPath = NormalizePath(@params["materialPath"]?.ToString());
string shaderName = @params["shader"]?.ToString() ?? "Standard";
JToken colorToken = @params["color"];
string colorProperty = @params["property"]?.ToString();
JObject properties = null;
JToken propsToken = @params["properties"];
if (propsToken != null)
{
if (propsToken.Type == JTokenType.String)
{
try { properties = JObject.Parse(propsToken.ToString()); }
catch (Exception ex) { return new ErrorResponse($"Invalid JSON in properties: {ex.Message}"); }
}
else if (propsToken is JObject obj)
{
properties = obj;
}
}
if (string.IsNullOrEmpty(materialPath))
{
return new ErrorResponse("materialPath is required");
}
// Safety check: SanitizeAssetPath should guarantee Assets/ prefix
// This check catches edge cases where normalization might fail
if (!materialPath.StartsWith("Assets/"))
{
return new ErrorResponse($"Invalid path '{materialPath}'. Path must be within Assets/ folder.");
}
Shader shader = RenderPipelineUtility.ResolveShader(shaderName);
if (shader == null)
{
return new ErrorResponse($"Could not find shader: {shaderName}");
}
// Check for existing asset to avoid silent overwrite
if (AssetDatabase.LoadAssetAtPath<Material>(materialPath) != null)
{
return new ErrorResponse($"Material already exists at {materialPath}");
}
Material material = null;
var shouldDestroyMaterial = true;
try
{
material = new Material(shader);
// Apply color param during creation (keeps Python tool signature and C# implementation consistent).
// If "properties" already contains a color property, let properties win.
bool shouldApplyColor = false;
if (colorToken != null)
{
if (properties == null)
{
shouldApplyColor = true;
}
else if (!string.IsNullOrEmpty(colorProperty))
{
// If colorProperty is specified, only check that specific property.
shouldApplyColor = !properties.ContainsKey(colorProperty);
}
else
{
// If colorProperty is not specified, check fallback properties.
shouldApplyColor = !properties.ContainsKey("_BaseColor") && !properties.ContainsKey("_Color");
}
}
if (shouldApplyColor)
{
Color color;
try
{
color = MaterialOps.ParseColor(colorToken, UnityJsonSerializer.Instance);
}
catch (Exception e)
{
return new ErrorResponse($"Invalid color format: {e.Message}");
}
if (!string.IsNullOrEmpty(colorProperty))
{
if (material.HasProperty(colorProperty))
{
material.SetColor(colorProperty, color);
}
else
{
return new ErrorResponse($"Specified color property '{colorProperty}' does not exist on this material.");
}
}
else if (material.HasProperty("_BaseColor"))
{
material.SetColor("_BaseColor", color);
}
else if (material.HasProperty("_Color"))
{
material.SetColor("_Color", color);
}
else
{
return new ErrorResponse("Could not find suitable color property (_BaseColor or _Color) on this material's shader.");
}
}
AssetDatabase.CreateAsset(material, materialPath);
shouldDestroyMaterial = false; // material is now owned by the AssetDatabase
if (properties != null)
{
MaterialOps.ApplyProperties(material, properties, UnityJsonSerializer.Instance);
}
EditorUtility.SetDirty(material);
AssetDatabase.SaveAssets();
return new SuccessResponse($"Created material at {materialPath} with shader {shaderName}");
}
finally
{
if (shouldDestroyMaterial && material != null)
{
UnityEngine.Object.DestroyImmediate(material);
}
}
}
}
}

View File

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

View File

@@ -0,0 +1,838 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using MCPForUnity.Editor.Helpers; // For Response class
using MCPForUnity.Runtime.Helpers; // For ScreenshotUtility
using Newtonsoft.Json.Linq;
using UnityEditor;
using UnityEditor.SceneManagement;
using UnityEngine;
using UnityEngine.SceneManagement;
namespace MCPForUnity.Editor.Tools
{
/// <summary>
/// Handles scene management operations like loading, saving, creating, and querying hierarchy.
/// </summary>
[McpForUnityTool("manage_scene", AutoRegister = false)]
public static class ManageScene
{
private sealed class SceneCommand
{
public string action { get; set; } = string.Empty;
public string name { get; set; } = string.Empty;
public string path { get; set; } = string.Empty;
public int? buildIndex { get; set; }
public string fileName { get; set; } = string.Empty;
public int? superSize { get; set; }
// get_hierarchy paging + safety (summary-first)
public JToken parent { get; set; }
public int? pageSize { get; set; }
public int? cursor { get; set; }
public int? maxNodes { get; set; }
public int? maxDepth { get; set; }
public int? maxChildrenPerNode { get; set; }
public bool? includeTransform { get; set; }
}
private static SceneCommand ToSceneCommand(JObject p)
{
if (p == null) return new SceneCommand();
return new SceneCommand
{
action = (p["action"]?.ToString() ?? string.Empty).Trim().ToLowerInvariant(),
name = p["name"]?.ToString() ?? string.Empty,
path = p["path"]?.ToString() ?? string.Empty,
buildIndex = ParamCoercion.CoerceIntNullable(p["buildIndex"] ?? p["build_index"]),
fileName = (p["fileName"] ?? p["filename"])?.ToString() ?? string.Empty,
superSize = ParamCoercion.CoerceIntNullable(p["superSize"] ?? p["super_size"] ?? p["supersize"]),
// get_hierarchy paging + safety
parent = p["parent"],
pageSize = ParamCoercion.CoerceIntNullable(p["pageSize"] ?? p["page_size"]),
cursor = ParamCoercion.CoerceIntNullable(p["cursor"]),
maxNodes = ParamCoercion.CoerceIntNullable(p["maxNodes"] ?? p["max_nodes"]),
maxDepth = ParamCoercion.CoerceIntNullable(p["maxDepth"] ?? p["max_depth"]),
maxChildrenPerNode = ParamCoercion.CoerceIntNullable(p["maxChildrenPerNode"] ?? p["max_children_per_node"]),
includeTransform = ParamCoercion.CoerceBoolNullable(p["includeTransform"] ?? p["include_transform"]),
};
}
/// <summary>
/// Main handler for scene management actions.
/// </summary>
public static object HandleCommand(JObject @params)
{
try { McpLog.Info("[ManageScene] HandleCommand: start", always: false); } catch { }
var cmd = ToSceneCommand(@params);
string action = cmd.action;
string name = string.IsNullOrEmpty(cmd.name) ? null : cmd.name;
string path = string.IsNullOrEmpty(cmd.path) ? null : cmd.path; // Relative to Assets/
int? buildIndex = cmd.buildIndex;
// bool loadAdditive = @params["loadAdditive"]?.ToObject<bool>() ?? false; // Example for future extension
// Ensure path is relative to Assets/, removing any leading "Assets/"
string relativeDir = path ?? string.Empty;
if (!string.IsNullOrEmpty(relativeDir))
{
relativeDir = AssetPathUtility.NormalizeSeparators(relativeDir).Trim('/');
if (relativeDir.StartsWith("Assets/", StringComparison.OrdinalIgnoreCase))
{
relativeDir = relativeDir.Substring("Assets/".Length).TrimStart('/');
}
}
// Apply default *after* sanitizing, using the original path variable for the check
if (string.IsNullOrEmpty(path) && action == "create") // Check original path for emptiness
{
relativeDir = "Scenes"; // Default relative directory
}
if (string.IsNullOrEmpty(action))
{
return new ErrorResponse("Action parameter is required.");
}
string sceneFileName = string.IsNullOrEmpty(name) ? null : $"{name}.unity";
// Construct full system path correctly: ProjectRoot/Assets/relativeDir/sceneFileName
string fullPathDir = Path.Combine(Application.dataPath, relativeDir); // Combine with Assets path (Application.dataPath ends in Assets)
string fullPath = string.IsNullOrEmpty(sceneFileName)
? null
: Path.Combine(fullPathDir, sceneFileName);
// Ensure relativePath always starts with "Assets/" and uses forward slashes
string relativePath = string.IsNullOrEmpty(sceneFileName)
? null
: AssetPathUtility.NormalizeSeparators(Path.Combine("Assets", relativeDir, sceneFileName));
// Ensure directory exists for 'create'
if (action == "create" && !string.IsNullOrEmpty(fullPathDir))
{
try
{
Directory.CreateDirectory(fullPathDir);
}
catch (Exception e)
{
return new ErrorResponse(
$"Could not create directory '{fullPathDir}': {e.Message}"
);
}
}
// Route action
try { McpLog.Info($"[ManageScene] Route action='{action}' name='{name}' path='{path}' buildIndex={(buildIndex.HasValue ? buildIndex.Value.ToString() : "null")}", always: false); } catch { }
switch (action)
{
case "create":
if (string.IsNullOrEmpty(name) || string.IsNullOrEmpty(relativePath))
return new ErrorResponse(
"'name' and 'path' parameters are required for 'create' action."
);
return CreateScene(fullPath, relativePath);
case "load":
// Loading can be done by path/name or build index
if (!string.IsNullOrEmpty(relativePath))
return LoadScene(relativePath);
else if (buildIndex.HasValue)
return LoadScene(buildIndex.Value);
else
return new ErrorResponse(
"Either 'name'/'path' or 'buildIndex' must be provided for 'load' action."
);
case "save":
// Save current scene, optionally to a new path
return SaveScene(fullPath, relativePath);
case "get_hierarchy":
try { McpLog.Info("[ManageScene] get_hierarchy: entering", always: false); } catch { }
var gh = GetSceneHierarchyPaged(cmd);
try { McpLog.Info("[ManageScene] get_hierarchy: exiting", always: false); } catch { }
return gh;
case "get_active":
try { McpLog.Info("[ManageScene] get_active: entering", always: false); } catch { }
var ga = GetActiveSceneInfo();
try { McpLog.Info("[ManageScene] get_active: exiting", always: false); } catch { }
return ga;
case "get_build_settings":
return GetBuildSettingsScenes();
case "screenshot":
return CaptureScreenshot(cmd.fileName, cmd.superSize);
// Add cases for modifying build settings, additive loading, unloading etc.
default:
return new ErrorResponse(
$"Unknown action: '{action}'. Valid actions: create, load, save, get_hierarchy, get_active, get_build_settings, screenshot."
);
}
}
/// <summary>
/// Captures a screenshot to Assets/Screenshots and returns a response payload.
/// Public so the tools UI can reuse the same logic without duplicating parameters.
/// Available in both Edit Mode and Play Mode.
/// </summary>
public static object ExecuteScreenshot(string fileName = null, int? superSize = null)
{
return CaptureScreenshot(fileName, superSize);
}
private static object CreateScene(string fullPath, string relativePath)
{
if (File.Exists(fullPath))
{
return new ErrorResponse($"Scene already exists at '{relativePath}'.");
}
try
{
// Create a new empty scene
Scene newScene = EditorSceneManager.NewScene(
NewSceneSetup.EmptyScene,
NewSceneMode.Single
);
// Save it to the specified path
bool saved = EditorSceneManager.SaveScene(newScene, relativePath);
if (saved)
{
AssetDatabase.Refresh(ImportAssetOptions.ForceSynchronousImport); // Ensure Unity sees the new scene file
return new SuccessResponse(
$"Scene '{Path.GetFileName(relativePath)}' created successfully at '{relativePath}'.",
new { path = relativePath }
);
}
else
{
// If SaveScene fails, it might leave an untitled scene open.
// Optionally try to close it, but be cautious.
return new ErrorResponse($"Failed to save new scene to '{relativePath}'.");
}
}
catch (Exception e)
{
return new ErrorResponse($"Error creating scene '{relativePath}': {e.Message}");
}
}
private static object LoadScene(string relativePath)
{
if (
!File.Exists(
Path.Combine(
Application.dataPath.Substring(
0,
Application.dataPath.Length - "Assets".Length
),
relativePath
)
)
)
{
return new ErrorResponse($"Scene file not found at '{relativePath}'.");
}
// Check for unsaved changes in the current scene
if (EditorSceneManager.GetActiveScene().isDirty)
{
// Optionally prompt the user or save automatically before loading
return new ErrorResponse(
"Current scene has unsaved changes. Please save or discard changes before loading a new scene."
);
// Example: bool saveOK = EditorSceneManager.SaveCurrentModifiedScenesIfUserWantsTo();
// if (!saveOK) return new ErrorResponse("Load cancelled by user.");
}
try
{
EditorSceneManager.OpenScene(relativePath, OpenSceneMode.Single);
return new SuccessResponse(
$"Scene '{relativePath}' loaded successfully.",
new
{
path = relativePath,
name = Path.GetFileNameWithoutExtension(relativePath),
}
);
}
catch (Exception e)
{
return new ErrorResponse($"Error loading scene '{relativePath}': {e.Message}");
}
}
private static object LoadScene(int buildIndex)
{
if (buildIndex < 0 || buildIndex >= SceneManager.sceneCountInBuildSettings)
{
return new ErrorResponse(
$"Invalid build index: {buildIndex}. Must be between 0 and {SceneManager.sceneCountInBuildSettings - 1}."
);
}
// Check for unsaved changes
if (EditorSceneManager.GetActiveScene().isDirty)
{
return new ErrorResponse(
"Current scene has unsaved changes. Please save or discard changes before loading a new scene."
);
}
try
{
string scenePath = SceneUtility.GetScenePathByBuildIndex(buildIndex);
EditorSceneManager.OpenScene(scenePath, OpenSceneMode.Single);
return new SuccessResponse(
$"Scene at build index {buildIndex} ('{scenePath}') loaded successfully.",
new
{
path = scenePath,
name = Path.GetFileNameWithoutExtension(scenePath),
buildIndex = buildIndex,
}
);
}
catch (Exception e)
{
return new ErrorResponse(
$"Error loading scene with build index {buildIndex}: {e.Message}"
);
}
}
private static object SaveScene(string fullPath, string relativePath)
{
try
{
Scene currentScene = EditorSceneManager.GetActiveScene();
if (!currentScene.IsValid())
{
return new ErrorResponse("No valid scene is currently active to save.");
}
bool saved;
string finalPath = currentScene.path; // Path where it was last saved or will be saved
if (!string.IsNullOrEmpty(relativePath) && currentScene.path != relativePath)
{
// Save As...
// Ensure directory exists
string dir = Path.GetDirectoryName(fullPath);
if (!Directory.Exists(dir))
Directory.CreateDirectory(dir);
saved = EditorSceneManager.SaveScene(currentScene, relativePath);
finalPath = relativePath;
}
else
{
// Save (overwrite existing or save untitled)
if (string.IsNullOrEmpty(currentScene.path))
{
// Scene is untitled, needs a path
return new ErrorResponse(
"Cannot save an untitled scene without providing a 'name' and 'path'. Use Save As functionality."
);
}
saved = EditorSceneManager.SaveScene(currentScene);
}
if (saved)
{
AssetDatabase.Refresh(ImportAssetOptions.ForceSynchronousImport);
return new SuccessResponse(
$"Scene '{currentScene.name}' saved successfully to '{finalPath}'.",
new { path = finalPath, name = currentScene.name }
);
}
else
{
return new ErrorResponse($"Failed to save scene '{currentScene.name}'.");
}
}
catch (Exception e)
{
return new ErrorResponse($"Error saving scene: {e.Message}");
}
}
private static object CaptureScreenshot(string fileName, int? superSize)
{
try
{
int resolvedSuperSize = (superSize.HasValue && superSize.Value > 0) ? superSize.Value : 1;
// Batch mode warning
if (Application.isBatchMode)
{
McpLog.Warn("[ManageScene] Screenshot capture in batch mode uses camera-based fallback. Results may vary.");
}
// Check Screen Capture module availability and warn if not available
bool screenCaptureAvailable = ScreenshotUtility.IsScreenCaptureModuleAvailable;
bool hasCameraFallback = Camera.main != null || UnityEngine.Object.FindObjectsOfType<Camera>().Length > 0;
#if UNITY_2022_1_OR_NEWER
if (!screenCaptureAvailable && !hasCameraFallback)
{
return new ErrorResponse(
"Cannot capture screenshot. The Screen Capture module is not enabled and no Camera was found in the scene. " +
"Please either: (1) Enable the Screen Capture module: Window > Package Manager > Built-in > Screen Capture > Enable, " +
"or (2) Add a Camera to your scene for camera-based fallback capture."
);
}
if (!screenCaptureAvailable)
{
McpLog.Warn("[ManageScene] Screen Capture module not enabled. Using camera-based fallback. " +
"For best results, enable it: Window > Package Manager > Built-in > Screen Capture > Enable.");
}
#else
if (!hasCameraFallback)
{
return new ErrorResponse(
"No camera found in the scene. Screenshot capture on Unity versions before 2022.1 requires a Camera in the scene. " +
"Please add a Camera to your scene or upgrade to Unity 2022.1+ for ScreenCapture API support."
);
}
#endif
// Best-effort: ensure Game View exists and repaints before capture.
if (!Application.isBatchMode)
{
EnsureGameView();
}
ScreenshotCaptureResult result = ScreenshotUtility.CaptureToAssetsFolder(fileName, resolvedSuperSize, ensureUniqueFileName: true);
// ScreenCapture.CaptureScreenshot is async. Import after the file actually hits disk.
if (result.IsAsync)
{
ScheduleAssetImportWhenFileExists(result.AssetsRelativePath, result.FullPath, timeoutSeconds: 30.0);
}
else
{
AssetDatabase.ImportAsset(result.AssetsRelativePath, ImportAssetOptions.ForceSynchronousImport);
}
string verb = result.IsAsync ? "Screenshot requested" : "Screenshot captured";
string message = $"{verb} to '{result.AssetsRelativePath}' (full: {result.FullPath}).";
return new SuccessResponse(
message,
new
{
path = result.AssetsRelativePath,
fullPath = result.FullPath,
superSize = result.SuperSize,
isAsync = result.IsAsync,
}
);
}
catch (Exception e)
{
return new ErrorResponse($"Error capturing screenshot: {e.Message}");
}
}
private static void EnsureGameView()
{
try
{
// Ensure a Game View exists and has a chance to repaint before capture.
try
{
if (!EditorApplication.ExecuteMenuItem("Window/General/Game"))
{
// Some Unity versions expose hotkey suffixes in menu paths.
EditorApplication.ExecuteMenuItem("Window/General/Game %2");
}
}
catch (Exception e)
{
try { McpLog.Debug($"[ManageScene] screenshot: failed to open Game View via menu item: {e.Message}"); } catch { }
}
try
{
var gameViewType = Type.GetType("UnityEditor.GameView,UnityEditor");
if (gameViewType != null)
{
var window = EditorWindow.GetWindow(gameViewType);
window?.Repaint();
}
}
catch (Exception e)
{
try { McpLog.Debug($"[ManageScene] screenshot: failed to repaint Game View: {e.Message}"); } catch { }
}
try { SceneView.RepaintAll(); }
catch (Exception e)
{
try { McpLog.Debug($"[ManageScene] screenshot: failed to repaint Scene View: {e.Message}"); } catch { }
}
try { EditorApplication.QueuePlayerLoopUpdate(); }
catch (Exception e)
{
try { McpLog.Debug($"[ManageScene] screenshot: failed to queue player loop update: {e.Message}"); } catch { }
}
}
catch (Exception e)
{
try { McpLog.Debug($"[ManageScene] screenshot: EnsureGameView failed: {e.Message}"); } catch { }
}
}
private static void ScheduleAssetImportWhenFileExists(string assetsRelativePath, string fullPath, double timeoutSeconds)
{
if (string.IsNullOrWhiteSpace(assetsRelativePath) || string.IsNullOrWhiteSpace(fullPath))
{
McpLog.Warn("[ManageScene] ScheduleAssetImportWhenFileExists: invalid paths provided, skipping import scheduling.");
return;
}
double start = EditorApplication.timeSinceStartup;
int failureCount = 0;
bool hasSeenFile = false;
const int maxLoggedFailures = 3;
EditorApplication.CallbackFunction tick = null;
tick = () =>
{
try
{
if (File.Exists(fullPath))
{
hasSeenFile = true;
AssetDatabase.ImportAsset(assetsRelativePath, ImportAssetOptions.ForceSynchronousImport);
McpLog.Debug($"[ManageScene] Imported asset at '{assetsRelativePath}'.");
EditorApplication.update -= tick;
return;
}
}
catch (Exception e)
{
failureCount++;
if (failureCount <= maxLoggedFailures)
{
McpLog.Warn($"[ManageScene] Exception while importing asset '{assetsRelativePath}' from '{fullPath}' (attempt {failureCount}): {e}");
}
}
if (EditorApplication.timeSinceStartup - start > timeoutSeconds)
{
if (!hasSeenFile)
{
McpLog.Warn($"[ManageScene] Timed out waiting for file '{fullPath}' (asset: '{assetsRelativePath}') after {timeoutSeconds:F1} seconds. The asset was not imported.");
}
else
{
McpLog.Warn($"[ManageScene] Timed out importing asset '{assetsRelativePath}' from '{fullPath}' after {timeoutSeconds:F1} seconds. The file existed but the asset was not imported.");
}
EditorApplication.update -= tick;
}
};
EditorApplication.update += tick;
}
private static object GetActiveSceneInfo()
{
try
{
try { McpLog.Info("[ManageScene] get_active: querying EditorSceneManager.GetActiveScene", always: false); } catch { }
Scene activeScene = EditorSceneManager.GetActiveScene();
try { McpLog.Info($"[ManageScene] get_active: got scene valid={activeScene.IsValid()} loaded={activeScene.isLoaded} name='{activeScene.name}'", always: false); } catch { }
if (!activeScene.IsValid())
{
return new ErrorResponse("No active scene found.");
}
var sceneInfo = new
{
name = activeScene.name,
path = activeScene.path,
buildIndex = activeScene.buildIndex, // -1 if not in build settings
isDirty = activeScene.isDirty,
isLoaded = activeScene.isLoaded,
rootCount = activeScene.rootCount,
};
return new SuccessResponse("Retrieved active scene information.", sceneInfo);
}
catch (Exception e)
{
try { McpLog.Error($"[ManageScene] get_active: exception {e.Message}"); } catch { }
return new ErrorResponse($"Error getting active scene info: {e.Message}");
}
}
private static object GetBuildSettingsScenes()
{
try
{
var scenes = new List<object>();
for (int i = 0; i < EditorBuildSettings.scenes.Length; i++)
{
var scene = EditorBuildSettings.scenes[i];
scenes.Add(
new
{
path = scene.path,
guid = scene.guid.ToString(),
enabled = scene.enabled,
buildIndex = i, // Actual build index considering only enabled scenes might differ
}
);
}
return new SuccessResponse("Retrieved scenes from Build Settings.", scenes);
}
catch (Exception e)
{
return new ErrorResponse($"Error getting scenes from Build Settings: {e.Message}");
}
}
private static object GetSceneHierarchyPaged(SceneCommand cmd)
{
try
{
// Check Prefab Stage first
var prefabStage = PrefabStageUtility.GetCurrentPrefabStage();
Scene activeScene;
if (prefabStage != null)
{
activeScene = prefabStage.scene;
try { McpLog.Info("[ManageScene] get_hierarchy: using Prefab Stage scene", always: false); } catch { }
}
else
{
try { McpLog.Info("[ManageScene] get_hierarchy: querying EditorSceneManager.GetActiveScene", always: false); } catch { }
activeScene = EditorSceneManager.GetActiveScene();
}
try { McpLog.Info($"[ManageScene] get_hierarchy: got scene valid={activeScene.IsValid()} loaded={activeScene.isLoaded} name='{activeScene.name}'", always: false); } catch { }
if (!activeScene.IsValid() || !activeScene.isLoaded)
{
return new ErrorResponse(
"No valid and loaded scene is active to get hierarchy from."
);
}
// Defaults tuned for safety; callers can override but we clamp to sane maxes.
// NOTE: pageSize is "items per page", not "number of pages".
// Keep this conservative to reduce peak response sizes when callers omit page_size.
int resolvedPageSize = Mathf.Clamp(cmd.pageSize ?? 50, 1, 500);
int resolvedCursor = Mathf.Max(0, cmd.cursor ?? 0);
int resolvedMaxNodes = Mathf.Clamp(cmd.maxNodes ?? 1000, 1, 5000);
int effectiveTake = Mathf.Min(resolvedPageSize, resolvedMaxNodes);
int resolvedMaxChildrenPerNode = Mathf.Clamp(cmd.maxChildrenPerNode ?? 200, 0, 2000);
bool includeTransform = cmd.includeTransform ?? false;
// NOTE: maxDepth is accepted for forward-compatibility, but current paging mode
// returns a single level (roots or direct children). This keeps payloads bounded.
List<GameObject> nodes;
string scope;
GameObject parentGo = ResolveGameObject(cmd.parent, activeScene);
if (cmd.parent == null || cmd.parent.Type == JTokenType.Null)
{
try { McpLog.Info("[ManageScene] get_hierarchy: listing root objects (paged summary)", always: false); } catch { }
nodes = activeScene.GetRootGameObjects().Where(go => go != null).ToList();
scope = "roots";
}
else
{
if (parentGo == null)
{
return new ErrorResponse($"Parent GameObject ('{cmd.parent}') not found.");
}
try { McpLog.Info($"[ManageScene] get_hierarchy: listing children of '{parentGo.name}' (paged summary)", always: false); } catch { }
nodes = new List<GameObject>(parentGo.transform.childCount);
foreach (Transform child in parentGo.transform)
{
if (child != null) nodes.Add(child.gameObject);
}
scope = "children";
}
int total = nodes.Count;
if (resolvedCursor > total) resolvedCursor = total;
int end = Mathf.Min(total, resolvedCursor + effectiveTake);
var items = new List<object>(Mathf.Max(0, end - resolvedCursor));
for (int i = resolvedCursor; i < end; i++)
{
var go = nodes[i];
if (go == null) continue;
items.Add(BuildGameObjectSummary(go, includeTransform, resolvedMaxChildrenPerNode));
}
bool truncated = end < total;
string nextCursor = truncated ? end.ToString() : null;
var payload = new
{
scope = scope,
cursor = resolvedCursor,
pageSize = effectiveTake,
next_cursor = nextCursor,
truncated = truncated,
total = total,
items = items,
};
var resp = new SuccessResponse($"Retrieved hierarchy page for scene '{activeScene.name}'.", payload);
try { McpLog.Info("[ManageScene] get_hierarchy: success", always: false); } catch { }
return resp;
}
catch (Exception e)
{
try { McpLog.Error($"[ManageScene] get_hierarchy: exception {e.Message}"); } catch { }
return new ErrorResponse($"Error getting scene hierarchy: {e.Message}");
}
}
private static GameObject ResolveGameObject(JToken targetToken, Scene activeScene)
{
if (targetToken == null || targetToken.Type == JTokenType.Null) return null;
try
{
if (targetToken.Type == JTokenType.Integer || int.TryParse(targetToken.ToString(), out _))
{
if (int.TryParse(targetToken.ToString(), out int id))
{
var obj = EditorUtility.InstanceIDToObject(id);
if (obj is GameObject go) return go;
if (obj is Component c) return c.gameObject;
}
}
}
catch { }
string s = targetToken.ToString();
if (string.IsNullOrEmpty(s)) return null;
// Path-based find (e.g., "Root/Child/GrandChild")
if (s.Contains("/"))
{
try
{
var ids = GameObjectLookup.SearchGameObjects("by_path", s, includeInactive: true, maxResults: 1);
if (ids.Count > 0)
{
var byPath = GameObjectLookup.FindById(ids[0]);
if (byPath != null) return byPath;
}
}
catch { }
}
// Name-based find (first match, includes inactive)
try
{
var all = activeScene.GetRootGameObjects();
foreach (var root in all)
{
if (root == null) continue;
if (root.name == s) return root;
var trs = root.GetComponentsInChildren<Transform>(includeInactive: true);
foreach (var t in trs)
{
if (t != null && t.gameObject != null && t.gameObject.name == s) return t.gameObject;
}
}
}
catch { }
return null;
}
private static object BuildGameObjectSummary(GameObject go, bool includeTransform, int maxChildrenPerNode)
{
if (go == null) return null;
int childCount = 0;
try { childCount = go.transform != null ? go.transform.childCount : 0; } catch { }
bool childrenTruncated = childCount > 0; // We do not inline children in summary mode.
// Get component type names (lightweight - no full serialization)
var componentTypes = new List<string>();
try
{
var components = go.GetComponents<Component>();
if (components != null)
{
foreach (var c in components)
{
if (c != null)
{
componentTypes.Add(c.GetType().Name);
}
}
}
}
catch (Exception ex)
{
McpLog.Debug($"[ManageScene] Failed to enumerate components for '{go.name}': {ex.Message}");
}
var d = new Dictionary<string, object>
{
{ "name", go.name },
{ "instanceID", go.GetInstanceID() },
{ "activeSelf", go.activeSelf },
{ "activeInHierarchy", go.activeInHierarchy },
{ "tag", go.tag },
{ "layer", go.layer },
{ "isStatic", go.isStatic },
{ "path", GetGameObjectPath(go) },
{ "childCount", childCount },
{ "childrenTruncated", childrenTruncated },
{ "childrenCursor", childCount > 0 ? "0" : null },
{ "childrenPageSizeDefault", maxChildrenPerNode },
{ "componentTypes", componentTypes },
};
if (includeTransform && go.transform != null)
{
var t = go.transform;
d["transform"] = new
{
position = new[] { t.localPosition.x, t.localPosition.y, t.localPosition.z },
rotation = new[] { t.localRotation.eulerAngles.x, t.localRotation.eulerAngles.y, t.localRotation.eulerAngles.z },
scale = new[] { t.localScale.x, t.localScale.y, t.localScale.z },
};
}
return d;
}
private static string GetGameObjectPath(GameObject go)
{
if (go == null) return string.Empty;
try
{
var names = new Stack<string>();
Transform t = go.transform;
while (t != null)
{
names.Push(t.name);
t = t.parent;
}
return string.Join("/", names);
}
catch
{
return go.name;
}
}
}
}

View File

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

File diff suppressed because it is too large Load Diff

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -0,0 +1,344 @@
using System;
using System.IO;
using System.Linq;
using System.Text.RegularExpressions;
using MCPForUnity.Editor.Helpers;
using Newtonsoft.Json.Linq;
using UnityEditor;
using UnityEngine;
namespace MCPForUnity.Editor.Tools
{
/// <summary>
/// Handles CRUD operations for shader files within the Unity project.
/// </summary>
[McpForUnityTool("manage_shader", AutoRegister = false)]
public static class ManageShader
{
/// <summary>
/// Main handler for shader management actions.
/// </summary>
public static object HandleCommand(JObject @params)
{
// Extract parameters
string action = @params["action"]?.ToString()?.ToLowerInvariant();
string name = @params["name"]?.ToString();
string path = @params["path"]?.ToString(); // Relative to Assets/
string contents = null;
// Check if we have base64 encoded contents
bool contentsEncoded = @params["contentsEncoded"]?.ToObject<bool>() ?? false;
if (contentsEncoded && @params["encodedContents"] != null)
{
try
{
contents = DecodeBase64(@params["encodedContents"].ToString());
}
catch (Exception e)
{
return new ErrorResponse($"Failed to decode shader contents: {e.Message}");
}
}
else
{
contents = @params["contents"]?.ToString();
}
// Validate required parameters
if (string.IsNullOrEmpty(action))
{
return new ErrorResponse("Action parameter is required.");
}
if (string.IsNullOrEmpty(name))
{
return new ErrorResponse("Name parameter is required.");
}
// Basic name validation (alphanumeric, underscores, cannot start with number)
if (!Regex.IsMatch(name, @"^[a-zA-Z_][a-zA-Z0-9_]*$"))
{
return new ErrorResponse(
$"Invalid shader name: '{name}'. Use only letters, numbers, underscores, and don't start with a number."
);
}
// Ensure path is relative to Assets/, removing any leading "Assets/"
// Set default directory to "Shaders" if path is not provided
string relativeDir = path ?? "Shaders"; // Default to "Shaders" if path is null
if (!string.IsNullOrEmpty(relativeDir))
{
relativeDir = AssetPathUtility.NormalizeSeparators(relativeDir).Trim('/');
if (relativeDir.StartsWith("Assets/", StringComparison.OrdinalIgnoreCase))
{
relativeDir = relativeDir.Substring("Assets/".Length).TrimStart('/');
}
}
// Handle empty string case explicitly after processing
if (string.IsNullOrEmpty(relativeDir))
{
relativeDir = "Shaders"; // Ensure default if path was provided as "" or only "/" or "Assets/"
}
// Construct paths
string shaderFileName = $"{name}.shader";
string fullPathDir = Path.Combine(Application.dataPath, relativeDir);
string fullPath = Path.Combine(fullPathDir, shaderFileName);
string relativePath = AssetPathUtility.NormalizeSeparators(
Path.Combine("Assets", relativeDir, shaderFileName)
); // Ensure "Assets/" prefix and forward slashes
// Ensure the target directory exists for create/update
if (action == "create" || action == "update")
{
try
{
if (!Directory.Exists(fullPathDir))
{
Directory.CreateDirectory(fullPathDir);
// Refresh AssetDatabase to recognize new folders
AssetDatabase.Refresh(ImportAssetOptions.ForceSynchronousImport);
}
}
catch (Exception e)
{
return new ErrorResponse(
$"Could not create directory '{fullPathDir}': {e.Message}"
);
}
}
// Route to specific action handlers
switch (action)
{
case "create":
return CreateShader(fullPath, relativePath, name, contents);
case "read":
return ReadShader(fullPath, relativePath);
case "update":
return UpdateShader(fullPath, relativePath, name, contents);
case "delete":
return DeleteShader(fullPath, relativePath);
default:
return new ErrorResponse(
$"Unknown action: '{action}'. Valid actions are: create, read, update, delete."
);
}
}
/// <summary>
/// Decode base64 string to normal text
/// </summary>
private static string DecodeBase64(string encoded)
{
byte[] data = Convert.FromBase64String(encoded);
return System.Text.Encoding.UTF8.GetString(data);
}
/// <summary>
/// Encode text to base64 string
/// </summary>
private static string EncodeBase64(string text)
{
byte[] data = System.Text.Encoding.UTF8.GetBytes(text);
return Convert.ToBase64String(data);
}
private static object CreateShader(
string fullPath,
string relativePath,
string name,
string contents
)
{
// Check if shader already exists
if (File.Exists(fullPath))
{
return new ErrorResponse(
$"Shader already exists at '{relativePath}'. Use 'update' action to modify."
);
}
// Add validation for shader name conflicts in Unity
if (Shader.Find(name) != null)
{
return new ErrorResponse(
$"A shader with name '{name}' already exists in the project. Choose a different name."
);
}
// Generate default content if none provided
if (string.IsNullOrEmpty(contents))
{
contents = GenerateDefaultShaderContent(name);
}
try
{
File.WriteAllText(fullPath, contents, new System.Text.UTF8Encoding(false));
AssetDatabase.ImportAsset(relativePath);
AssetDatabase.Refresh(ImportAssetOptions.ForceSynchronousImport); // Ensure Unity recognizes the new shader
return new SuccessResponse(
$"Shader '{name}.shader' created successfully at '{relativePath}'.",
new { path = relativePath }
);
}
catch (Exception e)
{
return new ErrorResponse($"Failed to create shader '{relativePath}': {e.Message}");
}
}
private static object ReadShader(string fullPath, string relativePath)
{
if (!File.Exists(fullPath))
{
return new ErrorResponse($"Shader not found at '{relativePath}'.");
}
try
{
string contents = File.ReadAllText(fullPath);
// Return both normal and encoded contents for larger files
//TODO: Consider a threshold for large files
bool isLarge = contents.Length > 10000; // If content is large, include encoded version
var responseData = new
{
path = relativePath,
contents = contents,
// For large files, also include base64-encoded version
encodedContents = isLarge ? EncodeBase64(contents) : null,
contentsEncoded = isLarge,
};
return new SuccessResponse(
$"Shader '{Path.GetFileName(relativePath)}' read successfully.",
responseData
);
}
catch (Exception e)
{
return new ErrorResponse($"Failed to read shader '{relativePath}': {e.Message}");
}
}
private static object UpdateShader(
string fullPath,
string relativePath,
string name,
string contents
)
{
if (!File.Exists(fullPath))
{
return new ErrorResponse(
$"Shader not found at '{relativePath}'. Use 'create' action to add a new shader."
);
}
if (string.IsNullOrEmpty(contents))
{
return new ErrorResponse("Content is required for the 'update' action.");
}
try
{
File.WriteAllText(fullPath, contents, new System.Text.UTF8Encoding(false));
AssetDatabase.ImportAsset(relativePath);
AssetDatabase.Refresh(ImportAssetOptions.ForceSynchronousImport);
return new SuccessResponse(
$"Shader '{Path.GetFileName(relativePath)}' updated successfully.",
new { path = relativePath }
);
}
catch (Exception e)
{
return new ErrorResponse($"Failed to update shader '{relativePath}': {e.Message}");
}
}
private static object DeleteShader(string fullPath, string relativePath)
{
if (!File.Exists(fullPath))
{
return new ErrorResponse($"Shader not found at '{relativePath}'.");
}
try
{
// Delete the asset through Unity's AssetDatabase first
bool success = AssetDatabase.DeleteAsset(relativePath);
if (!success)
{
return new ErrorResponse($"Failed to delete shader through Unity's AssetDatabase: '{relativePath}'");
}
// If the file still exists (rare case), try direct deletion
if (File.Exists(fullPath))
{
File.Delete(fullPath);
}
return new SuccessResponse($"Shader '{Path.GetFileName(relativePath)}' deleted successfully.");
}
catch (Exception e)
{
return new ErrorResponse($"Failed to delete shader '{relativePath}': {e.Message}");
}
}
//This is a CGProgram template
//TODO: making a HLSL template as well?
private static string GenerateDefaultShaderContent(string name)
{
return @"Shader """ + name + @"""
{
Properties
{
_MainTex (""Texture"", 2D) = ""white"" {}
}
SubShader
{
Tags { ""RenderType""=""Opaque"" }
LOD 100
Pass
{
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
#include ""UnityCG.cginc""
struct appdata
{
float4 vertex : POSITION;
float2 uv : TEXCOORD0;
};
struct v2f
{
float2 uv : TEXCOORD0;
float4 vertex : SV_POSITION;
};
sampler2D _MainTex;
float4 _MainTex_ST;
v2f vert (appdata v)
{
v2f o;
o.vertex = UnityObjectToClipPos(v.vertex);
o.uv = TRANSFORM_TEX(v.uv, _MainTex);
return o;
}
fixed4 frag (v2f i) : SV_Target
{
fixed4 col = tex2D(_MainTex, i.uv);
return col;
}
ENDCG
}
}
}";
}
}
}

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -0,0 +1,107 @@
using System;
namespace MCPForUnity.Editor.Tools
{
/// <summary>
/// Marks a class as an MCP tool handler
/// </summary>
[AttributeUsage(AttributeTargets.Class, AllowMultiple = false)]
public class McpForUnityToolAttribute : Attribute
{
/// <summary>
/// Tool name (if null, derived from class name)
/// </summary>
public string Name { get; set; }
/// <summary>
/// Tool description for LLM
/// </summary>
public string Description { get; set; }
/// <summary>
/// Whether this tool returns structured output
/// </summary>
public bool StructuredOutput { get; set; } = true;
/// <summary>
/// Controls whether this tool is automatically registered with FastMCP.
/// Defaults to true so most tools opt-in automatically. Set to false
/// for legacy/built-in tools that already exist server-side.
/// </summary>
public bool AutoRegister { get; set; } = true;
/// <summary>
/// Enables the polling middleware for long-running tools. When true, Unity
/// should return a PendingResponse and the Python side will poll using
/// <see cref="PollAction"/> until completion.
/// </summary>
public bool RequiresPolling { get; set; } = false;
/// <summary>
/// The action name to use when polling for status. Defaults to "status".
/// </summary>
public string PollAction { get; set; } = "status";
/// <summary>
/// The command name used to route requests to this tool.
/// If not specified, defaults to the PascalCase class name converted to snake_case.
/// Kept for backward compatibility.
/// </summary>
public string CommandName
{
get => Name;
set => Name = value;
}
/// <summary>
/// Create an MCP tool attribute with auto-generated command name.
/// The command name will be derived from the class name (PascalCase → snake_case).
/// Example: ManageAsset → manage_asset
/// </summary>
public McpForUnityToolAttribute()
{
Name = null; // Will be auto-generated
}
/// <summary>
/// Create an MCP tool attribute with explicit command name.
/// </summary>
/// <param name="name">The command name (e.g., "manage_asset")</param>
public McpForUnityToolAttribute(string name = null)
{
Name = name;
}
}
/// <summary>
/// Describes a tool parameter
/// </summary>
[AttributeUsage(AttributeTargets.Property | AttributeTargets.Field, AllowMultiple = false)]
public class ToolParameterAttribute : Attribute
{
/// <summary>
/// Parameter name (if null, derived from property/field name)
/// </summary>
public string Name { get; }
/// <summary>
/// Parameter description for LLM
/// </summary>
public string Description { get; set; }
/// <summary>
/// Whether this parameter is required
/// </summary>
public bool Required { get; set; } = true;
/// <summary>
/// Default value (as string)
/// </summary>
public string DefaultValue { get; set; }
public ToolParameterAttribute(string description)
{
Description = description;
}
}
}

View File

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

View File

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

View File

@@ -0,0 +1,982 @@
using System;
using System.Collections.Generic;
using System.IO;
using MCPForUnity.Editor.Helpers;
using Newtonsoft.Json.Linq;
using UnityEditor;
using UnityEditor.SceneManagement;
using UnityEngine;
using UnityEngine.SceneManagement;
namespace MCPForUnity.Editor.Tools.Prefabs
{
[McpForUnityTool("manage_prefabs", AutoRegister = false)]
/// <summary>
/// Tool to manage Unity Prefabs: create, inspect, and modify prefab assets.
/// Uses headless editing (no UI, no dialogs) for reliable automated workflows.
/// </summary>
public static class ManagePrefabs
{
// Action constants
private const string ACTION_CREATE_FROM_GAMEOBJECT = "create_from_gameobject";
private const string ACTION_GET_INFO = "get_info";
private const string ACTION_GET_HIERARCHY = "get_hierarchy";
private const string ACTION_MODIFY_CONTENTS = "modify_contents";
private const string SupportedActions = ACTION_CREATE_FROM_GAMEOBJECT + ", " + ACTION_GET_INFO + ", " + ACTION_GET_HIERARCHY + ", " + ACTION_MODIFY_CONTENTS;
public static object HandleCommand(JObject @params)
{
if (@params == null)
{
return new ErrorResponse("Parameters cannot be null.");
}
string action = @params["action"]?.ToString()?.ToLowerInvariant();
if (string.IsNullOrEmpty(action))
{
return new ErrorResponse($"Action parameter is required. Valid actions are: {SupportedActions}.");
}
try
{
switch (action)
{
case ACTION_CREATE_FROM_GAMEOBJECT:
return CreatePrefabFromGameObject(@params);
case ACTION_GET_INFO:
return GetInfo(@params);
case ACTION_GET_HIERARCHY:
return GetHierarchy(@params);
case ACTION_MODIFY_CONTENTS:
return ModifyContents(@params);
default:
return new ErrorResponse($"Unknown action: '{action}'. Valid actions are: {SupportedActions}.");
}
}
catch (Exception e)
{
McpLog.Error($"[ManagePrefabs] Action '{action}' failed: {e}");
return new ErrorResponse($"Internal error: {e.Message}");
}
}
#region Create Prefab from GameObject
/// <summary>
/// Creates a prefab asset from a GameObject in the scene.
/// </summary>
private static object CreatePrefabFromGameObject(JObject @params)
{
// 1. Validate and parse parameters
var validation = ValidateCreatePrefabParams(@params);
if (!validation.isValid)
{
return new ErrorResponse(validation.errorMessage);
}
string targetName = validation.targetName;
string finalPath = validation.finalPath;
bool includeInactive = validation.includeInactive;
bool replaceExisting = validation.replaceExisting;
bool unlinkIfInstance = validation.unlinkIfInstance;
// 2. Find the source object
GameObject sourceObject = FindSceneObjectByName(targetName, includeInactive);
if (sourceObject == null)
{
return new ErrorResponse($"GameObject '{targetName}' not found in the active scene or prefab stage{(includeInactive ? " (including inactive objects)" : "")}.");
}
// 3. Validate source object state
var objectValidation = ValidateSourceObjectForPrefab(sourceObject, unlinkIfInstance);
if (!objectValidation.isValid)
{
return new ErrorResponse(objectValidation.errorMessage);
}
// 4. Check for path conflicts and track if file will be replaced
bool fileExistedAtPath = AssetDatabase.LoadAssetAtPath<UnityEngine.Object>(finalPath) != null;
if (!replaceExisting && fileExistedAtPath)
{
finalPath = AssetDatabase.GenerateUniqueAssetPath(finalPath);
McpLog.Info($"[ManagePrefabs] Generated unique path: {finalPath}");
}
// 5. Ensure directory exists
EnsureAssetDirectoryExists(finalPath);
// 6. Unlink from existing prefab if needed
if (unlinkIfInstance && objectValidation.shouldUnlink)
{
try
{
// UnpackPrefabInstance requires the prefab instance root, not a child object
GameObject rootToUnlink = PrefabUtility.GetOutermostPrefabInstanceRoot(sourceObject);
if (rootToUnlink != null)
{
PrefabUtility.UnpackPrefabInstance(rootToUnlink, PrefabUnpackMode.Completely, InteractionMode.AutomatedAction);
McpLog.Info($"[ManagePrefabs] Unpacked prefab instance '{rootToUnlink.name}' before creating new prefab.");
}
}
catch (Exception e)
{
return new ErrorResponse($"Failed to unlink prefab instance: {e.Message}");
}
}
// 7. Create the prefab
try
{
GameObject result = CreatePrefabAsset(sourceObject, finalPath, replaceExisting);
if (result == null)
{
return new ErrorResponse($"Failed to create prefab asset at '{finalPath}'.");
}
// 8. Select the newly created instance
Selection.activeGameObject = result;
return new SuccessResponse(
$"Prefab created at '{finalPath}' and instance linked.",
new
{
prefabPath = finalPath,
instanceId = result.GetInstanceID(),
instanceName = result.name,
wasUnlinked = unlinkIfInstance && objectValidation.shouldUnlink,
wasReplaced = replaceExisting && fileExistedAtPath,
componentCount = result.GetComponents<Component>().Length,
childCount = result.transform.childCount
}
);
}
catch (Exception e)
{
McpLog.Error($"[ManagePrefabs] Error creating prefab at '{finalPath}': {e}");
return new ErrorResponse($"Error saving prefab asset: {e.Message}");
}
}
/// <summary>
/// Validates parameters for creating a prefab from GameObject.
/// </summary>
private static (bool isValid, string errorMessage, string targetName, string finalPath, bool includeInactive, bool replaceExisting, bool unlinkIfInstance)
ValidateCreatePrefabParams(JObject @params)
{
string targetName = @params["target"]?.ToString() ?? @params["name"]?.ToString();
if (string.IsNullOrEmpty(targetName))
{
return (false, "'target' parameter is required for create_from_gameobject.", null, null, false, false, false);
}
string requestedPath = @params["prefabPath"]?.ToString();
if (string.IsNullOrWhiteSpace(requestedPath))
{
return (false, "'prefabPath' parameter is required for create_from_gameobject.", targetName, null, false, false, false);
}
string sanitizedPath = AssetPathUtility.SanitizeAssetPath(requestedPath);
if (sanitizedPath == null)
{
return (false, $"Invalid prefab path (path traversal detected): '{requestedPath}'", targetName, null, false, false, false);
}
if (string.IsNullOrEmpty(sanitizedPath))
{
return (false, $"Invalid prefab path '{requestedPath}'. Path cannot be empty.", targetName, null, false, false, false);
}
if (!sanitizedPath.EndsWith(".prefab", StringComparison.OrdinalIgnoreCase))
{
sanitizedPath += ".prefab";
}
// Validate path is within Assets folder
if (!sanitizedPath.StartsWith("Assets/", StringComparison.OrdinalIgnoreCase))
{
return (false, $"Prefab path must be within the Assets folder. Got: '{sanitizedPath}'", targetName, null, false, false, false);
}
bool includeInactive = @params["searchInactive"]?.ToObject<bool>() ?? false;
bool replaceExisting = @params["allowOverwrite"]?.ToObject<bool>() ?? false;
bool unlinkIfInstance = @params["unlinkIfInstance"]?.ToObject<bool>() ?? false;
return (true, null, targetName, sanitizedPath, includeInactive, replaceExisting, unlinkIfInstance);
}
/// <summary>
/// Validates source object can be converted to prefab.
/// </summary>
private static (bool isValid, string errorMessage, bool shouldUnlink, string existingPrefabPath)
ValidateSourceObjectForPrefab(GameObject sourceObject, bool unlinkIfInstance)
{
// Check if this is a Prefab Asset (the .prefab file itself in the editor)
if (PrefabUtility.IsPartOfPrefabAsset(sourceObject))
{
return (false,
$"GameObject '{sourceObject.name}' is part of a prefab asset. " +
"Open the prefab stage to save changes instead.",
false, null);
}
// Check if this is already a Prefab Instance
PrefabInstanceStatus status = PrefabUtility.GetPrefabInstanceStatus(sourceObject);
if (status != PrefabInstanceStatus.NotAPrefab)
{
string existingPath = PrefabUtility.GetPrefabAssetPathOfNearestInstanceRoot(sourceObject);
if (!unlinkIfInstance)
{
return (false,
$"GameObject '{sourceObject.name}' is already linked to prefab '{existingPath}'. " +
"Set 'unlinkIfInstance' to true to unlink it first, or modify the existing prefab instead.",
false, existingPath);
}
// Needs to be unlinked
return (true, null, true, existingPath);
}
return (true, null, false, null);
}
/// <summary>
/// Creates a prefab asset from a GameObject.
/// </summary>
private static GameObject CreatePrefabAsset(GameObject sourceObject, string path, bool replaceExisting)
{
GameObject result = PrefabUtility.SaveAsPrefabAssetAndConnect(
sourceObject,
path,
InteractionMode.AutomatedAction
);
string action = replaceExisting ? "Replaced existing" : "Created new";
McpLog.Info($"[ManagePrefabs] {action} prefab at '{path}'.");
if (result != null)
{
AssetDatabase.SaveAssets();
AssetDatabase.Refresh();
}
return result;
}
#endregion
/// <summary>
/// Ensures the directory for an asset path exists, creating it if necessary.
/// </summary>
private static void EnsureAssetDirectoryExists(string assetPath)
{
string directory = Path.GetDirectoryName(assetPath);
if (string.IsNullOrEmpty(directory))
{
return;
}
// Use Application.dataPath for more reliable path resolution
// Application.dataPath points to the Assets folder (e.g., ".../ProjectName/Assets")
string assetsPath = Application.dataPath;
string projectRoot = Path.GetDirectoryName(assetsPath);
string fullDirectory = Path.Combine(projectRoot, directory);
if (!Directory.Exists(fullDirectory))
{
Directory.CreateDirectory(fullDirectory);
AssetDatabase.Refresh(ImportAssetOptions.ForceSynchronousImport);
McpLog.Info($"[ManagePrefabs] Created directory: {directory}");
}
}
/// <summary>
/// Finds a GameObject by name in the active scene or current prefab stage.
/// </summary>
private static GameObject FindSceneObjectByName(string name, bool includeInactive)
{
// First check if we're in Prefab Stage
PrefabStage stage = PrefabStageUtility.GetCurrentPrefabStage();
if (stage?.prefabContentsRoot != null)
{
foreach (Transform transform in stage.prefabContentsRoot.GetComponentsInChildren<Transform>(includeInactive))
{
if (transform.name == name && (includeInactive || transform.gameObject.activeSelf))
{
return transform.gameObject;
}
}
}
// Search in the active scene
Scene activeScene = SceneManager.GetActiveScene();
foreach (GameObject root in activeScene.GetRootGameObjects())
{
// Check the root object itself
if (root.name == name && (includeInactive || root.activeSelf))
{
return root;
}
// Check children
foreach (Transform transform in root.GetComponentsInChildren<Transform>(includeInactive))
{
if (transform.name == name && (includeInactive || transform.gameObject.activeSelf))
{
return transform.gameObject;
}
}
}
return null;
}
#region Read Operations
/// <summary>
/// Gets basic metadata information about a prefab asset.
/// </summary>
private static object GetInfo(JObject @params)
{
string prefabPath = @params["prefabPath"]?.ToString() ?? @params["path"]?.ToString();
if (string.IsNullOrEmpty(prefabPath))
{
return new ErrorResponse("'prefabPath' parameter is required for get_info.");
}
string sanitizedPath = AssetPathUtility.SanitizeAssetPath(prefabPath);
if (string.IsNullOrEmpty(sanitizedPath))
{
return new ErrorResponse($"Invalid prefab path: '{prefabPath}'.");
}
GameObject prefabAsset = AssetDatabase.LoadAssetAtPath<GameObject>(sanitizedPath);
if (prefabAsset == null)
{
return new ErrorResponse($"No prefab asset found at path '{sanitizedPath}'.");
}
string guid = PrefabUtilityHelper.GetPrefabGUID(sanitizedPath);
PrefabAssetType assetType = PrefabUtility.GetPrefabAssetType(prefabAsset);
string prefabTypeString = assetType.ToString();
var componentTypes = PrefabUtilityHelper.GetComponentTypeNames(prefabAsset);
int childCount = PrefabUtilityHelper.CountChildrenRecursive(prefabAsset.transform);
var (isVariant, parentPrefab, _) = PrefabUtilityHelper.GetVariantInfo(prefabAsset);
return new SuccessResponse(
$"Successfully retrieved prefab info.",
new
{
assetPath = sanitizedPath,
guid = guid,
prefabType = prefabTypeString,
rootObjectName = prefabAsset.name,
rootComponentTypes = componentTypes,
childCount = childCount,
isVariant = isVariant,
parentPrefab = parentPrefab
}
);
}
/// <summary>
/// Gets the hierarchical structure of a prefab asset.
/// Returns all objects in the prefab for full client-side filtering and search.
/// </summary>
private static object GetHierarchy(JObject @params)
{
string prefabPath = @params["prefabPath"]?.ToString() ?? @params["path"]?.ToString();
if (string.IsNullOrEmpty(prefabPath))
{
return new ErrorResponse("'prefabPath' parameter is required for get_hierarchy.");
}
string sanitizedPath = AssetPathUtility.SanitizeAssetPath(prefabPath);
if (string.IsNullOrEmpty(sanitizedPath))
{
return new ErrorResponse($"Invalid prefab path '{prefabPath}'. Path traversal sequences are not allowed.");
}
// Load prefab contents in background (without opening stage UI)
GameObject prefabContents = PrefabUtility.LoadPrefabContents(sanitizedPath);
if (prefabContents == null)
{
return new ErrorResponse($"Failed to load prefab contents from '{sanitizedPath}'.");
}
try
{
// Build complete hierarchy items (no pagination)
var allItems = BuildHierarchyItems(prefabContents.transform, sanitizedPath);
return new SuccessResponse(
$"Successfully retrieved prefab hierarchy. Found {allItems.Count} objects.",
new
{
prefabPath = sanitizedPath,
total = allItems.Count,
items = allItems
}
);
}
finally
{
// Always unload prefab contents to free memory
PrefabUtility.UnloadPrefabContents(prefabContents);
}
}
#endregion
#region Headless Prefab Editing
/// <summary>
/// Modifies a prefab's contents directly without opening the prefab stage.
/// This is ideal for automated/agentic workflows as it avoids UI, dirty flags, and dialogs.
/// </summary>
private static object ModifyContents(JObject @params)
{
string prefabPath = @params["prefabPath"]?.ToString() ?? @params["path"]?.ToString();
if (string.IsNullOrEmpty(prefabPath))
{
return new ErrorResponse("'prefabPath' parameter is required for modify_contents.");
}
string sanitizedPath = AssetPathUtility.SanitizeAssetPath(prefabPath);
if (string.IsNullOrEmpty(sanitizedPath))
{
return new ErrorResponse($"Invalid prefab path '{prefabPath}'. Path traversal sequences are not allowed.");
}
// Load prefab contents in isolated context (no UI)
GameObject prefabContents = PrefabUtility.LoadPrefabContents(sanitizedPath);
if (prefabContents == null)
{
return new ErrorResponse($"Failed to load prefab contents from '{sanitizedPath}'.");
}
try
{
// Find target object within the prefab (defaults to root)
string targetName = @params["target"]?.ToString();
GameObject targetGo = FindInPrefabContents(prefabContents, targetName);
if (targetGo == null)
{
string searchedFor = string.IsNullOrEmpty(targetName) ? "root" : $"'{targetName}'";
return new ErrorResponse($"Target {searchedFor} not found in prefab '{sanitizedPath}'.");
}
// Apply modifications
var modifyResult = ApplyModificationsToPrefabObject(targetGo, @params, prefabContents);
if (modifyResult.error != null)
{
return modifyResult.error;
}
// Skip saving when no modifications were made to avoid unnecessary asset writes
if (!modifyResult.modified)
{
return new SuccessResponse(
$"Prefab '{sanitizedPath}' is already up to date; no changes were applied.",
new
{
prefabPath = sanitizedPath,
targetName = targetGo.name,
modified = false
}
);
}
// Save the prefab
bool success;
PrefabUtility.SaveAsPrefabAsset(prefabContents, sanitizedPath, out success);
if (!success)
{
return new ErrorResponse($"Failed to save prefab asset at '{sanitizedPath}'.");
}
AssetDatabase.Refresh();
McpLog.Info($"[ManagePrefabs] Successfully modified and saved prefab '{sanitizedPath}' (headless).");
return new SuccessResponse(
$"Prefab '{sanitizedPath}' modified and saved successfully.",
new
{
prefabPath = sanitizedPath,
targetName = targetGo.name,
modified = modifyResult.modified,
transform = new
{
position = new { x = targetGo.transform.localPosition.x, y = targetGo.transform.localPosition.y, z = targetGo.transform.localPosition.z },
rotation = new { x = targetGo.transform.localEulerAngles.x, y = targetGo.transform.localEulerAngles.y, z = targetGo.transform.localEulerAngles.z },
scale = new { x = targetGo.transform.localScale.x, y = targetGo.transform.localScale.y, z = targetGo.transform.localScale.z }
},
componentTypes = PrefabUtilityHelper.GetComponentTypeNames(targetGo)
}
);
}
finally
{
// Always unload prefab contents to free memory
PrefabUtility.UnloadPrefabContents(prefabContents);
}
}
/// <summary>
/// Finds a GameObject within loaded prefab contents by name or path.
/// </summary>
private static GameObject FindInPrefabContents(GameObject prefabContents, string target)
{
if (string.IsNullOrEmpty(target))
{
// Return root if no target specified
return prefabContents;
}
// Try to find by path first (e.g., "Parent/Child/Target")
if (target.Contains("/"))
{
Transform found = prefabContents.transform.Find(target);
if (found != null)
{
return found.gameObject;
}
// If path starts with root name, try without it
if (target.StartsWith(prefabContents.name + "/"))
{
string relativePath = target.Substring(prefabContents.name.Length + 1);
found = prefabContents.transform.Find(relativePath);
if (found != null)
{
return found.gameObject;
}
}
}
// Check if target matches root name
if (prefabContents.name == target)
{
return prefabContents;
}
// Search by name in hierarchy
foreach (Transform t in prefabContents.GetComponentsInChildren<Transform>(true))
{
if (t.gameObject.name == target)
{
return t.gameObject;
}
}
return null;
}
/// <summary>
/// Applies modifications to a GameObject within loaded prefab contents.
/// Returns (modified: bool, error: ErrorResponse or null).
/// </summary>
private static (bool modified, ErrorResponse error) ApplyModificationsToPrefabObject(GameObject targetGo, JObject @params, GameObject prefabRoot)
{
bool modified = false;
// Name change
string newName = @params["name"]?.ToString();
if (!string.IsNullOrEmpty(newName) && targetGo.name != newName)
{
// If renaming the root, this will affect the prefab asset name on save
targetGo.name = newName;
modified = true;
}
// Active state
bool? setActive = @params["setActive"]?.ToObject<bool?>();
if (setActive.HasValue && targetGo.activeSelf != setActive.Value)
{
targetGo.SetActive(setActive.Value);
modified = true;
}
// Tag
string tag = @params["tag"]?.ToString();
if (tag != null && targetGo.tag != tag)
{
string tagToSet = string.IsNullOrEmpty(tag) ? "Untagged" : tag;
try
{
targetGo.tag = tagToSet;
modified = true;
}
catch (Exception ex)
{
return (false, new ErrorResponse($"Failed to set tag to '{tagToSet}': {ex.Message}"));
}
}
// Layer
string layerName = @params["layer"]?.ToString();
if (!string.IsNullOrEmpty(layerName))
{
int layerId = LayerMask.NameToLayer(layerName);
if (layerId == -1)
{
return (false, new ErrorResponse($"Invalid layer specified: '{layerName}'. Use a valid layer name."));
}
if (targetGo.layer != layerId)
{
targetGo.layer = layerId;
modified = true;
}
}
// Transform: position, rotation, scale
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;
}
// Parent change (within prefab hierarchy)
JToken parentToken = @params["parent"];
if (parentToken != null)
{
string parentTarget = parentToken.ToString();
Transform newParent = null;
if (!string.IsNullOrEmpty(parentTarget))
{
GameObject parentGo = FindInPrefabContents(prefabRoot, parentTarget);
if (parentGo == null)
{
return (false, new ErrorResponse($"Parent '{parentTarget}' not found in prefab."));
}
if (parentGo.transform.IsChildOf(targetGo.transform))
{
return (false, new ErrorResponse($"Cannot parent '{targetGo.name}' to '{parentGo.name}' as it would create a hierarchy loop."));
}
newParent = parentGo.transform;
}
if (targetGo.transform.parent != newParent)
{
targetGo.transform.SetParent(newParent, true);
modified = true;
}
}
// Components to add
if (@params["componentsToAdd"] is JArray componentsToAdd)
{
foreach (var compToken in componentsToAdd)
{
string typeName = compToken.Type == JTokenType.String
? compToken.ToString()
: (compToken as JObject)?["typeName"]?.ToString();
if (!string.IsNullOrEmpty(typeName))
{
if (!ComponentResolver.TryResolve(typeName, out Type componentType, out string error))
{
return (false, new ErrorResponse($"Component type '{typeName}' not found: {error}"));
}
targetGo.AddComponent(componentType);
modified = true;
}
}
}
// Components to remove
if (@params["componentsToRemove"] is JArray componentsToRemove)
{
foreach (var compToken in componentsToRemove)
{
string typeName = compToken.ToString();
if (!string.IsNullOrEmpty(typeName))
{
if (!ComponentResolver.TryResolve(typeName, out Type componentType, out string error))
{
return (false, new ErrorResponse($"Component type '{typeName}' not found: {error}"));
}
Component comp = targetGo.GetComponent(componentType);
if (comp != null)
{
UnityEngine.Object.DestroyImmediate(comp);
modified = true;
}
}
}
}
// Create child GameObjects (supports single object or array)
JToken createChildToken = @params["createChild"] ?? @params["create_child"];
if (createChildToken != null)
{
// Handle array of children
if (createChildToken is JArray childArray)
{
foreach (var childToken in childArray)
{
var childResult = CreateSingleChildInPrefab(childToken, targetGo, prefabRoot);
if (childResult.error != null)
{
return (false, childResult.error);
}
if (childResult.created)
{
modified = true;
}
}
}
else
{
// Handle single child object
var childResult = CreateSingleChildInPrefab(createChildToken, targetGo, prefabRoot);
if (childResult.error != null)
{
return (false, childResult.error);
}
if (childResult.created)
{
modified = true;
}
}
}
return (modified, null);
}
/// <summary>
/// Creates a single child GameObject within the prefab contents.
/// </summary>
private static (bool created, ErrorResponse error) CreateSingleChildInPrefab(JToken createChildToken, GameObject defaultParent, GameObject prefabRoot)
{
JObject childParams;
if (createChildToken is JObject obj)
{
childParams = obj;
}
else
{
return (false, new ErrorResponse("'create_child' must be an object with child properties."));
}
// Required: name
string childName = childParams["name"]?.ToString();
if (string.IsNullOrEmpty(childName))
{
return (false, new ErrorResponse("'create_child.name' is required."));
}
// Optional: parent (defaults to the target object)
string parentName = childParams["parent"]?.ToString();
Transform parentTransform = defaultParent.transform;
if (!string.IsNullOrEmpty(parentName))
{
GameObject parentGo = FindInPrefabContents(prefabRoot, parentName);
if (parentGo == null)
{
return (false, new ErrorResponse($"Parent '{parentName}' not found in prefab for create_child."));
}
parentTransform = parentGo.transform;
}
// Create the GameObject
GameObject newChild;
string primitiveType = childParams["primitiveType"]?.ToString() ?? childParams["primitive_type"]?.ToString();
if (!string.IsNullOrEmpty(primitiveType))
{
try
{
PrimitiveType type = (PrimitiveType)Enum.Parse(typeof(PrimitiveType), primitiveType, true);
newChild = GameObject.CreatePrimitive(type);
newChild.name = childName;
}
catch (ArgumentException)
{
return (false, new ErrorResponse($"Invalid primitive type: '{primitiveType}'. Valid types: {string.Join(", ", Enum.GetNames(typeof(PrimitiveType)))}"));
}
}
else
{
newChild = new GameObject(childName);
}
// Set parent
newChild.transform.SetParent(parentTransform, false);
// Apply transform properties
Vector3? position = VectorParsing.ParseVector3(childParams["position"]);
Vector3? rotation = VectorParsing.ParseVector3(childParams["rotation"]);
Vector3? scale = VectorParsing.ParseVector3(childParams["scale"]);
if (position.HasValue)
{
newChild.transform.localPosition = position.Value;
}
if (rotation.HasValue)
{
newChild.transform.localEulerAngles = rotation.Value;
}
if (scale.HasValue)
{
newChild.transform.localScale = scale.Value;
}
// Add components
JArray componentsToAdd = childParams["componentsToAdd"] as JArray ?? childParams["components_to_add"] as JArray;
if (componentsToAdd != null)
{
for (int i = 0; i < componentsToAdd.Count; i++)
{
var compToken = componentsToAdd[i];
string typeName = compToken.Type == JTokenType.String
? compToken.ToString()
: (compToken as JObject)?["typeName"]?.ToString();
if (string.IsNullOrEmpty(typeName))
{
// Clean up partially created child
UnityEngine.Object.DestroyImmediate(newChild);
return (false, new ErrorResponse($"create_child.components_to_add[{i}] must be a string or object with 'typeName' field, got {compToken.Type}"));
}
if (!ComponentResolver.TryResolve(typeName, out Type componentType, out string error))
{
// Clean up partially created child
UnityEngine.Object.DestroyImmediate(newChild);
return (false, new ErrorResponse($"Component type '{typeName}' not found for create_child: {error}"));
}
newChild.AddComponent(componentType);
}
}
// Set tag if specified
string tag = childParams["tag"]?.ToString();
if (!string.IsNullOrEmpty(tag))
{
try
{
newChild.tag = tag;
}
catch (Exception ex)
{
UnityEngine.Object.DestroyImmediate(newChild);
return (false, new ErrorResponse($"Failed to set tag '{tag}' on child '{childName}': {ex.Message}"));
}
}
// Set layer if specified
string layerName = childParams["layer"]?.ToString();
if (!string.IsNullOrEmpty(layerName))
{
int layerId = LayerMask.NameToLayer(layerName);
if (layerId == -1)
{
UnityEngine.Object.DestroyImmediate(newChild);
return (false, new ErrorResponse($"Invalid layer '{layerName}' for child '{childName}'. Use a valid layer name."));
}
newChild.layer = layerId;
}
// Set active state
bool? setActive = childParams["setActive"]?.ToObject<bool?>() ?? childParams["set_active"]?.ToObject<bool?>();
if (setActive.HasValue)
{
newChild.SetActive(setActive.Value);
}
McpLog.Info($"[ManagePrefabs] Created child '{childName}' under '{parentTransform.name}' in prefab.");
return (true, null);
}
#endregion
#region Hierarchy Builder
/// <summary>
/// Builds a flat list of hierarchy items from a transform root.
/// </summary>
/// <param name="root">The root transform of the prefab.</param>
/// <param name="mainPrefabPath">Asset path of the main prefab.</param>
/// <returns>List of hierarchy items with prefab information.</returns>
private static List<object> BuildHierarchyItems(Transform root, string mainPrefabPath)
{
var items = new List<object>();
BuildHierarchyItemsRecursive(root, root, mainPrefabPath, "", items);
return items;
}
/// <summary>
/// Recursively builds hierarchy items.
/// </summary>
/// <param name="transform">Current transform being processed.</param>
/// <param name="mainPrefabRoot">Root transform of the main prefab asset.</param>
/// <param name="mainPrefabPath">Asset path of the main prefab.</param>
/// <param name="parentPath">Parent path for building full hierarchy path.</param>
/// <param name="items">List to accumulate hierarchy items.</param>
private static void BuildHierarchyItemsRecursive(Transform transform, Transform mainPrefabRoot, string mainPrefabPath, string parentPath, List<object> items)
{
if (transform == null) return;
string name = transform.gameObject.name;
string path = string.IsNullOrEmpty(parentPath) ? name : $"{parentPath}/{name}";
int instanceId = transform.gameObject.GetInstanceID();
bool activeSelf = transform.gameObject.activeSelf;
int childCount = transform.childCount;
var componentTypes = PrefabUtilityHelper.GetComponentTypeNames(transform.gameObject);
// Prefab information
bool isNestedPrefab = PrefabUtility.IsAnyPrefabInstanceRoot(transform.gameObject);
bool isPrefabRoot = transform == mainPrefabRoot;
int nestingDepth = isPrefabRoot ? 0 : PrefabUtilityHelper.GetPrefabNestingDepth(transform.gameObject, mainPrefabRoot);
string parentPrefabPath = isNestedPrefab && !isPrefabRoot
? PrefabUtilityHelper.GetParentPrefabPath(transform.gameObject, mainPrefabRoot)
: null;
string nestedPrefabPath = isNestedPrefab ? PrefabUtilityHelper.GetNestedPrefabPath(transform.gameObject) : null;
var item = new
{
name = name,
instanceId = instanceId,
path = path,
activeSelf = activeSelf,
childCount = childCount,
componentTypes = componentTypes,
prefab = new
{
isRoot = isPrefabRoot,
isNestedRoot = isNestedPrefab,
nestingDepth = nestingDepth,
assetPath = isNestedPrefab ? nestedPrefabPath : mainPrefabPath,
parentPath = parentPrefabPath
}
};
items.Add(item);
// Recursively process children
foreach (Transform child in transform)
{
BuildHierarchyItemsRecursive(child, mainPrefabRoot, mainPrefabPath, path, items);
}
}
#endregion
}
}

View File

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

View File

@@ -0,0 +1,641 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using MCPForUnity.Editor.Helpers; // For Response class
using Newtonsoft.Json.Linq;
using UnityEditor;
using UnityEditorInternal;
using UnityEngine;
namespace MCPForUnity.Editor.Tools
{
/// <summary>
/// Handles reading and clearing Unity Editor console log entries.
/// Uses reflection to access internal LogEntry methods/properties.
/// </summary>
[McpForUnityTool("read_console", AutoRegister = false)]
public static class ReadConsole
{
// (Calibration removed)
// Reflection members for accessing internal LogEntry data
// private static MethodInfo _getEntriesMethod; // Removed as it's unused and fails reflection
private static MethodInfo _startGettingEntriesMethod;
private static MethodInfo _endGettingEntriesMethod; // Renamed from _stopGettingEntriesMethod, trying End...
private static MethodInfo _clearMethod;
private static MethodInfo _getCountMethod;
private static MethodInfo _getEntryMethod;
private static FieldInfo _modeField;
private static FieldInfo _messageField;
private static FieldInfo _fileField;
private static FieldInfo _lineField;
private static FieldInfo _instanceIdField;
// Note: Timestamp is not directly available in LogEntry; need to parse message or find alternative?
// Static constructor for reflection setup
static ReadConsole()
{
try
{
Type logEntriesType = typeof(EditorApplication).Assembly.GetType(
"UnityEditor.LogEntries"
);
if (logEntriesType == null)
throw new Exception("Could not find internal type UnityEditor.LogEntries");
// Include NonPublic binding flags as internal APIs might change accessibility
BindingFlags staticFlags =
BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic;
BindingFlags instanceFlags =
BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic;
_startGettingEntriesMethod = logEntriesType.GetMethod(
"StartGettingEntries",
staticFlags
);
if (_startGettingEntriesMethod == null)
throw new Exception("Failed to reflect LogEntries.StartGettingEntries");
// Try reflecting EndGettingEntries based on warning message
_endGettingEntriesMethod = logEntriesType.GetMethod(
"EndGettingEntries",
staticFlags
);
if (_endGettingEntriesMethod == null)
throw new Exception("Failed to reflect LogEntries.EndGettingEntries");
_clearMethod = logEntriesType.GetMethod("Clear", staticFlags);
if (_clearMethod == null)
throw new Exception("Failed to reflect LogEntries.Clear");
_getCountMethod = logEntriesType.GetMethod("GetCount", staticFlags);
if (_getCountMethod == null)
throw new Exception("Failed to reflect LogEntries.GetCount");
_getEntryMethod = logEntriesType.GetMethod("GetEntryInternal", staticFlags);
if (_getEntryMethod == null)
throw new Exception("Failed to reflect LogEntries.GetEntryInternal");
Type logEntryType = typeof(EditorApplication).Assembly.GetType(
"UnityEditor.LogEntry"
);
if (logEntryType == null)
throw new Exception("Could not find internal type UnityEditor.LogEntry");
_modeField = logEntryType.GetField("mode", instanceFlags);
if (_modeField == null)
throw new Exception("Failed to reflect LogEntry.mode");
_messageField = logEntryType.GetField("message", instanceFlags);
if (_messageField == null)
throw new Exception("Failed to reflect LogEntry.message");
_fileField = logEntryType.GetField("file", instanceFlags);
if (_fileField == null)
throw new Exception("Failed to reflect LogEntry.file");
_lineField = logEntryType.GetField("line", instanceFlags);
if (_lineField == null)
throw new Exception("Failed to reflect LogEntry.line");
_instanceIdField = logEntryType.GetField("instanceID", instanceFlags);
if (_instanceIdField == null)
throw new Exception("Failed to reflect LogEntry.instanceID");
// (Calibration removed)
}
catch (Exception e)
{
McpLog.Error(
$"[ReadConsole] Static Initialization Failed: Could not setup reflection for LogEntries/LogEntry. Console reading/clearing will likely fail. Specific Error: {e.Message}"
);
// Set members to null to prevent NullReferenceExceptions later, HandleCommand should check this.
_startGettingEntriesMethod =
_endGettingEntriesMethod =
_clearMethod =
_getCountMethod =
_getEntryMethod =
null;
_modeField = _messageField = _fileField = _lineField = _instanceIdField = null;
}
}
// --- Main Handler ---
public static object HandleCommand(JObject @params)
{
// Check if ALL required reflection members were successfully initialized.
if (
_startGettingEntriesMethod == null
|| _endGettingEntriesMethod == null
|| _clearMethod == null
|| _getCountMethod == null
|| _getEntryMethod == null
|| _modeField == null
|| _messageField == null
|| _fileField == null
|| _lineField == null
|| _instanceIdField == null
)
{
// Log the error here as well for easier debugging in Unity Console
McpLog.Error(
"[ReadConsole] HandleCommand called but reflection members are not initialized. Static constructor might have failed silently or there's an issue."
);
return new ErrorResponse(
"ReadConsole handler failed to initialize due to reflection errors. Cannot access console logs."
);
}
if (@params == null)
{
return new ErrorResponse("Parameters cannot be null.");
}
var p = new ToolParams(@params);
string action = p.Get("action", "get").ToLower();
try
{
if (action == "clear")
{
return ClearConsole();
}
else if (action == "get")
{
// Extract parameters for 'get'
var types =
(p.GetRaw("types") as JArray)?.Select(t => t.ToString().ToLower()).ToList()
?? new List<string> { "error", "warning" };
int? count = p.GetInt("count");
int? pageSize = p.GetInt("pageSize");
int? cursor = p.GetInt("cursor");
string filterText = p.Get("filterText");
string sinceTimestampStr = p.Get("sinceTimestamp"); // TODO: Implement timestamp filtering
string format = p.Get("format", "plain").ToLower();
bool includeStacktrace = p.GetBool("includeStacktrace", false);
if (types.Contains("all"))
{
types = new List<string> { "error", "warning", "log" }; // Expand 'all'
}
if (!string.IsNullOrEmpty(sinceTimestampStr))
{
McpLog.Warn(
"[ReadConsole] Filtering by 'since_timestamp' is not currently implemented."
);
// Need a way to get timestamp per log entry.
}
return GetConsoleEntries(
types,
count,
pageSize,
cursor,
filterText,
format,
includeStacktrace
);
}
else
{
return new ErrorResponse(
$"Unknown action: '{action}'. Valid actions are 'get' or 'clear'."
);
}
}
catch (Exception e)
{
McpLog.Error($"[ReadConsole] Action '{action}' failed: {e}");
return new ErrorResponse($"Internal error processing action '{action}': {e.Message}");
}
}
// --- Action Implementations ---
private static object ClearConsole()
{
try
{
_clearMethod.Invoke(null, null); // Static method, no instance, no parameters
return new SuccessResponse("Console cleared successfully.");
}
catch (Exception e)
{
McpLog.Error($"[ReadConsole] Failed to clear console: {e}");
return new ErrorResponse($"Failed to clear console: {e.Message}");
}
}
/// <summary>
/// Retrieves console log entries with optional filtering and paging.
/// </summary>
/// <param name="types">Log types to include (e.g., "error", "warning", "log").</param>
/// <param name="count">Maximum entries to return in non-paging mode. Ignored when paging is active.</param>
/// <param name="pageSize">Number of entries per page. Defaults to 50 when omitted.</param>
/// <param name="cursor">Starting index for paging (0-based). Defaults to 0.</param>
/// <param name="filterText">Optional text filter (case-insensitive substring match).</param>
/// <param name="format">Output format: "plain", "detailed", or "json".</param>
/// <param name="includeStacktrace">Whether to include stack traces in the output.</param>
/// <returns>A success response with entries, or an error response.</returns>
private static object GetConsoleEntries(
List<string> types,
int? count,
int? pageSize,
int? cursor,
string filterText,
string format,
bool includeStacktrace
)
{
List<object> formattedEntries = new List<object>();
int retrievedCount = 0;
int totalMatches = 0;
bool usePaging = pageSize.HasValue || cursor.HasValue;
// pageSize defaults to 50 when omitted; count is the overall non-paging limit only
int resolvedPageSize = Mathf.Clamp(pageSize ?? 50, 1, 500);
int resolvedCursor = Mathf.Max(0, cursor ?? 0);
int pageEndExclusive = resolvedCursor + resolvedPageSize;
try
{
// LogEntries requires calling Start/Stop around GetEntries/GetEntryInternal
_startGettingEntriesMethod.Invoke(null, null);
int totalEntries = (int)_getCountMethod.Invoke(null, null);
// Create instance to pass to GetEntryInternal - Ensure the type is correct
Type logEntryType = typeof(EditorApplication).Assembly.GetType(
"UnityEditor.LogEntry"
);
if (logEntryType == null)
throw new Exception(
"Could not find internal type UnityEditor.LogEntry during GetConsoleEntries."
);
object logEntryInstance = Activator.CreateInstance(logEntryType);
for (int i = 0; i < totalEntries; i++)
{
// Get the entry data into our instance using reflection
_getEntryMethod.Invoke(null, new object[] { i, logEntryInstance });
// Extract data using reflection
int mode = (int)_modeField.GetValue(logEntryInstance);
string message = (string)_messageField.GetValue(logEntryInstance);
string file = (string)_fileField.GetValue(logEntryInstance);
int line = (int)_lineField.GetValue(logEntryInstance);
// int instanceId = (int)_instanceIdField.GetValue(logEntryInstance);
if (string.IsNullOrEmpty(message))
{
continue; // Skip empty messages
}
// (Calibration removed)
// --- Filtering ---
// Prefer classifying severity from message/stacktrace; fallback to mode bits if needed
LogType unityType = InferTypeFromMessage(message);
bool isExplicitDebug = IsExplicitDebugLog(message);
if (!isExplicitDebug && unityType == LogType.Log)
{
unityType = GetLogTypeFromMode(mode);
}
bool want;
// Treat Exception/Assert as errors for filtering convenience
if (unityType == LogType.Exception)
{
want = types.Contains("error") || types.Contains("exception");
}
else if (unityType == LogType.Assert)
{
want = types.Contains("error") || types.Contains("assert");
}
else
{
want = types.Contains(unityType.ToString().ToLowerInvariant());
}
if (!want) continue;
// Filter by text (case-insensitive)
if (
!string.IsNullOrEmpty(filterText)
&& message.IndexOf(filterText, StringComparison.OrdinalIgnoreCase) < 0
)
{
continue;
}
// TODO: Filter by timestamp (requires timestamp data)
// --- Formatting ---
string stackTrace = includeStacktrace ? ExtractStackTrace(message) : null;
// Always get first line for the message, use full message only if no stack trace exists
string[] messageLines = message.Split(
new[] { '\n', '\r' },
StringSplitOptions.RemoveEmptyEntries
);
string messageOnly = messageLines.Length > 0 ? messageLines[0] : message;
// If not including stacktrace, ensure we only show the first line
if (!includeStacktrace)
{
stackTrace = null;
}
object formattedEntry = null;
switch (format)
{
case "plain":
formattedEntry = messageOnly;
break;
case "json":
case "detailed": // Treat detailed as json for structured return
default:
formattedEntry = new
{
type = unityType.ToString(),
message = messageOnly,
file = file,
line = line,
// timestamp = "", // TODO
stackTrace = stackTrace, // Will be null if includeStacktrace is false or no stack found
};
break;
}
totalMatches++;
if (usePaging)
{
if (totalMatches > resolvedCursor && totalMatches <= pageEndExclusive)
{
formattedEntries.Add(formattedEntry);
retrievedCount++;
}
// Early exit: we've filled the page and only need to check if more exist
else if (totalMatches > pageEndExclusive)
{
// We've passed the page; totalMatches now indicates truncation
break;
}
}
else
{
formattedEntries.Add(formattedEntry);
retrievedCount++;
// Apply count limit (after filtering)
if (count.HasValue && retrievedCount >= count.Value)
{
break;
}
}
}
}
catch (Exception e)
{
McpLog.Error($"[ReadConsole] Error while retrieving log entries: {e}");
// EndGettingEntries will be called in the finally block
return new ErrorResponse($"Error retrieving log entries: {e.Message}");
}
finally
{
// Ensure we always call EndGettingEntries
try
{
_endGettingEntriesMethod.Invoke(null, null);
}
catch (Exception e)
{
McpLog.Error($"[ReadConsole] Failed to call EndGettingEntries: {e}");
// Don't return error here as we might have valid data, but log it.
}
}
if (usePaging)
{
bool truncated = totalMatches > pageEndExclusive;
string nextCursor = truncated ? pageEndExclusive.ToString() : null;
var payload = new
{
cursor = resolvedCursor,
pageSize = resolvedPageSize,
nextCursor = nextCursor,
truncated = truncated,
total = totalMatches,
items = formattedEntries,
};
return new SuccessResponse(
$"Retrieved {formattedEntries.Count} log entries.",
payload
);
}
// Return the filtered and formatted list (might be empty)
return new SuccessResponse(
$"Retrieved {formattedEntries.Count} log entries.",
formattedEntries
);
}
// --- Internal Helpers ---
// Mapping bits from LogEntry.mode. These may vary by Unity version.
private const int ModeBitError = 1 << 0;
private const int ModeBitAssert = 1 << 1;
private const int ModeBitWarning = 1 << 2;
private const int ModeBitLog = 1 << 3;
private const int ModeBitException = 1 << 4; // often combined with Error bits
private const int ModeBitScriptingError = 1 << 9;
private const int ModeBitScriptingWarning = 1 << 10;
private const int ModeBitScriptingLog = 1 << 11;
private const int ModeBitScriptingException = 1 << 18;
private const int ModeBitScriptingAssertion = 1 << 22;
private static LogType GetLogTypeFromMode(int mode)
{
// Preserve Unity's real type (no remapping); bits may vary by version
if ((mode & (ModeBitException | ModeBitScriptingException)) != 0) return LogType.Exception;
if ((mode & (ModeBitError | ModeBitScriptingError)) != 0) return LogType.Error;
if ((mode & (ModeBitAssert | ModeBitScriptingAssertion)) != 0) return LogType.Assert;
if ((mode & (ModeBitWarning | ModeBitScriptingWarning)) != 0) return LogType.Warning;
return LogType.Log;
}
// (Calibration helpers removed)
/// <summary>
/// Classifies severity using message/stacktrace content. Works across Unity versions.
/// </summary>
private static LogType InferTypeFromMessage(string fullMessage)
{
if (string.IsNullOrEmpty(fullMessage)) return LogType.Log;
// Fast path: look for explicit Debug API names in the appended stack trace
// e.g., "UnityEngine.Debug:LogError (object)" or "LogWarning"
if (fullMessage.IndexOf("LogError", StringComparison.OrdinalIgnoreCase) >= 0)
return LogType.Error;
if (fullMessage.IndexOf("LogWarning", StringComparison.OrdinalIgnoreCase) >= 0)
return LogType.Warning;
// Compiler diagnostics (C#): "warning CSxxxx" / "error CSxxxx"
if (fullMessage.IndexOf(" warning CS", StringComparison.OrdinalIgnoreCase) >= 0
|| fullMessage.IndexOf(": warning CS", StringComparison.OrdinalIgnoreCase) >= 0)
return LogType.Warning;
if (fullMessage.IndexOf(" error CS", StringComparison.OrdinalIgnoreCase) >= 0
|| fullMessage.IndexOf(": error CS", StringComparison.OrdinalIgnoreCase) >= 0)
return LogType.Error;
// Exceptions (avoid misclassifying compiler diagnostics)
if (fullMessage.IndexOf("Exception", StringComparison.OrdinalIgnoreCase) >= 0)
return LogType.Exception;
// Unity assertions
if (fullMessage.IndexOf("Assertion", StringComparison.OrdinalIgnoreCase) >= 0)
return LogType.Assert;
return LogType.Log;
}
private static bool IsExplicitDebugLog(string fullMessage)
{
if (string.IsNullOrEmpty(fullMessage)) return false;
if (fullMessage.IndexOf("Debug:Log (", StringComparison.OrdinalIgnoreCase) >= 0) return true;
if (fullMessage.IndexOf("UnityEngine.Debug:Log (", StringComparison.OrdinalIgnoreCase) >= 0) return true;
return false;
}
/// <summary>
/// Applies the "one level lower" remapping for filtering, like the old version.
/// This ensures compatibility with the filtering logic that expects remapped types.
/// </summary>
private static LogType GetRemappedTypeForFiltering(LogType unityType)
{
switch (unityType)
{
case LogType.Error:
return LogType.Warning; // Error becomes Warning
case LogType.Warning:
return LogType.Log; // Warning becomes Log
case LogType.Assert:
return LogType.Assert; // Assert remains Assert
case LogType.Log:
return LogType.Log; // Log remains Log
case LogType.Exception:
return LogType.Warning; // Exception becomes Warning
default:
return LogType.Log; // Default fallback
}
}
/// <summary>
/// Attempts to extract the stack trace part from a log message.
/// Unity log messages often have the stack trace appended after the main message,
/// starting on a new line and typically indented or beginning with "at ".
/// </summary>
/// <param name="fullMessage">The complete log message including potential stack trace.</param>
/// <returns>The extracted stack trace string, or null if none is found.</returns>
private static string ExtractStackTrace(string fullMessage)
{
if (string.IsNullOrEmpty(fullMessage))
return null;
// Split into lines, removing empty ones to handle different line endings gracefully.
// Using StringSplitOptions.None might be better if empty lines matter within stack trace, but RemoveEmptyEntries is usually safer here.
string[] lines = fullMessage.Split(
new[] { '\r', '\n' },
StringSplitOptions.RemoveEmptyEntries
);
// If there's only one line or less, there's no separate stack trace.
if (lines.Length <= 1)
return null;
int stackStartIndex = -1;
// Start checking from the second line onwards.
for (int i = 1; i < lines.Length; ++i)
{
// Performance: TrimStart creates a new string. Consider using IsWhiteSpace check if performance critical.
string trimmedLine = lines[i].TrimStart();
// Check for common stack trace patterns.
if (
trimmedLine.StartsWith("at ")
|| trimmedLine.StartsWith("UnityEngine.")
|| trimmedLine.StartsWith("UnityEditor.")
|| trimmedLine.Contains("(at ")
|| // Covers "(at Assets/..." pattern
// Heuristic: Check if line starts with likely namespace/class pattern (Uppercase.Something)
(
trimmedLine.Length > 0
&& char.IsUpper(trimmedLine[0])
&& trimmedLine.Contains('.')
)
)
{
stackStartIndex = i;
break; // Found the likely start of the stack trace
}
}
// If a potential start index was found...
if (stackStartIndex > 0)
{
// Join the lines from the stack start index onwards using standard newline characters.
// This reconstructs the stack trace part of the message.
return string.Join("\n", lines.Skip(stackStartIndex));
}
// No clear stack trace found based on the patterns.
return null;
}
/* LogEntry.mode bits exploration (based on Unity decompilation/observation):
May change between versions.
Basic Types:
kError = 1 << 0 (1)
kAssert = 1 << 1 (2)
kWarning = 1 << 2 (4)
kLog = 1 << 3 (8)
kFatal = 1 << 4 (16) - Often treated as Exception/Error
Modifiers/Context:
kAssetImportError = 1 << 7 (128)
kAssetImportWarning = 1 << 8 (256)
kScriptingError = 1 << 9 (512)
kScriptingWarning = 1 << 10 (1024)
kScriptingLog = 1 << 11 (2048)
kScriptCompileError = 1 << 12 (4096)
kScriptCompileWarning = 1 << 13 (8192)
kStickyError = 1 << 14 (16384) - Stays visible even after Clear On Play
kMayIgnoreLineNumber = 1 << 15 (32768)
kReportBug = 1 << 16 (65536) - Shows the "Report Bug" button
kDisplayPreviousErrorInStatusBar = 1 << 17 (131072)
kScriptingException = 1 << 18 (262144)
kDontExtractStacktrace = 1 << 19 (524288) - Hint to the console UI
kShouldClearOnPlay = 1 << 20 (1048576) - Default behavior
kGraphCompileError = 1 << 21 (2097152)
kScriptingAssertion = 1 << 22 (4194304)
kVisualScriptingError = 1 << 23 (8388608)
Example observed values:
Log: 2048 (ScriptingLog) or 8 (Log)
Warning: 1028 (ScriptingWarning | Warning) or 4 (Warning)
Error: 513 (ScriptingError | Error) or 1 (Error)
Exception: 262161 (ScriptingException | Error | kFatal?) - Complex combination
Assertion: 4194306 (ScriptingAssertion | Assert) or 2 (Assert)
*/
}
}

View File

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

View File

@@ -0,0 +1,171 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using MCPForUnity.Editor.Helpers;
using MCPForUnity.Editor.Services;
using Newtonsoft.Json.Linq;
using UnityEditor;
using UnityEditor.Compilation;
namespace MCPForUnity.Editor.Tools
{
/// <summary>
/// Explicitly refreshes Unity's asset database and optionally requests a script compilation.
/// This is side-effectful and should be treated as a tool.
/// </summary>
[McpForUnityTool("refresh_unity", AutoRegister = false)]
public static class RefreshUnity
{
private const int DefaultWaitTimeoutSeconds = 60;
public static async Task<object> HandleCommand(JObject @params)
{
string mode = @params?["mode"]?.ToString() ?? "if_dirty";
string scope = @params?["scope"]?.ToString() ?? "all";
string compile = @params?["compile"]?.ToString() ?? "none";
bool waitForReady = ParamCoercion.CoerceBool(@params?["wait_for_ready"], false);
if (TestRunStatus.IsRunning)
{
return new ErrorResponse("tests_running", new
{
reason = "tests_running",
retry_after_ms = 5000
});
}
bool refreshTriggered = false;
bool compileRequested = false;
try
{
// Best-effort semantics: if_dirty currently behaves like force unless future dirty signals are added.
bool shouldRefresh = string.Equals(mode, "force", StringComparison.OrdinalIgnoreCase)
|| string.Equals(mode, "if_dirty", StringComparison.OrdinalIgnoreCase);
if (shouldRefresh)
{
if (string.Equals(scope, "scripts", StringComparison.OrdinalIgnoreCase))
{
// For scripts, requesting compilation is usually the meaningful action.
// We avoid a heavyweight full refresh by default.
}
else
{
AssetDatabase.Refresh(ImportAssetOptions.ForceUpdate | ImportAssetOptions.ForceSynchronousImport);
refreshTriggered = true;
}
}
if (string.Equals(compile, "request", StringComparison.OrdinalIgnoreCase))
{
CompilationPipeline.RequestScriptCompilation();
compileRequested = true;
}
if (string.Equals(scope, "all", StringComparison.OrdinalIgnoreCase) && !refreshTriggered)
{
// If the caller asked for "all" and we skipped refresh above (e.g., scripts-only path),
// do a lightweight refresh now. Use ForceSynchronousImport to ensure the refresh
// completes before returning, preventing stalls when Unity is backgrounded.
AssetDatabase.Refresh(ImportAssetOptions.ForceSynchronousImport);
refreshTriggered = true;
}
}
catch (Exception ex)
{
return new ErrorResponse($"refresh_failed: {ex.Message}");
}
// Unity 6+ fix: Skip wait_for_ready when compile was requested.
// The EditorApplication.update polling in WaitForUnityReadyAsync doesn't survive
// domain reloads properly in Unity 6+, causing infinite compilation loops.
// When compilation is requested, return immediately and let client poll editor_state.
// Earlier Unity versions retain the original behavior.
#if UNITY_6000_0_OR_NEWER
bool shouldWaitForReady = waitForReady && !compileRequested;
#else
bool shouldWaitForReady = waitForReady;
#endif
if (shouldWaitForReady)
{
try
{
await WaitForUnityReadyAsync(
TimeSpan.FromSeconds(DefaultWaitTimeoutSeconds)).ConfigureAwait(true);
}
catch (TimeoutException)
{
return new ErrorResponse("refresh_timeout_waiting_for_ready", new
{
refresh_triggered = refreshTriggered,
compile_requested = compileRequested,
resulting_state = "unknown",
});
}
catch (Exception ex)
{
return new ErrorResponse($"refresh_wait_failed: {ex.Message}");
}
}
string resultingState = EditorApplication.isCompiling
? "compiling"
: (EditorApplication.isUpdating ? "asset_import" : "idle");
return new SuccessResponse("Refresh requested.", new
{
refresh_triggered = refreshTriggered,
compile_requested = compileRequested,
resulting_state = resultingState,
hint = shouldWaitForReady
? "Unity refresh completed; editor should be ready."
: "If Unity enters compilation/domain reload, poll editor_state until ready_for_tools is true."
});
}
private static Task WaitForUnityReadyAsync(TimeSpan timeout)
{
var tcs = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
var start = DateTime.UtcNow;
void Tick()
{
try
{
if (tcs.Task.IsCompleted)
{
EditorApplication.update -= Tick;
return;
}
if ((DateTime.UtcNow - start) > timeout)
{
EditorApplication.update -= Tick;
tcs.TrySetException(new TimeoutException());
return;
}
if (!EditorApplication.isCompiling
&& !EditorApplication.isUpdating
&& !TestRunStatus.IsRunning
&& !EditorApplication.isPlayingOrWillChangePlaymode)
{
EditorApplication.update -= Tick;
tcs.TrySetResult(true);
}
}
catch (Exception ex)
{
EditorApplication.update -= Tick;
tcs.TrySetException(ex);
}
}
EditorApplication.update += Tick;
// Nudge Unity to pump once in case update is throttled.
try { EditorApplication.QueuePlayerLoopUpdate(); } catch { }
return tcs.Task;
}
}
}

View File

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

View File

@@ -0,0 +1,118 @@
using System;
using System.Linq;
using System.Threading.Tasks;
using MCPForUnity.Editor.Helpers;
using MCPForUnity.Editor.Resources.Tests;
using MCPForUnity.Editor.Services;
using Newtonsoft.Json.Linq;
using UnityEditor.TestTools.TestRunner.Api;
namespace MCPForUnity.Editor.Tools
{
/// <summary>
/// Starts a Unity Test Runner run asynchronously and returns a job id immediately.
/// Use get_test_job(job_id) to poll status/results.
/// </summary>
[McpForUnityTool("run_tests", AutoRegister = false)]
public static class RunTests
{
public static Task<object> HandleCommand(JObject @params)
{
try
{
// Check for clear_stuck action first
if (ParamCoercion.CoerceBool(@params?["clear_stuck"], false))
{
bool wasCleared = TestJobManager.ClearStuckJob();
return Task.FromResult<object>(new SuccessResponse(
wasCleared ? "Stuck job cleared." : "No running job to clear.",
new { cleared = wasCleared }
));
}
string modeStr = @params?["mode"]?.ToString();
if (string.IsNullOrWhiteSpace(modeStr))
{
modeStr = "EditMode";
}
if (!ModeParser.TryParse(modeStr, out var parsedMode, out var parseError))
{
return Task.FromResult<object>(new ErrorResponse(parseError));
}
bool includeDetails = ParamCoercion.CoerceBool(@params?["includeDetails"], false);
bool includeFailedTests = ParamCoercion.CoerceBool(@params?["includeFailedTests"], false);
var filterOptions = GetFilterOptions(@params);
string jobId = TestJobManager.StartJob(parsedMode.Value, filterOptions);
return Task.FromResult<object>(new SuccessResponse("Test job started.", new
{
job_id = jobId,
status = "running",
mode = parsedMode.Value.ToString(),
include_details = includeDetails,
include_failed_tests = includeFailedTests
}));
}
catch (Exception ex)
{
// Normalize the already-running case to a stable error token.
if (ex.Message != null && ex.Message.IndexOf("already in progress", StringComparison.OrdinalIgnoreCase) >= 0)
{
return Task.FromResult<object>(new ErrorResponse("tests_running", new { reason = "tests_running", retry_after_ms = 5000 }));
}
return Task.FromResult<object>(new ErrorResponse($"Failed to start test job: {ex.Message}"));
}
}
private static TestFilterOptions GetFilterOptions(JObject @params)
{
if (@params == null)
{
return null;
}
string[] ParseStringArray(string key)
{
var token = @params[key];
if (token == null) return null;
if (token.Type == JTokenType.String)
{
var value = token.ToString();
return string.IsNullOrWhiteSpace(value) ? null : new[] { value };
}
if (token.Type == JTokenType.Array)
{
var array = token as JArray;
if (array == null || array.Count == 0) return null;
var values = array
.Values<string>()
.Where(s => !string.IsNullOrWhiteSpace(s))
.ToArray();
return values.Length > 0 ? values : null;
}
return null;
}
var testNames = ParseStringArray("testNames");
var groupNames = ParseStringArray("groupNames");
var categoryNames = ParseStringArray("categoryNames");
var assemblyNames = ParseStringArray("assemblyNames");
if (testNames == null && groupNames == null && categoryNames == null && assemblyNames == null)
{
return null;
}
return new TestFilterOptions
{
TestNames = testNames,
GroupNames = groupNames,
CategoryNames = categoryNames,
AssemblyNames = assemblyNames
};
}
}
}

View File

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

View File

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

View File

@@ -0,0 +1,220 @@
using Newtonsoft.Json.Linq;
using UnityEditor;
using UnityEngine;
using MCPForUnity.Editor.Helpers;
namespace MCPForUnity.Editor.Tools.Vfx
{
internal static class LineCreate
{
public static object CreateLine(JObject @params)
{
LineRenderer lr = LineRead.FindLineRenderer(@params);
if (lr == null) return new { success = false, message = "LineRenderer not found" };
Vector3 start = ManageVfxCommon.ParseVector3(@params["start"]);
Vector3 end = ManageVfxCommon.ParseVector3(@params["end"]);
Undo.RecordObject(lr, "Create Line");
lr.positionCount = 2;
lr.SetPosition(0, start);
lr.SetPosition(1, end);
RendererHelpers.EnsureMaterial(lr);
// Apply optional width
if (@params["width"] != null)
{
float w = @params["width"].ToObject<float>();
lr.startWidth = w;
lr.endWidth = w;
}
if (@params["startWidth"] != null) lr.startWidth = @params["startWidth"].ToObject<float>();
if (@params["endWidth"] != null) lr.endWidth = @params["endWidth"].ToObject<float>();
// Apply optional color
if (@params["color"] != null)
{
Color c = ManageVfxCommon.ParseColor(@params["color"]);
lr.startColor = c;
lr.endColor = c;
}
if (@params["startColor"] != null) lr.startColor = ManageVfxCommon.ParseColor(@params["startColor"]);
if (@params["endColor"] != null) lr.endColor = ManageVfxCommon.ParseColor(@params["endColor"]);
EditorUtility.SetDirty(lr);
return new { success = true, message = "Created line" };
}
public static object CreateCircle(JObject @params)
{
LineRenderer lr = LineRead.FindLineRenderer(@params);
if (lr == null) return new { success = false, message = "LineRenderer not found" };
Vector3 center = ManageVfxCommon.ParseVector3(@params["center"]);
float radius = @params["radius"]?.ToObject<float>() ?? 1f;
int segments = @params["segments"]?.ToObject<int>() ?? 32;
Vector3 normal = @params["normal"] != null ? ManageVfxCommon.ParseVector3(@params["normal"]).normalized : Vector3.up;
Vector3 right = Vector3.Cross(normal, Vector3.forward);
if (right.sqrMagnitude < 0.001f) right = Vector3.Cross(normal, Vector3.up);
right = right.normalized;
Vector3 forward = Vector3.Cross(right, normal).normalized;
Undo.RecordObject(lr, "Create Circle");
lr.positionCount = segments;
lr.loop = true;
for (int i = 0; i < segments; i++)
{
float angle = (float)i / segments * Mathf.PI * 2f;
Vector3 point = center + (right * Mathf.Cos(angle) + forward * Mathf.Sin(angle)) * radius;
lr.SetPosition(i, point);
}
RendererHelpers.EnsureMaterial(lr);
// Apply optional width
if (@params["width"] != null)
{
float w = @params["width"].ToObject<float>();
lr.startWidth = w;
lr.endWidth = w;
}
if (@params["startWidth"] != null) lr.startWidth = @params["startWidth"].ToObject<float>();
if (@params["endWidth"] != null) lr.endWidth = @params["endWidth"].ToObject<float>();
// Apply optional color
if (@params["color"] != null)
{
Color c = ManageVfxCommon.ParseColor(@params["color"]);
lr.startColor = c;
lr.endColor = c;
}
if (@params["startColor"] != null) lr.startColor = ManageVfxCommon.ParseColor(@params["startColor"]);
if (@params["endColor"] != null) lr.endColor = ManageVfxCommon.ParseColor(@params["endColor"]);
EditorUtility.SetDirty(lr);
return new { success = true, message = $"Created circle with {segments} segments" };
}
public static object CreateArc(JObject @params)
{
LineRenderer lr = LineRead.FindLineRenderer(@params);
if (lr == null) return new { success = false, message = "LineRenderer not found" };
Vector3 center = ManageVfxCommon.ParseVector3(@params["center"]);
float radius = @params["radius"]?.ToObject<float>() ?? 1f;
float startAngle = (@params["startAngle"]?.ToObject<float>() ?? 0f) * Mathf.Deg2Rad;
float endAngle = (@params["endAngle"]?.ToObject<float>() ?? 180f) * Mathf.Deg2Rad;
int segments = @params["segments"]?.ToObject<int>() ?? 16;
Vector3 normal = @params["normal"] != null ? ManageVfxCommon.ParseVector3(@params["normal"]).normalized : Vector3.up;
Vector3 right = Vector3.Cross(normal, Vector3.forward);
if (right.sqrMagnitude < 0.001f) right = Vector3.Cross(normal, Vector3.up);
right = right.normalized;
Vector3 forward = Vector3.Cross(right, normal).normalized;
Undo.RecordObject(lr, "Create Arc");
lr.positionCount = segments + 1;
lr.loop = false;
for (int i = 0; i <= segments; i++)
{
float t = (float)i / segments;
float angle = Mathf.Lerp(startAngle, endAngle, t);
Vector3 point = center + (right * Mathf.Cos(angle) + forward * Mathf.Sin(angle)) * radius;
lr.SetPosition(i, point);
}
RendererHelpers.EnsureMaterial(lr);
// Apply optional width
if (@params["width"] != null)
{
float w = @params["width"].ToObject<float>();
lr.startWidth = w;
lr.endWidth = w;
}
if (@params["startWidth"] != null) lr.startWidth = @params["startWidth"].ToObject<float>();
if (@params["endWidth"] != null) lr.endWidth = @params["endWidth"].ToObject<float>();
// Apply optional color
if (@params["color"] != null)
{
Color c = ManageVfxCommon.ParseColor(@params["color"]);
lr.startColor = c;
lr.endColor = c;
}
if (@params["startColor"] != null) lr.startColor = ManageVfxCommon.ParseColor(@params["startColor"]);
if (@params["endColor"] != null) lr.endColor = ManageVfxCommon.ParseColor(@params["endColor"]);
EditorUtility.SetDirty(lr);
return new { success = true, message = $"Created arc with {segments} segments" };
}
public static object CreateBezier(JObject @params)
{
LineRenderer lr = LineRead.FindLineRenderer(@params);
if (lr == null) return new { success = false, message = "LineRenderer not found" };
Vector3 start = ManageVfxCommon.ParseVector3(@params["start"]);
Vector3 end = ManageVfxCommon.ParseVector3(@params["end"]);
Vector3 cp1 = ManageVfxCommon.ParseVector3(@params["controlPoint1"] ?? @params["control1"]);
Vector3 cp2 = @params["controlPoint2"] != null || @params["control2"] != null
? ManageVfxCommon.ParseVector3(@params["controlPoint2"] ?? @params["control2"])
: cp1;
int segments = @params["segments"]?.ToObject<int>() ?? 32;
bool isQuadratic = @params["controlPoint2"] == null && @params["control2"] == null;
Undo.RecordObject(lr, "Create Bezier");
lr.positionCount = segments + 1;
lr.loop = false;
for (int i = 0; i <= segments; i++)
{
float t = (float)i / segments;
Vector3 point;
if (isQuadratic)
{
float u = 1 - t;
point = u * u * start + 2 * u * t * cp1 + t * t * end;
}
else
{
float u = 1 - t;
point = u * u * u * start + 3 * u * u * t * cp1 + 3 * u * t * t * cp2 + t * t * t * end;
}
lr.SetPosition(i, point);
}
RendererHelpers.EnsureMaterial(lr);
// Apply optional width
if (@params["width"] != null)
{
float w = @params["width"].ToObject<float>();
lr.startWidth = w;
lr.endWidth = w;
}
if (@params["startWidth"] != null) lr.startWidth = @params["startWidth"].ToObject<float>();
if (@params["endWidth"] != null) lr.endWidth = @params["endWidth"].ToObject<float>();
// Apply optional color
if (@params["color"] != null)
{
Color c = ManageVfxCommon.ParseColor(@params["color"]);
lr.startColor = c;
lr.endColor = c;
}
if (@params["startColor"] != null) lr.startColor = ManageVfxCommon.ParseColor(@params["startColor"]);
if (@params["endColor"] != null) lr.endColor = ManageVfxCommon.ParseColor(@params["endColor"]);
EditorUtility.SetDirty(lr);
return new { success = true, message = $"Created {(isQuadratic ? "quadratic" : "cubic")} Bezier" };
}
}
}

View File

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

View File

@@ -0,0 +1,52 @@
using System.Linq;
using Newtonsoft.Json.Linq;
using UnityEngine;
namespace MCPForUnity.Editor.Tools.Vfx
{
internal static class LineRead
{
public static LineRenderer FindLineRenderer(JObject @params)
{
GameObject go = ManageVfxCommon.FindTargetGameObject(@params);
return go?.GetComponent<LineRenderer>();
}
public static object GetInfo(JObject @params)
{
LineRenderer lr = FindLineRenderer(@params);
if (lr == null) return new { success = false, message = "LineRenderer not found" };
var positions = new Vector3[lr.positionCount];
lr.GetPositions(positions);
return new
{
success = true,
data = new
{
gameObject = lr.gameObject.name,
positionCount = lr.positionCount,
positions = positions.Select(p => new { x = p.x, y = p.y, z = p.z }).ToArray(),
startWidth = lr.startWidth,
endWidth = lr.endWidth,
loop = lr.loop,
useWorldSpace = lr.useWorldSpace,
alignment = lr.alignment.ToString(),
textureMode = lr.textureMode.ToString(),
numCornerVertices = lr.numCornerVertices,
numCapVertices = lr.numCapVertices,
generateLightingData = lr.generateLightingData,
material = lr.sharedMaterial?.name,
shadowCastingMode = lr.shadowCastingMode.ToString(),
receiveShadows = lr.receiveShadows,
lightProbeUsage = lr.lightProbeUsage.ToString(),
reflectionProbeUsage = lr.reflectionProbeUsage.ToString(),
sortingOrder = lr.sortingOrder,
sortingLayerName = lr.sortingLayerName,
renderingLayerMask = lr.renderingLayerMask
}
};
}
}
}

View File

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

View File

@@ -0,0 +1,189 @@
using System.Collections.Generic;
using MCPForUnity.Editor.Helpers;
using Newtonsoft.Json.Linq;
using UnityEditor;
using UnityEngine;
namespace MCPForUnity.Editor.Tools.Vfx
{
internal static class LineWrite
{
public static object SetPositions(JObject @params)
{
LineRenderer lr = LineRead.FindLineRenderer(@params);
if (lr == null) return new { success = false, message = "LineRenderer not found" };
RendererHelpers.EnsureMaterial(lr);
JArray posArr = @params["positions"] as JArray;
if (posArr == null) return new { success = false, message = "Positions array required" };
var positions = new Vector3[posArr.Count];
for (int i = 0; i < posArr.Count; i++)
{
positions[i] = ManageVfxCommon.ParseVector3(posArr[i]);
}
Undo.RecordObject(lr, "Set Line Positions");
lr.positionCount = positions.Length;
lr.SetPositions(positions);
EditorUtility.SetDirty(lr);
return new { success = true, message = $"Set {positions.Length} positions" };
}
public static object AddPosition(JObject @params)
{
LineRenderer lr = LineRead.FindLineRenderer(@params);
if (lr == null) return new { success = false, message = "LineRenderer not found" };
RendererHelpers.EnsureMaterial(lr);
Vector3 pos = ManageVfxCommon.ParseVector3(@params["position"]);
Undo.RecordObject(lr, "Add Line Position");
int idx = lr.positionCount;
lr.positionCount = idx + 1;
lr.SetPosition(idx, pos);
EditorUtility.SetDirty(lr);
return new { success = true, message = $"Added position at index {idx}", index = idx };
}
public static object SetPosition(JObject @params)
{
LineRenderer lr = LineRead.FindLineRenderer(@params);
if (lr == null) return new { success = false, message = "LineRenderer not found" };
RendererHelpers.EnsureMaterial(lr);
int index = @params["index"]?.ToObject<int>() ?? -1;
if (index < 0 || index >= lr.positionCount) return new { success = false, message = $"Invalid index {index}" };
Vector3 pos = ManageVfxCommon.ParseVector3(@params["position"]);
Undo.RecordObject(lr, "Set Line Position");
lr.SetPosition(index, pos);
EditorUtility.SetDirty(lr);
return new { success = true, message = $"Set position at index {index}" };
}
public static object SetWidth(JObject @params)
{
LineRenderer lr = LineRead.FindLineRenderer(@params);
if (lr == null) return new { success = false, message = "LineRenderer not found" };
RendererHelpers.EnsureMaterial(lr);
Undo.RecordObject(lr, "Set Line Width");
var changes = new List<string>();
RendererHelpers.ApplyWidthProperties(@params, changes,
v => lr.startWidth = v, v => lr.endWidth = v,
v => lr.widthCurve = v, v => lr.widthMultiplier = v,
ManageVfxCommon.ParseAnimationCurve);
EditorUtility.SetDirty(lr);
return new { success = true, message = $"Updated: {string.Join(", ", changes)}" };
}
public static object SetColor(JObject @params)
{
LineRenderer lr = LineRead.FindLineRenderer(@params);
if (lr == null) return new { success = false, message = "LineRenderer not found" };
RendererHelpers.EnsureMaterial(lr);
Undo.RecordObject(lr, "Set Line Color");
var changes = new List<string>();
RendererHelpers.ApplyColorProperties(@params, changes,
v => lr.startColor = v, v => lr.endColor = v,
v => lr.colorGradient = v,
ManageVfxCommon.ParseColor, ManageVfxCommon.ParseGradient, fadeEndAlpha: false);
EditorUtility.SetDirty(lr);
return new { success = true, message = $"Updated: {string.Join(", ", changes)}" };
}
public static object SetMaterial(JObject @params)
{
LineRenderer lr = LineRead.FindLineRenderer(@params);
return RendererHelpers.SetRendererMaterial(lr, @params, "Set Line Material", ManageVfxCommon.FindMaterialByPath);
}
public static object SetProperties(JObject @params)
{
LineRenderer lr = LineRead.FindLineRenderer(@params);
if (lr == null) return new { success = false, message = "LineRenderer not found" };
RendererHelpers.EnsureMaterial(lr);
Undo.RecordObject(lr, "Set Line Properties");
var changes = new List<string>();
// Handle material if provided
if (@params["materialPath"] != null)
{
Material mat = ManageVfxCommon.FindMaterialByPath(@params["materialPath"].ToString());
if (mat != null)
{
lr.sharedMaterial = mat;
changes.Add($"material={mat.name}");
}
else
{
McpLog.Warn($"Material not found: {@params["materialPath"]}");
}
}
// Handle positions if provided
if (@params["positions"] != null)
{
JArray posArr = @params["positions"] as JArray;
if (posArr != null && posArr.Count > 0)
{
var positions = new Vector3[posArr.Count];
for (int i = 0; i < posArr.Count; i++)
{
positions[i] = ManageVfxCommon.ParseVector3(posArr[i]);
}
lr.positionCount = positions.Length;
lr.SetPositions(positions);
changes.Add($"positions({positions.Length})");
}
}
else if (@params["positionCount"] != null)
{
int count = @params["positionCount"].ToObject<int>();
lr.positionCount = count;
changes.Add("positionCount");
}
RendererHelpers.ApplyLineTrailProperties(@params, changes,
v => lr.loop = v, v => lr.useWorldSpace = v,
v => lr.numCornerVertices = v, v => lr.numCapVertices = v,
v => lr.alignment = v, v => lr.textureMode = v,
v => lr.generateLightingData = v);
RendererHelpers.ApplyCommonRendererProperties(lr, @params, changes);
EditorUtility.SetDirty(lr);
return new { success = true, message = $"Updated: {string.Join(", ", changes)}" };
}
public static object Clear(JObject @params)
{
LineRenderer lr = LineRead.FindLineRenderer(@params);
if (lr == null) return new { success = false, message = "LineRenderer not found" };
int count = lr.positionCount;
Undo.RecordObject(lr, "Clear Line");
lr.positionCount = 0;
EditorUtility.SetDirty(lr);
return new { success = true, message = $"Cleared {count} positions" };
}
}
}

View File

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

View File

@@ -0,0 +1,412 @@
using System;
using System.Collections.Generic;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using MCPForUnity.Editor.Helpers;
using UnityEngine;
using UnityEditor;
#if UNITY_VFX_GRAPH //Please enable the symbol in the project settings for VisualEffectGraph to work
using UnityEngine.VFX;
#endif
namespace MCPForUnity.Editor.Tools.Vfx
{
/// <summary>
/// Tool for managing Unity VFX components:
/// - ParticleSystem (legacy particle effects)
/// - Visual Effect Graph (modern GPU particles, currently only support HDRP, other SRPs may not work)
/// - LineRenderer (lines, bezier curves, shapes)
/// - TrailRenderer (motion trails)
///
/// COMPONENT REQUIREMENTS:
/// - particle_* actions require ParticleSystem component on target GameObject
/// - vfx_* actions require VisualEffect component (+ com.unity.visualeffectgraph package)
/// - line_* actions require LineRenderer component
/// - trail_* actions require TrailRenderer component
///
/// TARGETING:
/// Use 'target' parameter with optional 'searchMethod':
/// - by_name (default): "Fire" finds first GameObject named "Fire"
/// - by_path: "Effects/Fire" finds GameObject at hierarchy path
/// - by_id: "12345" finds GameObject by instance ID (most reliable)
/// - by_tag: "Enemy" finds first GameObject with tag
///
/// AUTOMATIC MATERIAL ASSIGNMENT:
/// VFX components (ParticleSystem, LineRenderer, TrailRenderer) automatically receive
/// appropriate default materials based on the active rendering pipeline when no material
/// is explicitly specified:
/// - Built-in Pipeline: Uses Unity's built-in Default-Particle.mat and Default-Line.mat
/// - URP/HDRP: Creates materials with pipeline-appropriate unlit shaders
/// - Materials are cached to avoid recreation
/// - Explicit materialPath parameter always overrides auto-assignment
/// - Auto-assigned materials are logged for transparency
///
/// AVAILABLE ACTIONS:
///
/// ParticleSystem (particle_*):
/// - particle_get_info: Get system info and current state
/// - particle_set_main: Set main module (duration, looping, startLifetime, startSpeed, startSize, startColor, gravityModifier, maxParticles, simulationSpace, playOnAwake, etc.)
/// - particle_set_emission: Set emission module (rateOverTime, rateOverDistance)
/// - particle_set_shape: Set shape module (shapeType, radius, angle, arc, position, rotation, scale)
/// - particle_set_color_over_lifetime: Set color gradient over particle lifetime
/// - particle_set_size_over_lifetime: Set size curve over particle lifetime
/// - particle_set_velocity_over_lifetime: Set velocity (x, y, z, speedModifier, space)
/// - particle_set_noise: Set noise turbulence (strength, frequency, scrollSpeed, damping, octaveCount, quality)
/// - particle_set_renderer: Set renderer (renderMode, material, sortMode, minParticleSize, maxParticleSize, etc.)
/// - particle_enable_module: Enable/disable modules by name
/// - particle_play/stop/pause/restart/clear: Playback control (withChildren optional)
/// - particle_add_burst: Add emission burst (time, count, cycles, interval, probability)
/// - particle_clear_bursts: Clear all bursts
///
/// Visual Effect Graph (vfx_*):
/// Asset Management:
/// - vfx_create_asset: Create new VFX asset file (assetName, folderPath, template, overwrite)
/// - vfx_assign_asset: Assign VFX asset to VisualEffect component (target, assetPath)
/// - vfx_list_templates: List available VFX templates in project and packages
/// - vfx_list_assets: List all VFX assets (folder, search filters)
/// Runtime Control:
/// - vfx_get_info: Get VFX info including exposed parameters
/// - vfx_set_float/int/bool: Set exposed scalar parameters (parameter, value)
/// - vfx_set_vector2/vector3/vector4: Set exposed vector parameters (parameter, value as array)
/// - vfx_set_color: Set exposed color (parameter, color as [r,g,b,a])
/// - vfx_set_gradient: Set exposed gradient (parameter, gradient)
/// - vfx_set_texture: Set exposed texture (parameter, texturePath)
/// - vfx_set_mesh: Set exposed mesh (parameter, meshPath)
/// - vfx_set_curve: Set exposed animation curve (parameter, curve)
/// - vfx_send_event: Send event with attributes (eventName, position, velocity, color, size, lifetime)
/// - vfx_play/stop/pause/reinit: Playback control
/// - vfx_set_playback_speed: Set playback speed multiplier (playRate)
/// - vfx_set_seed: Set random seed (seed, resetSeedOnPlay)
///
/// LineRenderer (line_*):
/// - line_get_info: Get line info (position count, width, color, etc.)
/// - line_set_positions: Set all positions (positions as [[x,y,z], ...])
/// - line_add_position: Add position at end (position as [x,y,z])
/// - line_set_position: Set specific position (index, position)
/// - line_set_width: Set width (width, startWidth, endWidth, widthCurve, widthMultiplier)
/// - line_set_color: Set color (color, gradient, startColor, endColor)
/// - line_set_material: Set material (materialPath)
/// - line_set_properties: Set renderer properties (loop, useWorldSpace, alignment, textureMode, numCornerVertices, numCapVertices, etc.)
/// - line_clear: Clear all positions
/// Shape Creation:
/// - line_create_line: Create simple line (start, end, segments)
/// - line_create_circle: Create circle (center, radius, segments, normal)
/// - line_create_arc: Create arc (center, radius, startAngle, endAngle, segments, normal)
/// - line_create_bezier: Create Bezier curve (start, end, controlPoint1, controlPoint2, segments)
///
/// TrailRenderer (trail_*):
/// - trail_get_info: Get trail info
/// - trail_set_time: Set trail duration (time)
/// - trail_set_width: Set width (width, startWidth, endWidth, widthCurve, widthMultiplier)
/// - trail_set_color: Set color (color, gradient, startColor, endColor)
/// - trail_set_material: Set material (materialPath)
/// - trail_set_properties: Set properties (minVertexDistance, autodestruct, emitting, alignment, textureMode, etc.)
/// - trail_clear: Clear trail
/// - trail_emit: Emit point at current position (Unity 2021.1+)
///
/// COMMON PARAMETERS:
/// - target (string): GameObject identifier
/// - searchMethod (string): "by_id" | "by_name" | "by_path" | "by_tag" | "by_layer"
/// - materialPath (string): Asset path to material (e.g., "Assets/Materials/Fire.mat")
/// - color (array): Color as [r, g, b, a] with values 0-1
/// - position (array): 3D position as [x, y, z]
/// - gradient (object): {colorKeys: [{color: [r,g,b,a], time: 0-1}], alphaKeys: [{alpha: 0-1, time: 0-1}]}
/// - curve (object): {keys: [{time: 0-1, value: number, inTangent: number, outTangent: number}]}
///
/// For full parameter details, refer to Unity documentation for each component type.
/// </summary>
[McpForUnityTool("manage_vfx", AutoRegister = false)]
public static class ManageVFX
{
private static readonly Dictionary<string, string> ParamAliases = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
{ "size_over_lifetime", "size" },
{ "start_color_line", "startColor" },
{ "sorting_layer_id", "sortingLayerID" },
{ "material", "materialPath" },
};
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 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;
}
private static string ToCamelCase(string key) => StringCaseUtility.ToCamelCase(key);
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();
// Route to appropriate handler based on action prefix
if (actionLower == "ping")
{
return new { success = true, tool = "manage_vfx", components = new[] { "ParticleSystem", "VisualEffect", "LineRenderer", "TrailRenderer" } };
}
// ParticleSystem actions (particle_*)
if (actionLower.StartsWith("particle_"))
{
return HandleParticleSystemAction(normalizedParams, actionLower.Substring(9));
}
// VFX Graph actions (vfx_*)
if (actionLower.StartsWith("vfx_"))
{
return HandleVFXGraphAction(normalizedParams, actionLower.Substring(4));
}
// LineRenderer actions (line_*)
if (actionLower.StartsWith("line_"))
{
return HandleLineRendererAction(normalizedParams, actionLower.Substring(5));
}
// TrailRenderer actions (trail_*)
if (actionLower.StartsWith("trail_"))
{
return HandleTrailRendererAction(normalizedParams, actionLower.Substring(6));
}
return new { success = false, message = $"Unknown action: {action}. Actions must be prefixed with: particle_, vfx_, line_, or trail_" };
}
catch (Exception ex)
{
return new { success = false, message = ex.Message, stackTrace = ex.StackTrace };
}
}
private static object HandleParticleSystemAction(JObject @params, string action)
{
switch (action)
{
case "get_info": return ParticleRead.GetInfo(@params);
case "set_main": return ParticleWrite.SetMain(@params);
case "set_emission": return ParticleWrite.SetEmission(@params);
case "set_shape": return ParticleWrite.SetShape(@params);
case "set_color_over_lifetime": return ParticleWrite.SetColorOverLifetime(@params);
case "set_size_over_lifetime": return ParticleWrite.SetSizeOverLifetime(@params);
case "set_velocity_over_lifetime": return ParticleWrite.SetVelocityOverLifetime(@params);
case "set_noise": return ParticleWrite.SetNoise(@params);
case "set_renderer": return ParticleWrite.SetRenderer(@params);
case "enable_module": return ParticleControl.EnableModule(@params);
case "play": return ParticleControl.Control(@params, "play");
case "stop": return ParticleControl.Control(@params, "stop");
case "pause": return ParticleControl.Control(@params, "pause");
case "restart": return ParticleControl.Control(@params, "restart");
case "clear": return ParticleControl.Control(@params, "clear");
case "add_burst": return ParticleControl.AddBurst(@params);
case "clear_bursts": return ParticleControl.ClearBursts(@params);
default:
return new { success = false, message = $"Unknown particle action: {action}. Valid: get_info, set_main, set_emission, set_shape, set_color_over_lifetime, set_size_over_lifetime, set_velocity_over_lifetime, set_noise, set_renderer, enable_module, play, stop, pause, restart, clear, add_burst, clear_bursts" };
}
}
// ==================== VFX GRAPH ====================
#region VFX Graph
private static object HandleVFXGraphAction(JObject @params, string action)
{
#if !UNITY_VFX_GRAPH
return new { success = false, message = "VFX Graph package (com.unity.visualeffectgraph) not installed" };
#else
switch (action)
{
// Asset management
case "create_asset": return VfxGraphAssets.CreateAsset(@params);
case "assign_asset": return VfxGraphAssets.AssignAsset(@params);
case "list_templates": return VfxGraphAssets.ListTemplates(@params);
case "list_assets": return VfxGraphAssets.ListAssets(@params);
// Runtime parameter control
case "get_info": return VfxGraphRead.GetInfo(@params);
case "set_float": return VfxGraphWrite.SetParameter<float>(@params, (vfx, n, v) => vfx.SetFloat(n, v));
case "set_int": return VfxGraphWrite.SetParameter<int>(@params, (vfx, n, v) => vfx.SetInt(n, v));
case "set_bool": return VfxGraphWrite.SetParameter<bool>(@params, (vfx, n, v) => vfx.SetBool(n, v));
case "set_vector2": return VfxGraphWrite.SetVector(@params, 2);
case "set_vector3": return VfxGraphWrite.SetVector(@params, 3);
case "set_vector4": return VfxGraphWrite.SetVector(@params, 4);
case "set_color": return VfxGraphWrite.SetColor(@params);
case "set_gradient": return VfxGraphWrite.SetGradient(@params);
case "set_texture": return VfxGraphWrite.SetTexture(@params);
case "set_mesh": return VfxGraphWrite.SetMesh(@params);
case "set_curve": return VfxGraphWrite.SetCurve(@params);
case "send_event": return VfxGraphWrite.SendEvent(@params);
case "play": return VfxGraphControl.Control(@params, "play");
case "stop": return VfxGraphControl.Control(@params, "stop");
case "pause": return VfxGraphControl.Control(@params, "pause");
case "reinit": return VfxGraphControl.Control(@params, "reinit");
case "set_playback_speed": return VfxGraphControl.SetPlaybackSpeed(@params);
case "set_seed": return VfxGraphControl.SetSeed(@params);
default:
return new { success = false, message = $"Unknown vfx action: {action}. Valid: create_asset, assign_asset, list_templates, list_assets, get_info, set_float, set_int, set_bool, set_vector2/3/4, set_color, set_gradient, set_texture, set_mesh, set_curve, send_event, play, stop, pause, reinit, set_playback_speed, set_seed" };
}
#endif
}
#endregion
private static object HandleLineRendererAction(JObject @params, string action)
{
switch (action)
{
case "get_info": return LineRead.GetInfo(@params);
case "set_positions": return LineWrite.SetPositions(@params);
case "add_position": return LineWrite.AddPosition(@params);
case "set_position": return LineWrite.SetPosition(@params);
case "set_width": return LineWrite.SetWidth(@params);
case "set_color": return LineWrite.SetColor(@params);
case "set_material": return LineWrite.SetMaterial(@params);
case "set_properties": return LineWrite.SetProperties(@params);
case "clear": return LineWrite.Clear(@params);
case "create_line": return LineCreate.CreateLine(@params);
case "create_circle": return LineCreate.CreateCircle(@params);
case "create_arc": return LineCreate.CreateArc(@params);
case "create_bezier": return LineCreate.CreateBezier(@params);
default:
return new { success = false, message = $"Unknown line action: {action}. Valid: get_info, set_positions, add_position, set_position, set_width, set_color, set_material, set_properties, clear, create_line, create_circle, create_arc, create_bezier" };
}
}
private static object HandleTrailRendererAction(JObject @params, string action)
{
switch (action)
{
case "get_info": return TrailRead.GetInfo(@params);
case "set_time": return TrailWrite.SetTime(@params);
case "set_width": return TrailWrite.SetWidth(@params);
case "set_color": return TrailWrite.SetColor(@params);
case "set_material": return TrailWrite.SetMaterial(@params);
case "set_properties": return TrailWrite.SetProperties(@params);
case "clear": return TrailControl.Clear(@params);
case "emit": return TrailControl.Emit(@params);
default:
return new { success = false, message = $"Unknown trail action: {action}. Valid: get_info, set_time, set_width, set_color, set_material, set_properties, clear, emit" };
}
}
}
}

View File

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

View File

@@ -0,0 +1,22 @@
using Newtonsoft.Json.Linq;
using MCPForUnity.Editor.Helpers;
using UnityEngine;
namespace MCPForUnity.Editor.Tools.Vfx
{
internal static class ManageVfxCommon
{
public static Color ParseColor(JToken token) => VectorParsing.ParseColorOrDefault(token);
public static Vector3 ParseVector3(JToken token) => VectorParsing.ParseVector3OrDefault(token);
public static Vector4 ParseVector4(JToken token) => VectorParsing.ParseVector4OrDefault(token);
public static Gradient ParseGradient(JToken token) => VectorParsing.ParseGradientOrDefault(token);
public static AnimationCurve ParseAnimationCurve(JToken token, float defaultValue = 1f)
=> VectorParsing.ParseAnimationCurveOrDefault(token, defaultValue);
public static GameObject FindTargetGameObject(JObject @params)
=> ObjectResolver.ResolveGameObject(@params["target"], @params["searchMethod"]?.ToString());
public static Material FindMaterialByPath(string path)
=> ObjectResolver.ResolveMaterial(path);
}
}

View File

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

View File

@@ -0,0 +1,87 @@
using Newtonsoft.Json.Linq;
using UnityEngine;
namespace MCPForUnity.Editor.Tools.Vfx
{
internal static class ParticleCommon
{
public static ParticleSystem FindParticleSystem(JObject @params)
{
GameObject go = ManageVfxCommon.FindTargetGameObject(@params);
return go?.GetComponent<ParticleSystem>();
}
public static ParticleSystem.MinMaxCurve ParseMinMaxCurve(JToken token, float defaultValue = 1f)
{
if (token == null)
return new ParticleSystem.MinMaxCurve(defaultValue);
if (token.Type == JTokenType.Float || token.Type == JTokenType.Integer)
{
return new ParticleSystem.MinMaxCurve(token.ToObject<float>());
}
if (token is JObject obj)
{
string mode = obj["mode"]?.ToString()?.ToLowerInvariant() ?? "constant";
switch (mode)
{
case "constant":
float constant = obj["value"]?.ToObject<float>() ?? defaultValue;
return new ParticleSystem.MinMaxCurve(constant);
case "random_between_constants":
case "two_constants":
float min = obj["min"]?.ToObject<float>() ?? 0f;
float max = obj["max"]?.ToObject<float>() ?? 1f;
return new ParticleSystem.MinMaxCurve(min, max);
case "curve":
AnimationCurve curve = ManageVfxCommon.ParseAnimationCurve(obj, defaultValue);
return new ParticleSystem.MinMaxCurve(obj["multiplier"]?.ToObject<float>() ?? 1f, curve);
default:
return new ParticleSystem.MinMaxCurve(defaultValue);
}
}
return new ParticleSystem.MinMaxCurve(defaultValue);
}
public static ParticleSystem.MinMaxGradient ParseMinMaxGradient(JToken token)
{
if (token == null)
return new ParticleSystem.MinMaxGradient(Color.white);
if (token is JArray arr && arr.Count >= 3)
{
return new ParticleSystem.MinMaxGradient(ManageVfxCommon.ParseColor(arr));
}
if (token is JObject obj)
{
string mode = obj["mode"]?.ToString()?.ToLowerInvariant() ?? "color";
switch (mode)
{
case "color":
return new ParticleSystem.MinMaxGradient(ManageVfxCommon.ParseColor(obj["color"]));
case "two_colors":
Color colorMin = ManageVfxCommon.ParseColor(obj["colorMin"]);
Color colorMax = ManageVfxCommon.ParseColor(obj["colorMax"]);
return new ParticleSystem.MinMaxGradient(colorMin, colorMax);
case "gradient":
return new ParticleSystem.MinMaxGradient(ManageVfxCommon.ParseGradient(obj));
default:
return new ParticleSystem.MinMaxGradient(Color.white);
}
}
return new ParticleSystem.MinMaxGradient(Color.white);
}
}
}

View File

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

View File

@@ -0,0 +1,121 @@
using System;
using Newtonsoft.Json.Linq;
using UnityEditor;
using UnityEngine;
using MCPForUnity.Editor.Helpers;
namespace MCPForUnity.Editor.Tools.Vfx
{
internal static class ParticleControl
{
public static object EnableModule(JObject @params)
{
ParticleSystem ps = ParticleCommon.FindParticleSystem(@params);
if (ps == null) return new { success = false, message = "ParticleSystem not found" };
string moduleName = @params["module"]?.ToString()?.ToLowerInvariant();
bool enabled = @params["enabled"]?.ToObject<bool>() ?? true;
if (string.IsNullOrEmpty(moduleName)) return new { success = false, message = "Module name required" };
Undo.RecordObject(ps, $"Toggle {moduleName}");
switch (moduleName.Replace("_", ""))
{
case "emission": var em = ps.emission; em.enabled = enabled; break;
case "shape": var sh = ps.shape; sh.enabled = enabled; break;
case "coloroverlifetime": var col = ps.colorOverLifetime; col.enabled = enabled; break;
case "sizeoverlifetime": var sol = ps.sizeOverLifetime; sol.enabled = enabled; break;
case "velocityoverlifetime": var vol = ps.velocityOverLifetime; vol.enabled = enabled; break;
case "noise": var n = ps.noise; n.enabled = enabled; break;
case "collision": var coll = ps.collision; coll.enabled = enabled; break;
case "trails": var tr = ps.trails; tr.enabled = enabled; break;
case "lights": var li = ps.lights; li.enabled = enabled; break;
default: return new { success = false, message = $"Unknown module: {moduleName}" };
}
EditorUtility.SetDirty(ps);
return new { success = true, message = $"Module '{moduleName}' {(enabled ? "enabled" : "disabled")}" };
}
public static object Control(JObject @params, string action)
{
ParticleSystem ps = ParticleCommon.FindParticleSystem(@params);
if (ps == null) return new { success = false, message = "ParticleSystem not found" };
// Ensure material is assigned before playing
if (action == "play" || action == "restart")
{
var renderer = ps.GetComponent<ParticleSystemRenderer>();
if (renderer != null)
{
RendererHelpers.EnsureMaterial(renderer);
}
}
bool withChildren = @params["withChildren"]?.ToObject<bool>() ?? true;
switch (action)
{
case "play": ps.Play(withChildren); break;
case "stop": ps.Stop(withChildren, ParticleSystemStopBehavior.StopEmitting); break;
case "pause": ps.Pause(withChildren); break;
case "restart": ps.Stop(withChildren, ParticleSystemStopBehavior.StopEmittingAndClear); ps.Play(withChildren); break;
case "clear": ps.Clear(withChildren); break;
default: return new { success = false, message = $"Unknown action: {action}" };
}
return new { success = true, message = $"ParticleSystem {action}" };
}
public static object AddBurst(JObject @params)
{
ParticleSystem ps = ParticleCommon.FindParticleSystem(@params);
if (ps == null) return new { success = false, message = "ParticleSystem not found" };
// Ensure material is assigned
var renderer = ps.GetComponent<ParticleSystemRenderer>();
if (renderer != null)
{
RendererHelpers.EnsureMaterial(renderer);
}
Undo.RecordObject(ps, "Add Burst");
var emission = ps.emission;
float time = @params["time"]?.ToObject<float>() ?? 0f;
int minCountRaw = @params["minCount"]?.ToObject<int>() ?? @params["count"]?.ToObject<int>() ?? 30;
int maxCountRaw = @params["maxCount"]?.ToObject<int>() ?? @params["count"]?.ToObject<int>() ?? 30;
short minCount = (short)Math.Clamp(minCountRaw, 0, short.MaxValue);
short maxCount = (short)Math.Clamp(maxCountRaw, 0, short.MaxValue);
int cycles = @params["cycles"]?.ToObject<int>() ?? 1;
float interval = @params["interval"]?.ToObject<float>() ?? 0.01f;
var burst = new ParticleSystem.Burst(time, minCount, maxCount, cycles, interval);
burst.probability = @params["probability"]?.ToObject<float>() ?? 1f;
int idx = emission.burstCount;
var bursts = new ParticleSystem.Burst[idx + 1];
emission.GetBursts(bursts);
bursts[idx] = burst;
emission.SetBursts(bursts);
EditorUtility.SetDirty(ps);
return new { success = true, message = $"Added burst at t={time}", burstIndex = idx };
}
public static object ClearBursts(JObject @params)
{
ParticleSystem ps = ParticleCommon.FindParticleSystem(@params);
if (ps == null) return new { success = false, message = "ParticleSystem not found" };
Undo.RecordObject(ps, "Clear Bursts");
var emission = ps.emission;
int count = emission.burstCount;
emission.SetBursts(new ParticleSystem.Burst[0]);
EditorUtility.SetDirty(ps);
return new { success = true, message = $"Cleared {count} bursts" };
}
}
}

View File

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

View File

@@ -0,0 +1,153 @@
using Newtonsoft.Json.Linq;
using System.Linq;
using UnityEngine;
namespace MCPForUnity.Editor.Tools.Vfx
{
internal static class ParticleRead
{
private static object SerializeAnimationCurve(AnimationCurve curve)
{
if (curve == null)
{
return null;
}
return new
{
keys = curve.keys.Select(k => new
{
time = k.time,
value = k.value,
inTangent = k.inTangent,
outTangent = k.outTangent
}).ToArray()
};
}
private static object SerializeMinMaxCurve(ParticleSystem.MinMaxCurve curve)
{
switch (curve.mode)
{
case ParticleSystemCurveMode.Constant:
return new
{
mode = "constant",
value = curve.constant
};
case ParticleSystemCurveMode.TwoConstants:
return new
{
mode = "two_constants",
min = curve.constantMin,
max = curve.constantMax
};
case ParticleSystemCurveMode.Curve:
return new
{
mode = "curve",
multiplier = curve.curveMultiplier,
keys = curve.curve.keys.Select(k => new
{
time = k.time,
value = k.value,
inTangent = k.inTangent,
outTangent = k.outTangent
}).ToArray()
};
case ParticleSystemCurveMode.TwoCurves:
return new
{
mode = "curve",
multiplier = curve.curveMultiplier,
keys = curve.curveMax.keys.Select(k => new
{
time = k.time,
value = k.value,
inTangent = k.inTangent,
outTangent = k.outTangent
}).ToArray(),
originalMode = "two_curves",
curveMin = SerializeAnimationCurve(curve.curveMin),
curveMax = SerializeAnimationCurve(curve.curveMax)
};
default:
return new
{
mode = "constant",
value = curve.constant
};
}
}
public static object GetInfo(JObject @params)
{
ParticleSystem ps = ParticleCommon.FindParticleSystem(@params);
if (ps == null)
{
return new { success = false, message = "ParticleSystem not found" };
}
var main = ps.main;
var emission = ps.emission;
var shape = ps.shape;
var renderer = ps.GetComponent<ParticleSystemRenderer>();
return new
{
success = true,
data = new
{
gameObject = ps.gameObject.name,
isPlaying = ps.isPlaying,
isPaused = ps.isPaused,
particleCount = ps.particleCount,
main = new
{
duration = main.duration,
looping = main.loop,
startLifetime = SerializeMinMaxCurve(main.startLifetime),
startSpeed = SerializeMinMaxCurve(main.startSpeed),
startSize = SerializeMinMaxCurve(main.startSize),
gravityModifier = SerializeMinMaxCurve(main.gravityModifier),
simulationSpace = main.simulationSpace.ToString(),
maxParticles = main.maxParticles
},
emission = new
{
enabled = emission.enabled,
rateOverTime = SerializeMinMaxCurve(emission.rateOverTime),
burstCount = emission.burstCount
},
shape = new
{
enabled = shape.enabled,
shapeType = shape.shapeType.ToString(),
radius = shape.radius,
angle = shape.angle
},
renderer = renderer != null ? new
{
renderMode = renderer.renderMode.ToString(),
sortMode = renderer.sortMode.ToString(),
material = renderer.sharedMaterial?.name,
trailMaterial = renderer.trailMaterial?.name,
minParticleSize = renderer.minParticleSize,
maxParticleSize = renderer.maxParticleSize,
shadowCastingMode = renderer.shadowCastingMode.ToString(),
receiveShadows = renderer.receiveShadows,
lightProbeUsage = renderer.lightProbeUsage.ToString(),
reflectionProbeUsage = renderer.reflectionProbeUsage.ToString(),
sortingOrder = renderer.sortingOrder,
sortingLayerName = renderer.sortingLayerName,
renderingLayerMask = renderer.renderingLayerMask
} : null
}
};
}
}
}

View File

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

View File

@@ -0,0 +1,295 @@
using System;
using System.Collections.Generic;
using Newtonsoft.Json.Linq;
using UnityEditor;
using UnityEngine;
using MCPForUnity.Editor.Helpers;
namespace MCPForUnity.Editor.Tools.Vfx
{
internal static class ParticleWrite
{
public static object SetMain(JObject @params)
{
ParticleSystem ps = ParticleCommon.FindParticleSystem(@params);
if (ps == null) return new { success = false, message = "ParticleSystem not found" };
// Ensure material is assigned before any configuration
var renderer = ps.GetComponent<ParticleSystemRenderer>();
if (renderer != null)
{
RendererHelpers.EnsureMaterial(renderer);
}
// Stop particle system if it's playing and duration needs to be changed
bool wasPlaying = ps.isPlaying;
bool needsStop = @params["duration"] != null && wasPlaying;
if (needsStop)
{
ps.Stop(true, ParticleSystemStopBehavior.StopEmittingAndClear);
}
Undo.RecordObject(ps, "Set ParticleSystem Main");
var main = ps.main;
var changes = new List<string>();
if (@params["duration"] != null) { main.duration = @params["duration"].ToObject<float>(); changes.Add("duration"); }
if (@params["looping"] != null) { main.loop = @params["looping"].ToObject<bool>(); changes.Add("looping"); }
if (@params["prewarm"] != null) { main.prewarm = @params["prewarm"].ToObject<bool>(); changes.Add("prewarm"); }
if (@params["startDelay"] != null) { main.startDelay = ParticleCommon.ParseMinMaxCurve(@params["startDelay"], 0f); changes.Add("startDelay"); }
if (@params["startLifetime"] != null) { main.startLifetime = ParticleCommon.ParseMinMaxCurve(@params["startLifetime"], 5f); changes.Add("startLifetime"); }
if (@params["startSpeed"] != null) { main.startSpeed = ParticleCommon.ParseMinMaxCurve(@params["startSpeed"], 5f); changes.Add("startSpeed"); }
if (@params["startSize"] != null) { main.startSize = ParticleCommon.ParseMinMaxCurve(@params["startSize"], 1f); changes.Add("startSize"); }
if (@params["startRotation"] != null) { main.startRotation = ParticleCommon.ParseMinMaxCurve(@params["startRotation"], 0f); changes.Add("startRotation"); }
if (@params["startColor"] != null) { main.startColor = ParticleCommon.ParseMinMaxGradient(@params["startColor"]); changes.Add("startColor"); }
if (@params["gravityModifier"] != null) { main.gravityModifier = ParticleCommon.ParseMinMaxCurve(@params["gravityModifier"], 0f); changes.Add("gravityModifier"); }
if (@params["simulationSpace"] != null && Enum.TryParse<ParticleSystemSimulationSpace>(@params["simulationSpace"].ToString(), true, out var simSpace)) { main.simulationSpace = simSpace; changes.Add("simulationSpace"); }
if (@params["scalingMode"] != null && Enum.TryParse<ParticleSystemScalingMode>(@params["scalingMode"].ToString(), true, out var scaleMode)) { main.scalingMode = scaleMode; changes.Add("scalingMode"); }
if (@params["playOnAwake"] != null) { main.playOnAwake = @params["playOnAwake"].ToObject<bool>(); changes.Add("playOnAwake"); }
if (@params["maxParticles"] != null) { main.maxParticles = @params["maxParticles"].ToObject<int>(); changes.Add("maxParticles"); }
EditorUtility.SetDirty(ps);
// Restart particle system if it was playing
if (needsStop && wasPlaying)
{
ps.Play(true);
changes.Add("(restarted after duration change)");
}
return new { success = true, message = $"Updated: {string.Join(", ", changes)}" };
}
public static object SetEmission(JObject @params)
{
ParticleSystem ps = ParticleCommon.FindParticleSystem(@params);
if (ps == null) return new { success = false, message = "ParticleSystem not found" };
// Ensure material is assigned
var renderer = ps.GetComponent<ParticleSystemRenderer>();
if (renderer != null)
{
RendererHelpers.EnsureMaterial(renderer);
}
Undo.RecordObject(ps, "Set ParticleSystem Emission");
var emission = ps.emission;
var changes = new List<string>();
if (@params["enabled"] != null) { emission.enabled = @params["enabled"].ToObject<bool>(); changes.Add("enabled"); }
if (@params["rateOverTime"] != null) { emission.rateOverTime = ParticleCommon.ParseMinMaxCurve(@params["rateOverTime"], 10f); changes.Add("rateOverTime"); }
if (@params["rateOverDistance"] != null) { emission.rateOverDistance = ParticleCommon.ParseMinMaxCurve(@params["rateOverDistance"], 0f); changes.Add("rateOverDistance"); }
EditorUtility.SetDirty(ps);
return new { success = true, message = $"Updated emission: {string.Join(", ", changes)}" };
}
public static object SetShape(JObject @params)
{
ParticleSystem ps = ParticleCommon.FindParticleSystem(@params);
if (ps == null) return new { success = false, message = "ParticleSystem not found" };
// Ensure material is assigned
var renderer = ps.GetComponent<ParticleSystemRenderer>();
if (renderer != null)
{
RendererHelpers.EnsureMaterial(renderer);
}
Undo.RecordObject(ps, "Set ParticleSystem Shape");
var shape = ps.shape;
var changes = new List<string>();
if (@params["enabled"] != null) { shape.enabled = @params["enabled"].ToObject<bool>(); changes.Add("enabled"); }
if (@params["shapeType"] != null && Enum.TryParse<ParticleSystemShapeType>(@params["shapeType"].ToString(), true, out var shapeType)) { shape.shapeType = shapeType; changes.Add("shapeType"); }
if (@params["radius"] != null) { shape.radius = @params["radius"].ToObject<float>(); changes.Add("radius"); }
if (@params["radiusThickness"] != null) { shape.radiusThickness = @params["radiusThickness"].ToObject<float>(); changes.Add("radiusThickness"); }
if (@params["angle"] != null) { shape.angle = @params["angle"].ToObject<float>(); changes.Add("angle"); }
if (@params["arc"] != null) { shape.arc = @params["arc"].ToObject<float>(); changes.Add("arc"); }
if (@params["position"] != null) { shape.position = ManageVfxCommon.ParseVector3(@params["position"]); changes.Add("position"); }
if (@params["rotation"] != null) { shape.rotation = ManageVfxCommon.ParseVector3(@params["rotation"]); changes.Add("rotation"); }
if (@params["scale"] != null) { shape.scale = ManageVfxCommon.ParseVector3(@params["scale"]); changes.Add("scale"); }
EditorUtility.SetDirty(ps);
return new { success = true, message = $"Updated shape: {string.Join(", ", changes)}" };
}
public static object SetColorOverLifetime(JObject @params)
{
ParticleSystem ps = ParticleCommon.FindParticleSystem(@params);
if (ps == null) return new { success = false, message = "ParticleSystem not found" };
// Ensure material is assigned
var renderer = ps.GetComponent<ParticleSystemRenderer>();
if (renderer != null)
{
RendererHelpers.EnsureMaterial(renderer);
}
Undo.RecordObject(ps, "Set ParticleSystem Color Over Lifetime");
var col = ps.colorOverLifetime;
var changes = new List<string>();
if (@params["enabled"] != null) { col.enabled = @params["enabled"].ToObject<bool>(); changes.Add("enabled"); }
if (@params["color"] != null) { col.color = ParticleCommon.ParseMinMaxGradient(@params["color"]); changes.Add("color"); }
EditorUtility.SetDirty(ps);
return new { success = true, message = $"Updated: {string.Join(", ", changes)}" };
}
public static object SetSizeOverLifetime(JObject @params)
{
ParticleSystem ps = ParticleCommon.FindParticleSystem(@params);
if (ps == null) return new { success = false, message = "ParticleSystem not found" };
// Ensure material is assigned
var renderer = ps.GetComponent<ParticleSystemRenderer>();
if (renderer != null)
{
RendererHelpers.EnsureMaterial(renderer);
}
Undo.RecordObject(ps, "Set ParticleSystem Size Over Lifetime");
var sol = ps.sizeOverLifetime;
var changes = new List<string>();
bool hasSizeProperty = @params["size"] != null || @params["sizeX"] != null ||
@params["sizeY"] != null || @params["sizeZ"] != null;
if (hasSizeProperty && @params["enabled"] == null && !sol.enabled)
{
sol.enabled = true;
changes.Add("enabled");
}
else if (@params["enabled"] != null)
{
sol.enabled = @params["enabled"].ToObject<bool>();
changes.Add("enabled");
}
if (@params["separateAxes"] != null) { sol.separateAxes = @params["separateAxes"].ToObject<bool>(); changes.Add("separateAxes"); }
if (@params["size"] != null) { sol.size = ParticleCommon.ParseMinMaxCurve(@params["size"], 1f); changes.Add("size"); }
if (@params["sizeX"] != null) { sol.x = ParticleCommon.ParseMinMaxCurve(@params["sizeX"], 1f); changes.Add("sizeX"); }
if (@params["sizeY"] != null) { sol.y = ParticleCommon.ParseMinMaxCurve(@params["sizeY"], 1f); changes.Add("sizeY"); }
if (@params["sizeZ"] != null) { sol.z = ParticleCommon.ParseMinMaxCurve(@params["sizeZ"], 1f); changes.Add("sizeZ"); }
EditorUtility.SetDirty(ps);
return new { success = true, message = $"Updated: {string.Join(", ", changes)}" };
}
public static object SetVelocityOverLifetime(JObject @params)
{
ParticleSystem ps = ParticleCommon.FindParticleSystem(@params);
if (ps == null) return new { success = false, message = "ParticleSystem not found" };
// Ensure material is assigned
var renderer = ps.GetComponent<ParticleSystemRenderer>();
if (renderer != null)
{
RendererHelpers.EnsureMaterial(renderer);
}
Undo.RecordObject(ps, "Set ParticleSystem Velocity Over Lifetime");
var vol = ps.velocityOverLifetime;
var changes = new List<string>();
if (@params["enabled"] != null) { vol.enabled = @params["enabled"].ToObject<bool>(); changes.Add("enabled"); }
if (@params["space"] != null && Enum.TryParse<ParticleSystemSimulationSpace>(@params["space"].ToString(), true, out var space)) { vol.space = space; changes.Add("space"); }
if (@params["x"] != null) { vol.x = ParticleCommon.ParseMinMaxCurve(@params["x"], 0f); changes.Add("x"); }
if (@params["y"] != null) { vol.y = ParticleCommon.ParseMinMaxCurve(@params["y"], 0f); changes.Add("y"); }
if (@params["z"] != null) { vol.z = ParticleCommon.ParseMinMaxCurve(@params["z"], 0f); changes.Add("z"); }
if (@params["speedModifier"] != null) { vol.speedModifier = ParticleCommon.ParseMinMaxCurve(@params["speedModifier"], 1f); changes.Add("speedModifier"); }
EditorUtility.SetDirty(ps);
return new { success = true, message = $"Updated: {string.Join(", ", changes)}" };
}
public static object SetNoise(JObject @params)
{
ParticleSystem ps = ParticleCommon.FindParticleSystem(@params);
if (ps == null) return new { success = false, message = "ParticleSystem not found" };
// Ensure material is assigned
var renderer = ps.GetComponent<ParticleSystemRenderer>();
if (renderer != null)
{
RendererHelpers.EnsureMaterial(renderer);
}
Undo.RecordObject(ps, "Set ParticleSystem Noise");
var noise = ps.noise;
var changes = new List<string>();
if (@params["enabled"] != null) { noise.enabled = @params["enabled"].ToObject<bool>(); changes.Add("enabled"); }
if (@params["strength"] != null) { noise.strength = ParticleCommon.ParseMinMaxCurve(@params["strength"], 1f); changes.Add("strength"); }
if (@params["frequency"] != null) { noise.frequency = @params["frequency"].ToObject<float>(); changes.Add("frequency"); }
if (@params["scrollSpeed"] != null) { noise.scrollSpeed = ParticleCommon.ParseMinMaxCurve(@params["scrollSpeed"], 0f); changes.Add("scrollSpeed"); }
if (@params["damping"] != null) { noise.damping = @params["damping"].ToObject<bool>(); changes.Add("damping"); }
if (@params["octaveCount"] != null) { noise.octaveCount = @params["octaveCount"].ToObject<int>(); changes.Add("octaveCount"); }
if (@params["quality"] != null && Enum.TryParse<ParticleSystemNoiseQuality>(@params["quality"].ToString(), true, out var quality)) { noise.quality = quality; changes.Add("quality"); }
EditorUtility.SetDirty(ps);
return new { success = true, message = $"Updated noise: {string.Join(", ", changes)}" };
}
public static object SetRenderer(JObject @params)
{
ParticleSystem ps = ParticleCommon.FindParticleSystem(@params);
if (ps == null) return new { success = false, message = "ParticleSystem not found" };
var renderer = ps.GetComponent<ParticleSystemRenderer>();
if (renderer == null) return new { success = false, message = "ParticleSystemRenderer not found" };
// Ensure material is set before any other operations
RendererHelpers.EnsureMaterial(renderer);
Undo.RecordObject(renderer, "Set ParticleSystem Renderer");
var changes = new List<string>();
if (@params["renderMode"] != null && Enum.TryParse<ParticleSystemRenderMode>(@params["renderMode"].ToString(), true, out var renderMode)) { renderer.renderMode = renderMode; changes.Add("renderMode"); }
if (@params["sortMode"] != null && Enum.TryParse<ParticleSystemSortMode>(@params["sortMode"].ToString(), true, out var sortMode)) { renderer.sortMode = sortMode; changes.Add("sortMode"); }
if (@params["minParticleSize"] != null) { renderer.minParticleSize = @params["minParticleSize"].ToObject<float>(); changes.Add("minParticleSize"); }
if (@params["maxParticleSize"] != null) { renderer.maxParticleSize = @params["maxParticleSize"].ToObject<float>(); changes.Add("maxParticleSize"); }
if (@params["lengthScale"] != null) { renderer.lengthScale = @params["lengthScale"].ToObject<float>(); changes.Add("lengthScale"); }
if (@params["velocityScale"] != null) { renderer.velocityScale = @params["velocityScale"].ToObject<float>(); changes.Add("velocityScale"); }
if (@params["cameraVelocityScale"] != null) { renderer.cameraVelocityScale = @params["cameraVelocityScale"].ToObject<float>(); changes.Add("cameraVelocityScale"); }
if (@params["normalDirection"] != null) { renderer.normalDirection = @params["normalDirection"].ToObject<float>(); changes.Add("normalDirection"); }
if (@params["alignment"] != null && Enum.TryParse<ParticleSystemRenderSpace>(@params["alignment"].ToString(), true, out var alignment)) { renderer.alignment = alignment; changes.Add("alignment"); }
if (@params["pivot"] != null) { renderer.pivot = ManageVfxCommon.ParseVector3(@params["pivot"]); changes.Add("pivot"); }
if (@params["flip"] != null) { renderer.flip = ManageVfxCommon.ParseVector3(@params["flip"]); changes.Add("flip"); }
if (@params["allowRoll"] != null) { renderer.allowRoll = @params["allowRoll"].ToObject<bool>(); changes.Add("allowRoll"); }
if (@params["shadowBias"] != null) { renderer.shadowBias = @params["shadowBias"].ToObject<float>(); changes.Add("shadowBias"); }
RendererHelpers.ApplyCommonRendererProperties(renderer, @params, changes);
if (@params["materialPath"] != null)
{
string matPath = @params["materialPath"].ToString();
var findInst = new JObject { ["find"] = matPath };
Material mat = ObjectResolver.Resolve(findInst, typeof(Material)) as Material;
if (mat != null)
{
renderer.sharedMaterial = mat;
changes.Add($"material={mat.name}");
}
else
{
McpLog.Warn($"Material not found at path: {matPath}. Keeping existing material.");
}
}
if (@params["trailMaterialPath"] != null)
{
var findInst = new JObject { ["find"] = @params["trailMaterialPath"].ToString() };
Material mat = ObjectResolver.Resolve(findInst, typeof(Material)) as Material;
if (mat != null) { renderer.trailMaterial = mat; changes.Add("trailMaterial"); }
}
EditorUtility.SetDirty(renderer);
return new { success = true, message = $"Updated renderer: {string.Join(", ", changes)}" };
}
}
}

View File

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

View File

@@ -0,0 +1,36 @@
using Newtonsoft.Json.Linq;
using UnityEditor;
using UnityEngine;
using MCPForUnity.Editor.Helpers;
namespace MCPForUnity.Editor.Tools.Vfx
{
internal static class TrailControl
{
public static object Clear(JObject @params)
{
TrailRenderer tr = TrailRead.FindTrailRenderer(@params);
if (tr == null) return new { success = false, message = "TrailRenderer not found" };
Undo.RecordObject(tr, "Clear Trail");
tr.Clear();
return new { success = true, message = "Trail cleared" };
}
public static object Emit(JObject @params)
{
TrailRenderer tr = TrailRead.FindTrailRenderer(@params);
if (tr == null) return new { success = false, message = "TrailRenderer not found" };
RendererHelpers.EnsureMaterial(tr);
#if UNITY_2021_1_OR_NEWER
Vector3 pos = ManageVfxCommon.ParseVector3(@params["position"]);
tr.AddPosition(pos);
return new { success = true, message = $"Emitted at ({pos.x}, {pos.y}, {pos.z})" };
#else
return new { success = false, message = "AddPosition requires Unity 2021.1+" };
#endif
}
}
}

View File

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

View File

@@ -0,0 +1,49 @@
using Newtonsoft.Json.Linq;
using UnityEngine;
namespace MCPForUnity.Editor.Tools.Vfx
{
internal static class TrailRead
{
public static TrailRenderer FindTrailRenderer(JObject @params)
{
GameObject go = ManageVfxCommon.FindTargetGameObject(@params);
return go?.GetComponent<TrailRenderer>();
}
public static object GetInfo(JObject @params)
{
TrailRenderer tr = FindTrailRenderer(@params);
if (tr == null) return new { success = false, message = "TrailRenderer not found" };
return new
{
success = true,
data = new
{
gameObject = tr.gameObject.name,
time = tr.time,
startWidth = tr.startWidth,
endWidth = tr.endWidth,
minVertexDistance = tr.minVertexDistance,
emitting = tr.emitting,
autodestruct = tr.autodestruct,
positionCount = tr.positionCount,
alignment = tr.alignment.ToString(),
textureMode = tr.textureMode.ToString(),
numCornerVertices = tr.numCornerVertices,
numCapVertices = tr.numCapVertices,
generateLightingData = tr.generateLightingData,
material = tr.sharedMaterial?.name,
shadowCastingMode = tr.shadowCastingMode.ToString(),
receiveShadows = tr.receiveShadows,
lightProbeUsage = tr.lightProbeUsage.ToString(),
reflectionProbeUsage = tr.reflectionProbeUsage.ToString(),
sortingOrder = tr.sortingOrder,
sortingLayerName = tr.sortingLayerName,
renderingLayerMask = tr.renderingLayerMask
}
};
}
}
}

View File

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

View File

@@ -0,0 +1,130 @@
using System;
using System.Collections.Generic;
using MCPForUnity.Editor.Helpers;
using Newtonsoft.Json.Linq;
using UnityEditor;
using UnityEngine;
namespace MCPForUnity.Editor.Tools.Vfx
{
internal static class TrailWrite
{
public static object SetTime(JObject @params)
{
TrailRenderer tr = TrailRead.FindTrailRenderer(@params);
if (tr == null) return new { success = false, message = "TrailRenderer not found" };
RendererHelpers.EnsureMaterial(tr);
float time = @params["time"]?.ToObject<float>() ?? 5f;
Undo.RecordObject(tr, "Set Trail Time");
tr.time = time;
EditorUtility.SetDirty(tr);
return new { success = true, message = $"Set trail time to {time}s" };
}
public static object SetWidth(JObject @params)
{
TrailRenderer tr = TrailRead.FindTrailRenderer(@params);
if (tr == null) return new { success = false, message = "TrailRenderer not found" };
RendererHelpers.EnsureMaterial(tr);
Undo.RecordObject(tr, "Set Trail Width");
var changes = new List<string>();
RendererHelpers.ApplyWidthProperties(@params, changes,
v => tr.startWidth = v, v => tr.endWidth = v,
v => tr.widthCurve = v, v => tr.widthMultiplier = v,
ManageVfxCommon.ParseAnimationCurve);
EditorUtility.SetDirty(tr);
return new { success = true, message = $"Updated: {string.Join(", ", changes)}" };
}
public static object SetColor(JObject @params)
{
TrailRenderer tr = TrailRead.FindTrailRenderer(@params);
if (tr == null) return new { success = false, message = "TrailRenderer not found" };
RendererHelpers.EnsureMaterial(tr);
Undo.RecordObject(tr, "Set Trail Color");
var changes = new List<string>();
RendererHelpers.ApplyColorProperties(@params, changes,
v => tr.startColor = v, v => tr.endColor = v,
v => tr.colorGradient = v,
ManageVfxCommon.ParseColor, ManageVfxCommon.ParseGradient, fadeEndAlpha: true);
EditorUtility.SetDirty(tr);
return new { success = true, message = $"Updated: {string.Join(", ", changes)}" };
}
public static object SetMaterial(JObject @params)
{
TrailRenderer tr = TrailRead.FindTrailRenderer(@params);
return RendererHelpers.SetRendererMaterial(tr, @params, "Set Trail Material", ManageVfxCommon.FindMaterialByPath);
}
public static object SetProperties(JObject @params)
{
TrailRenderer tr = TrailRead.FindTrailRenderer(@params);
if (tr == null) return new { success = false, message = "TrailRenderer not found" };
RendererHelpers.EnsureMaterial(tr);
Undo.RecordObject(tr, "Set Trail Properties");
var changes = new List<string>();
// Handle material if provided
if (@params["materialPath"] != null)
{
Material mat = ManageVfxCommon.FindMaterialByPath(@params["materialPath"].ToString());
if (mat != null)
{
tr.sharedMaterial = mat;
changes.Add($"material={mat.name}");
}
else
{
McpLog.Warn($"Material not found: {@params["materialPath"]}");
}
}
// Handle time if provided
if (@params["time"] != null) { tr.time = @params["time"].ToObject<float>(); changes.Add("time"); }
// Handle width properties if provided
if (@params["width"] != null || @params["startWidth"] != null || @params["endWidth"] != null)
{
if (@params["width"] != null)
{
float w = @params["width"].ToObject<float>();
tr.startWidth = w;
tr.endWidth = w;
changes.Add("width");
}
if (@params["startWidth"] != null) { tr.startWidth = @params["startWidth"].ToObject<float>(); changes.Add("startWidth"); }
if (@params["endWidth"] != null) { tr.endWidth = @params["endWidth"].ToObject<float>(); changes.Add("endWidth"); }
}
if (@params["minVertexDistance"] != null) { tr.minVertexDistance = @params["minVertexDistance"].ToObject<float>(); changes.Add("minVertexDistance"); }
if (@params["autodestruct"] != null) { tr.autodestruct = @params["autodestruct"].ToObject<bool>(); changes.Add("autodestruct"); }
if (@params["emitting"] != null) { tr.emitting = @params["emitting"].ToObject<bool>(); changes.Add("emitting"); }
RendererHelpers.ApplyLineTrailProperties(@params, changes,
null, null,
v => tr.numCornerVertices = v, v => tr.numCapVertices = v,
v => tr.alignment = v, v => tr.textureMode = v,
v => tr.generateLightingData = v);
RendererHelpers.ApplyCommonRendererProperties(tr, @params, changes);
EditorUtility.SetDirty(tr);
return new { success = true, message = $"Updated: {string.Join(", ", changes)}" };
}
}
}

View File

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

View File

@@ -0,0 +1,568 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Newtonsoft.Json.Linq;
using UnityEditor;
using UnityEngine;
#if UNITY_VFX_GRAPH
using UnityEngine.VFX;
#endif
namespace MCPForUnity.Editor.Tools.Vfx
{
/// <summary>
/// Asset management operations for VFX Graph.
/// Handles creating, assigning, and listing VFX assets.
/// Requires com.unity.visualeffectgraph package and UNITY_VFX_GRAPH symbol.
/// </summary>
internal static class VfxGraphAssets
{
#if !UNITY_VFX_GRAPH
public static object CreateAsset(JObject @params)
{
return new { success = false, message = "VFX Graph package (com.unity.visualeffectgraph) not installed" };
}
public static object AssignAsset(JObject @params)
{
return new { success = false, message = "VFX Graph package (com.unity.visualeffectgraph) not installed" };
}
public static object ListTemplates(JObject @params)
{
return new { success = false, message = "VFX Graph package (com.unity.visualeffectgraph) not installed" };
}
public static object ListAssets(JObject @params)
{
return new { success = false, message = "VFX Graph package (com.unity.visualeffectgraph) not installed" };
}
#else
private static readonly string[] SupportedVfxGraphVersions = { "12.1" };
/// <summary>
/// Creates a new VFX Graph asset file from a template.
/// </summary>
public static object CreateAsset(JObject @params)
{
string assetName = @params["assetName"]?.ToString();
string folderPath = @params["folderPath"]?.ToString() ?? "Assets/VFX";
string template = @params["template"]?.ToString() ?? "empty";
if (string.IsNullOrEmpty(assetName))
{
return new { success = false, message = "assetName is required" };
}
string versionError = ValidateVfxGraphVersion();
if (!string.IsNullOrEmpty(versionError))
{
return new { success = false, message = versionError };
}
// Ensure folder exists
if (!AssetDatabase.IsValidFolder(folderPath))
{
string[] folders = folderPath.Split('/');
string currentPath = folders[0];
for (int i = 1; i < folders.Length; i++)
{
string newPath = currentPath + "/" + folders[i];
if (!AssetDatabase.IsValidFolder(newPath))
{
AssetDatabase.CreateFolder(currentPath, folders[i]);
}
currentPath = newPath;
}
}
string assetPath = $"{folderPath}/{assetName}.vfx";
// Check if asset already exists
if (AssetDatabase.LoadAssetAtPath<VisualEffectAsset>(assetPath) != null)
{
bool overwrite = @params["overwrite"]?.ToObject<bool>() ?? false;
if (!overwrite)
{
return new { success = false, message = $"Asset already exists at {assetPath}. Set overwrite=true to replace." };
}
AssetDatabase.DeleteAsset(assetPath);
}
// Find template asset and copy it
string templatePath = FindTemplate(template);
string templateAssetPath = TryGetAssetPathFromFileSystem(templatePath);
VisualEffectAsset newAsset = null;
if (!string.IsNullOrEmpty(templateAssetPath))
{
// Copy the asset to create a new VFX Graph asset
if (!AssetDatabase.CopyAsset(templateAssetPath, assetPath))
{
return new { success = false, message = $"Failed to copy VFX template from {templateAssetPath}" };
}
AssetDatabase.Refresh();
newAsset = AssetDatabase.LoadAssetAtPath<VisualEffectAsset>(assetPath);
}
else
{
return new { success = false, message = "VFX template not found. Add a .vfx template asset or install VFX Graph templates." };
}
if (newAsset == null)
{
return new { success = false, message = "Failed to create VFX asset. Try using a template from list_templates." };
}
return new
{
success = true,
message = $"Created VFX asset: {assetPath}",
data = new
{
assetPath = assetPath,
assetName = newAsset.name,
template = template
}
};
}
/// <summary>
/// Finds VFX template path by name.
/// </summary>
private static string FindTemplate(string templateName)
{
// Get the actual filesystem path for the VFX Graph package using PackageManager API
var packageInfo = UnityEditor.PackageManager.PackageInfo.FindForAssetPath("Packages/com.unity.visualeffectgraph");
var searchPaths = new List<string>();
if (packageInfo != null)
{
// Use the resolved path from PackageManager (handles Library/PackageCache paths)
searchPaths.Add(System.IO.Path.Combine(packageInfo.resolvedPath, "Editor/Templates"));
searchPaths.Add(System.IO.Path.Combine(packageInfo.resolvedPath, "Samples"));
}
// Also search project-local paths
searchPaths.Add("Assets/VFX/Templates");
string[] templatePatterns = new[]
{
$"{templateName}.vfx",
$"VFX{templateName}.vfx",
$"Simple{templateName}.vfx",
$"{templateName}VFX.vfx"
};
foreach (string basePath in searchPaths)
{
string searchRoot = basePath;
if (basePath.StartsWith("Assets/"))
{
searchRoot = System.IO.Path.Combine(UnityEngine.Application.dataPath, basePath.Substring("Assets/".Length));
}
if (!System.IO.Directory.Exists(searchRoot))
{
continue;
}
foreach (string pattern in templatePatterns)
{
string[] files = System.IO.Directory.GetFiles(searchRoot, pattern, System.IO.SearchOption.AllDirectories);
if (files.Length > 0)
{
return files[0];
}
}
// Also search by partial match
try
{
string[] allVfxFiles = System.IO.Directory.GetFiles(searchRoot, "*.vfx", System.IO.SearchOption.AllDirectories);
foreach (string file in allVfxFiles)
{
if (System.IO.Path.GetFileNameWithoutExtension(file).ToLower().Contains(templateName.ToLower()))
{
return file;
}
}
}
catch (Exception ex)
{
Debug.LogWarning($"Failed to search VFX templates under '{searchRoot}': {ex.Message}");
}
}
// Search in project assets
string[] guids = AssetDatabase.FindAssets("t:VisualEffectAsset " + templateName);
if (guids.Length > 0)
{
string assetPath = AssetDatabase.GUIDToAssetPath(guids[0]);
// Convert asset path (e.g., "Assets/...") to absolute filesystem path
if (!string.IsNullOrEmpty(assetPath) && assetPath.StartsWith("Assets/"))
{
return System.IO.Path.Combine(UnityEngine.Application.dataPath, assetPath.Substring("Assets/".Length));
}
if (!string.IsNullOrEmpty(assetPath) && assetPath.StartsWith("Packages/"))
{
var info = UnityEditor.PackageManager.PackageInfo.FindForAssetPath(assetPath);
if (info != null)
{
string relPath = assetPath.Substring(("Packages/" + info.name + "/").Length);
return System.IO.Path.Combine(info.resolvedPath, relPath);
}
}
return null;
}
return null;
}
/// <summary>
/// Assigns a VFX asset to a VisualEffect component.
/// </summary>
public static object AssignAsset(JObject @params)
{
VisualEffect vfx = VfxGraphCommon.FindVisualEffect(@params);
if (vfx == null)
{
return new { success = false, message = "VisualEffect component not found" };
}
string assetPath = @params["assetPath"]?.ToString();
if (string.IsNullOrEmpty(assetPath))
{
return new { success = false, message = "assetPath is required" };
}
// Validate and normalize path
// Reject absolute paths, parent directory traversal, and backslashes
if (assetPath.Contains("\\") || assetPath.Contains("..") || System.IO.Path.IsPathRooted(assetPath))
{
return new { success = false, message = "Invalid assetPath: traversal and absolute paths are not allowed" };
}
if (assetPath.StartsWith("Packages/"))
{
return new { success = false, message = "Invalid assetPath: VFX assets must live under Assets/." };
}
if (!assetPath.StartsWith("Assets/"))
{
assetPath = "Assets/" + assetPath;
}
if (!assetPath.EndsWith(".vfx"))
{
assetPath += ".vfx";
}
// Verify the normalized path doesn't escape the project
string fullPath = System.IO.Path.Combine(UnityEngine.Application.dataPath, assetPath.Substring("Assets/".Length));
string canonicalProjectRoot = System.IO.Path.GetFullPath(UnityEngine.Application.dataPath);
string canonicalAssetPath = System.IO.Path.GetFullPath(fullPath);
if (!canonicalAssetPath.StartsWith(canonicalProjectRoot + System.IO.Path.DirectorySeparatorChar) &&
canonicalAssetPath != canonicalProjectRoot)
{
return new { success = false, message = "Invalid assetPath: would escape project directory" };
}
var asset = AssetDatabase.LoadAssetAtPath<VisualEffectAsset>(assetPath);
if (asset == null)
{
// Try searching by name
string searchName = System.IO.Path.GetFileNameWithoutExtension(assetPath);
string[] guids = AssetDatabase.FindAssets($"t:VisualEffectAsset {searchName}");
if (guids.Length > 0)
{
assetPath = AssetDatabase.GUIDToAssetPath(guids[0]);
asset = AssetDatabase.LoadAssetAtPath<VisualEffectAsset>(assetPath);
}
}
if (asset == null)
{
return new { success = false, message = $"VFX asset not found: {assetPath}" };
}
Undo.RecordObject(vfx, "Assign VFX Asset");
vfx.visualEffectAsset = asset;
EditorUtility.SetDirty(vfx);
return new
{
success = true,
message = $"Assigned VFX asset '{asset.name}' to {vfx.gameObject.name}",
data = new
{
gameObject = vfx.gameObject.name,
assetName = asset.name,
assetPath = assetPath
}
};
}
/// <summary>
/// Lists available VFX templates.
/// </summary>
public static object ListTemplates(JObject @params)
{
var templates = new List<object>();
var seenPaths = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
// Get the actual filesystem path for the VFX Graph package using PackageManager API
var packageInfo = UnityEditor.PackageManager.PackageInfo.FindForAssetPath("Packages/com.unity.visualeffectgraph");
var searchPaths = new List<string>();
if (packageInfo != null)
{
// Use the resolved path from PackageManager (handles Library/PackageCache paths)
searchPaths.Add(System.IO.Path.Combine(packageInfo.resolvedPath, "Editor/Templates"));
searchPaths.Add(System.IO.Path.Combine(packageInfo.resolvedPath, "Samples"));
}
// Also search project-local paths
searchPaths.Add("Assets/VFX/Templates");
searchPaths.Add("Assets/VFX");
// Precompute normalized package path for comparison
string normalizedPackagePath = null;
if (packageInfo != null)
{
normalizedPackagePath = packageInfo.resolvedPath.Replace("\\", "/");
}
// Precompute the Assets base path for converting absolute paths to project-relative
string assetsBasePath = Application.dataPath.Replace("\\", "/");
foreach (string basePath in searchPaths)
{
if (!System.IO.Directory.Exists(basePath))
{
continue;
}
try
{
string[] vfxFiles = System.IO.Directory.GetFiles(basePath, "*.vfx", System.IO.SearchOption.AllDirectories);
foreach (string file in vfxFiles)
{
string absolutePath = file.Replace("\\", "/");
string name = System.IO.Path.GetFileNameWithoutExtension(file);
bool isPackage = normalizedPackagePath != null && absolutePath.StartsWith(normalizedPackagePath);
// Convert absolute path to project-relative path
string projectRelativePath;
if (isPackage)
{
// For package paths, convert to Packages/... format
projectRelativePath = "Packages/" + packageInfo.name + absolutePath.Substring(normalizedPackagePath.Length);
}
else if (absolutePath.StartsWith(assetsBasePath))
{
// For project assets, convert to Assets/... format
projectRelativePath = "Assets" + absolutePath.Substring(assetsBasePath.Length);
}
else
{
// Fallback: use the absolute path if we can't determine the relative path
projectRelativePath = absolutePath;
}
string normalizedPath = projectRelativePath.Replace("\\", "/");
if (seenPaths.Add(normalizedPath))
{
templates.Add(new { name = name, path = projectRelativePath, source = isPackage ? "package" : "project" });
}
}
}
catch (Exception ex)
{
Debug.LogWarning($"Failed to list VFX templates under '{basePath}': {ex.Message}");
}
}
// Also search project assets
string[] guids = AssetDatabase.FindAssets("t:VisualEffectAsset");
foreach (string guid in guids)
{
string path = AssetDatabase.GUIDToAssetPath(guid);
string normalizedPath = path.Replace("\\", "/");
if (seenPaths.Add(normalizedPath))
{
string name = System.IO.Path.GetFileNameWithoutExtension(path);
templates.Add(new { name = name, path = path, source = "project" });
}
}
return new
{
success = true,
data = new
{
count = templates.Count,
templates = templates
}
};
}
/// <summary>
/// Lists all VFX assets in the project.
/// </summary>
public static object ListAssets(JObject @params)
{
string searchFolder = @params["folder"]?.ToString();
string searchPattern = @params["search"]?.ToString();
string filter = "t:VisualEffectAsset";
if (!string.IsNullOrEmpty(searchPattern))
{
filter += " " + searchPattern;
}
string[] guids;
if (!string.IsNullOrEmpty(searchFolder))
{
if (searchFolder.Contains("\\") || searchFolder.Contains("..") || System.IO.Path.IsPathRooted(searchFolder))
{
return new { success = false, message = "Invalid folder: traversal and absolute paths are not allowed" };
}
if (searchFolder.StartsWith("Packages/"))
{
return new { success = false, message = "Invalid folder: VFX assets must live under Assets/." };
}
if (!searchFolder.StartsWith("Assets/"))
{
searchFolder = "Assets/" + searchFolder;
}
string fullPath = System.IO.Path.Combine(UnityEngine.Application.dataPath, searchFolder.Substring("Assets/".Length));
string canonicalProjectRoot = System.IO.Path.GetFullPath(UnityEngine.Application.dataPath);
string canonicalSearchFolder = System.IO.Path.GetFullPath(fullPath);
if (!canonicalSearchFolder.StartsWith(canonicalProjectRoot + System.IO.Path.DirectorySeparatorChar) &&
canonicalSearchFolder != canonicalProjectRoot)
{
return new { success = false, message = "Invalid folder: would escape project directory" };
}
guids = AssetDatabase.FindAssets(filter, new[] { searchFolder });
}
else
{
guids = AssetDatabase.FindAssets(filter);
}
var assets = new List<object>();
foreach (string guid in guids)
{
string path = AssetDatabase.GUIDToAssetPath(guid);
var asset = AssetDatabase.LoadAssetAtPath<VisualEffectAsset>(path);
if (asset != null)
{
assets.Add(new
{
name = asset.name,
path = path,
guid = guid
});
}
}
return new
{
success = true,
data = new
{
count = assets.Count,
assets = assets
}
};
}
private static string ValidateVfxGraphVersion()
{
var info = UnityEditor.PackageManager.PackageInfo.FindForAssetPath("Packages/com.unity.visualeffectgraph");
if (info == null)
{
return "VFX Graph package (com.unity.visualeffectgraph) not installed";
}
if (IsVersionSupported(info.version))
{
return null;
}
string supported = string.Join(", ", SupportedVfxGraphVersions.Select(version => $"{version}.x"));
return $"Unsupported VFX Graph version {info.version}. Supported versions: {supported}.";
}
private static bool IsVersionSupported(string installedVersion)
{
if (string.IsNullOrEmpty(installedVersion))
{
return false;
}
string normalized = installedVersion;
int suffixIndex = normalized.IndexOfAny(new[] { '-', '+' });
if (suffixIndex >= 0)
{
normalized = normalized.Substring(0, suffixIndex);
}
if (!Version.TryParse(normalized, out Version installed))
{
return false;
}
foreach (string supported in SupportedVfxGraphVersions)
{
if (!Version.TryParse(supported, out Version target))
{
continue;
}
if (installed.Major == target.Major && installed.Minor == target.Minor)
{
return true;
}
}
return false;
}
private static string TryGetAssetPathFromFileSystem(string templatePath)
{
if (string.IsNullOrEmpty(templatePath))
{
return null;
}
string normalized = templatePath.Replace("\\", "/");
string assetsRoot = Application.dataPath.Replace("\\", "/");
if (normalized.StartsWith(assetsRoot + "/"))
{
return "Assets/" + normalized.Substring(assetsRoot.Length + 1);
}
var packageInfo = UnityEditor.PackageManager.PackageInfo.FindForAssetPath("Packages/com.unity.visualeffectgraph");
if (packageInfo != null)
{
string packageRoot = packageInfo.resolvedPath.Replace("\\", "/");
if (normalized.StartsWith(packageRoot + "/"))
{
return "Packages/" + packageInfo.name + "/" + normalized.Substring(packageRoot.Length + 1);
}
}
return null;
}
#endif
}
}

View File

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

View File

@@ -0,0 +1,29 @@
using Newtonsoft.Json.Linq;
using UnityEngine;
#if UNITY_VFX_GRAPH
using UnityEngine.VFX;
#endif
namespace MCPForUnity.Editor.Tools.Vfx
{
/// <summary>
/// Common utilities for VFX Graph operations.
/// </summary>
internal static class VfxGraphCommon
{
#if UNITY_VFX_GRAPH
/// <summary>
/// Finds a VisualEffect component on the target GameObject.
/// </summary>
public static VisualEffect FindVisualEffect(JObject @params)
{
if (@params == null)
return null;
GameObject go = ManageVfxCommon.FindTargetGameObject(@params);
return go?.GetComponent<VisualEffect>();
}
#endif
}
}

View File

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

View File

@@ -0,0 +1,89 @@
using Newtonsoft.Json.Linq;
using UnityEditor;
#if UNITY_VFX_GRAPH
using UnityEngine.VFX;
#endif
namespace MCPForUnity.Editor.Tools.Vfx
{
/// <summary>
/// Playback control operations for VFX Graph (VisualEffect component).
/// Requires com.unity.visualeffectgraph package and UNITY_VFX_GRAPH symbol.
/// </summary>
internal static class VfxGraphControl
{
#if !UNITY_VFX_GRAPH
public static object Control(JObject @params, string action)
{
return new { success = false, message = "VFX Graph package (com.unity.visualeffectgraph) not installed" };
}
public static object SetPlaybackSpeed(JObject @params)
{
return new { success = false, message = "VFX Graph package (com.unity.visualeffectgraph) not installed" };
}
public static object SetSeed(JObject @params)
{
return new { success = false, message = "VFX Graph package (com.unity.visualeffectgraph) not installed" };
}
#else
public static object Control(JObject @params, string action)
{
VisualEffect vfx = VfxGraphCommon.FindVisualEffect(@params);
if (vfx == null)
{
return new { success = false, message = "VisualEffect not found" };
}
switch (action)
{
case "play": vfx.Play(); break;
case "stop": vfx.Stop(); break;
case "pause": vfx.pause = !vfx.pause; break;
case "reinit": vfx.Reinit(); break;
default:
return new { success = false, message = $"Unknown VFX action: {action}" };
}
return new { success = true, message = $"VFX {action}", isPaused = vfx.pause };
}
public static object SetPlaybackSpeed(JObject @params)
{
VisualEffect vfx = VfxGraphCommon.FindVisualEffect(@params);
if (vfx == null)
{
return new { success = false, message = "VisualEffect not found" };
}
float rate = @params["playRate"]?.ToObject<float>() ?? 1f;
Undo.RecordObject(vfx, "Set VFX Play Rate");
vfx.playRate = rate;
EditorUtility.SetDirty(vfx);
return new { success = true, message = $"Set play rate = {rate}" };
}
public static object SetSeed(JObject @params)
{
VisualEffect vfx = VfxGraphCommon.FindVisualEffect(@params);
if (vfx == null)
{
return new { success = false, message = "VisualEffect not found" };
}
uint seed = @params["seed"]?.ToObject<uint>() ?? 0;
bool resetOnPlay = @params["resetSeedOnPlay"]?.ToObject<bool>() ?? true;
Undo.RecordObject(vfx, "Set VFX Seed");
vfx.startSeed = seed;
vfx.resetSeedOnPlay = resetOnPlay;
EditorUtility.SetDirty(vfx);
return new { success = true, message = $"Set seed = {seed}" };
}
#endif
}
}

View File

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

View File

@@ -0,0 +1,47 @@
using Newtonsoft.Json.Linq;
using UnityEngine;
#if UNITY_VFX_GRAPH
using UnityEngine.VFX;
#endif
namespace MCPForUnity.Editor.Tools.Vfx
{
/// <summary>
/// Read operations for VFX Graph (VisualEffect component).
/// Requires com.unity.visualeffectgraph package and UNITY_VFX_GRAPH symbol.
/// </summary>
internal static class VfxGraphRead
{
#if !UNITY_VFX_GRAPH
public static object GetInfo(JObject @params)
{
return new { success = false, message = "VFX Graph package (com.unity.visualeffectgraph) not installed" };
}
#else
public static object GetInfo(JObject @params)
{
VisualEffect vfx = VfxGraphCommon.FindVisualEffect(@params);
if (vfx == null)
{
return new { success = false, message = "VisualEffect not found" };
}
return new
{
success = true,
data = new
{
gameObject = vfx.gameObject.name,
assetName = vfx.visualEffectAsset?.name ?? "None",
aliveParticleCount = vfx.aliveParticleCount,
culled = vfx.culled,
pause = vfx.pause,
playRate = vfx.playRate,
startSeed = vfx.startSeed
}
};
}
#endif
}
}

View File

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

View File

@@ -0,0 +1,310 @@
using System;
using MCPForUnity.Editor.Helpers;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using UnityEditor;
using UnityEngine;
#if UNITY_VFX_GRAPH
using UnityEngine.VFX;
#endif
namespace MCPForUnity.Editor.Tools.Vfx
{
/// <summary>
/// Parameter setter operations for VFX Graph (VisualEffect component).
/// Requires com.unity.visualeffectgraph package and UNITY_VFX_GRAPH symbol.
/// </summary>
internal static class VfxGraphWrite
{
#if !UNITY_VFX_GRAPH
public static object SetParameter<T>(JObject @params, Action<object, string, T> setter)
{
return new { success = false, message = "VFX Graph package (com.unity.visualeffectgraph) not installed" };
}
public static object SetVector(JObject @params, int dims)
{
return new { success = false, message = "VFX Graph package (com.unity.visualeffectgraph) not installed" };
}
public static object SetColor(JObject @params)
{
return new { success = false, message = "VFX Graph package (com.unity.visualeffectgraph) not installed" };
}
public static object SetGradient(JObject @params)
{
return new { success = false, message = "VFX Graph package (com.unity.visualeffectgraph) not installed" };
}
public static object SetTexture(JObject @params)
{
return new { success = false, message = "VFX Graph package (com.unity.visualeffectgraph) not installed" };
}
public static object SetMesh(JObject @params)
{
return new { success = false, message = "VFX Graph package (com.unity.visualeffectgraph) not installed" };
}
public static object SetCurve(JObject @params)
{
return new { success = false, message = "VFX Graph package (com.unity.visualeffectgraph) not installed" };
}
public static object SendEvent(JObject @params)
{
return new { success = false, message = "VFX Graph package (com.unity.visualeffectgraph) not installed" };
}
#else
public static object SetParameter<T>(JObject @params, Action<VisualEffect, string, T> setter)
{
VisualEffect vfx = VfxGraphCommon.FindVisualEffect(@params);
if (vfx == null)
{
return new { success = false, message = "VisualEffect not found" };
}
string param = @params["parameter"]?.ToString();
if (string.IsNullOrEmpty(param))
{
return new { success = false, message = "Parameter name required" };
}
JToken valueToken = @params["value"];
if (valueToken == null)
{
return new { success = false, message = "Value required" };
}
// Safely deserialize the value
T value;
try
{
value = valueToken.ToObject<T>();
}
catch (JsonException ex)
{
return new { success = false, message = $"Invalid value for {param}: {ex.Message}" };
}
catch (InvalidCastException ex)
{
return new { success = false, message = $"Invalid value type for {param}: {ex.Message}" };
}
Undo.RecordObject(vfx, $"Set VFX {param}");
setter(vfx, param, value);
EditorUtility.SetDirty(vfx);
return new { success = true, message = $"Set {param} = {value}" };
}
public static object SetVector(JObject @params, int dims)
{
VisualEffect vfx = VfxGraphCommon.FindVisualEffect(@params);
if (vfx == null)
{
return new { success = false, message = "VisualEffect not found" };
}
string param = @params["parameter"]?.ToString();
if (string.IsNullOrEmpty(param))
{
return new { success = false, message = "Parameter name required" };
}
if (dims != 2 && dims != 3 && dims != 4)
{
return new { success = false, message = $"Unsupported vector dimension: {dims}. Expected 2, 3, or 4." };
}
Vector4 vec = ManageVfxCommon.ParseVector4(@params["value"]);
Undo.RecordObject(vfx, $"Set VFX {param}");
switch (dims)
{
case 2: vfx.SetVector2(param, new Vector2(vec.x, vec.y)); break;
case 3: vfx.SetVector3(param, new Vector3(vec.x, vec.y, vec.z)); break;
case 4: vfx.SetVector4(param, vec); break;
}
EditorUtility.SetDirty(vfx);
return new { success = true, message = $"Set {param}" };
}
public static object SetColor(JObject @params)
{
VisualEffect vfx = VfxGraphCommon.FindVisualEffect(@params);
if (vfx == null)
{
return new { success = false, message = "VisualEffect not found" };
}
string param = @params["parameter"]?.ToString();
if (string.IsNullOrEmpty(param))
{
return new { success = false, message = "Parameter name required" };
}
Color color = ManageVfxCommon.ParseColor(@params["value"]);
Undo.RecordObject(vfx, $"Set VFX Color {param}");
vfx.SetVector4(param, new Vector4(color.r, color.g, color.b, color.a));
EditorUtility.SetDirty(vfx);
return new { success = true, message = $"Set color {param}" };
}
public static object SetGradient(JObject @params)
{
VisualEffect vfx = VfxGraphCommon.FindVisualEffect(@params);
if (vfx == null)
{
return new { success = false, message = "VisualEffect not found" };
}
string param = @params["parameter"]?.ToString();
if (string.IsNullOrEmpty(param))
{
return new { success = false, message = "Parameter name required" };
}
Gradient gradient = ManageVfxCommon.ParseGradient(@params["gradient"]);
Undo.RecordObject(vfx, $"Set VFX Gradient {param}");
vfx.SetGradient(param, gradient);
EditorUtility.SetDirty(vfx);
return new { success = true, message = $"Set gradient {param}" };
}
public static object SetTexture(JObject @params)
{
VisualEffect vfx = VfxGraphCommon.FindVisualEffect(@params);
if (vfx == null)
{
return new { success = false, message = "VisualEffect not found" };
}
string param = @params["parameter"]?.ToString();
string path = @params["texturePath"]?.ToString();
if (string.IsNullOrEmpty(param) || string.IsNullOrEmpty(path))
{
return new { success = false, message = "Parameter and texturePath required" };
}
var findInst = new JObject { ["find"] = path };
Texture tex = ObjectResolver.Resolve(findInst, typeof(Texture)) as Texture;
if (tex == null)
{
return new { success = false, message = $"Texture not found: {path}" };
}
Undo.RecordObject(vfx, $"Set VFX Texture {param}");
vfx.SetTexture(param, tex);
EditorUtility.SetDirty(vfx);
return new { success = true, message = $"Set texture {param} = {tex.name}" };
}
public static object SetMesh(JObject @params)
{
VisualEffect vfx = VfxGraphCommon.FindVisualEffect(@params);
if (vfx == null)
{
return new { success = false, message = "VisualEffect not found" };
}
string param = @params["parameter"]?.ToString();
string path = @params["meshPath"]?.ToString();
if (string.IsNullOrEmpty(param) || string.IsNullOrEmpty(path))
{
return new { success = false, message = "Parameter and meshPath required" };
}
var findInst = new JObject { ["find"] = path };
Mesh mesh = ObjectResolver.Resolve(findInst, typeof(Mesh)) as Mesh;
if (mesh == null)
{
return new { success = false, message = $"Mesh not found: {path}" };
}
Undo.RecordObject(vfx, $"Set VFX Mesh {param}");
vfx.SetMesh(param, mesh);
EditorUtility.SetDirty(vfx);
return new { success = true, message = $"Set mesh {param} = {mesh.name}" };
}
public static object SetCurve(JObject @params)
{
VisualEffect vfx = VfxGraphCommon.FindVisualEffect(@params);
if (vfx == null)
{
return new { success = false, message = "VisualEffect not found" };
}
string param = @params["parameter"]?.ToString();
if (string.IsNullOrEmpty(param))
{
return new { success = false, message = "Parameter name required" };
}
AnimationCurve curve = ManageVfxCommon.ParseAnimationCurve(@params["curve"], 1f);
Undo.RecordObject(vfx, $"Set VFX Curve {param}");
vfx.SetAnimationCurve(param, curve);
EditorUtility.SetDirty(vfx);
return new { success = true, message = $"Set curve {param}" };
}
public static object SendEvent(JObject @params)
{
VisualEffect vfx = VfxGraphCommon.FindVisualEffect(@params);
if (vfx == null)
{
return new { success = false, message = "VisualEffect not found" };
}
string eventName = @params["eventName"]?.ToString();
if (string.IsNullOrEmpty(eventName))
{
return new { success = false, message = "Event name required" };
}
VFXEventAttribute attr = vfx.CreateVFXEventAttribute();
if (@params["position"] != null)
{
attr.SetVector3("position", ManageVfxCommon.ParseVector3(@params["position"]));
}
if (@params["velocity"] != null)
{
attr.SetVector3("velocity", ManageVfxCommon.ParseVector3(@params["velocity"]));
}
if (@params["color"] != null)
{
var c = ManageVfxCommon.ParseColor(@params["color"]);
attr.SetVector3("color", new Vector3(c.r, c.g, c.b));
}
if (@params["size"] != null)
{
float? sizeValue = @params["size"].Value<float?>();
if (sizeValue.HasValue)
{
attr.SetFloat("size", sizeValue.Value);
}
}
if (@params["lifetime"] != null)
{
float? lifetimeValue = @params["lifetime"].Value<float?>();
if (lifetimeValue.HasValue)
{
attr.SetFloat("lifetime", lifetimeValue.Value);
}
}
vfx.SendEvent(eventName, attr);
return new { success = true, message = $"Sent event '{eventName}'" };
}
#endif
}
}

View File

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