chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 12:49:17 +08:00
commit 7243d5823b
2201 changed files with 257291 additions and 0 deletions
@@ -0,0 +1,90 @@
using System;
using System.IO;
using UnityEngine;
namespace MCPForUnity.Editor.Helpers
{
/// <summary>
/// Project-path conversions shared by the asset-gen import/write code: project-relative
/// ("Assets/...") ↔ absolute on-disk paths, with forward-slash normalization for
/// cross-platform consistency.
/// </summary>
public static class AssetGenPaths
{
/// <summary>Resolve a project-relative ("Assets/...") path to an absolute, forward-slashed path.</summary>
public static string ToAbsolute(string projectRelative)
{
if (string.IsNullOrWhiteSpace(projectRelative)) return projectRelative;
string p = projectRelative.Replace('\\', '/');
string abs = Path.IsPathRooted(p) ? p : Path.Combine(ProjectRoot(), p);
return Path.GetFullPath(abs).Replace('\\', '/');
}
/// <summary>Convert an absolute (or already-relative) path to a project-relative ("Assets/...") path.</summary>
public static string ToProjectRelative(string path)
{
if (TryGetAssetsRelativePath(path, out string rel)) return rel;
return path?.Replace('\\', '/');
}
/// <summary>
/// Normalize an absolute or "Assets/..." path and verify it resolves inside the project's
/// Assets directory. Rejects traversal such as "Assets/../ProjectSettings".
/// </summary>
public static bool TryGetAssetsRelativePath(string path, out string projectRelative)
{
projectRelative = null;
if (string.IsNullOrWhiteSpace(path)) return false;
try
{
string p = path.Replace('\\', '/');
string abs;
if (p == "Assets" || p.StartsWith("Assets/", StringComparison.Ordinal))
{
abs = Path.Combine(ProjectRoot(), p);
}
else if (Path.IsPathRooted(p))
{
abs = p;
}
else
{
return false;
}
string full = Path.GetFullPath(abs).Replace('\\', '/');
string dataPath = Path.GetFullPath(Application.dataPath).Replace('\\', '/').TrimEnd('/');
if (string.Equals(full, dataPath, StringComparison.Ordinal))
{
projectRelative = "Assets";
return true;
}
string prefix = dataPath + "/";
if (!full.StartsWith(prefix, StringComparison.Ordinal)) return false;
projectRelative = "Assets/" + full.Substring(prefix.Length);
return true;
}
catch
{
return false;
}
}
/// <summary>Normalize an Assets folder path, trimming trailing slashes.</summary>
public static bool TryGetAssetsFolder(string path, out string projectRelative)
{
if (!TryGetAssetsRelativePath(path, out projectRelative)) return false;
projectRelative = projectRelative.TrimEnd('/');
return true;
}
private static string ProjectRoot()
{
string dataPath = Path.GetFullPath(Application.dataPath).Replace('\\', '/');
return dataPath.Substring(0, dataPath.Length - "Assets".Length);
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: afc641959f464b55911f6703a96b5f37
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,73 @@
using MCPForUnity.Editor.Constants;
using UnityEditor;
namespace MCPForUnity.Editor.Helpers
{
/// <summary>
/// Per-user, NON-SECRET configuration for the AI Asset Generation feature.
///
/// Provider API keys are never stored here — they live in the OS secure store
/// (<see cref="MCPForUnity.Editor.Security.SecureKeyStore"/>). Only UI/behavior
/// preferences (selected provider, default format, output folder, normalize toggle,
/// per-provider enable flags) are kept in EditorPrefs.
/// </summary>
public static class AssetGenPrefs
{
public const string DefaultOutputRoot = "Assets/Generated";
public const string DefaultModelProvider = "tripo";
public const string DefaultImageProvider = "fal";
public const string DefaultFormatValue = "glb";
public static string ModelProvider
{
get => EditorPrefs.GetString(EditorPrefKeys.AssetGenSelectedModelProvider, DefaultModelProvider);
set => SetOrDelete(EditorPrefKeys.AssetGenSelectedModelProvider, value);
}
public static string ImageProvider
{
get => EditorPrefs.GetString(EditorPrefKeys.AssetGenSelectedImageProvider, DefaultImageProvider);
set => SetOrDelete(EditorPrefKeys.AssetGenSelectedImageProvider, value);
}
public static string DefaultFormat
{
get => EditorPrefs.GetString(EditorPrefKeys.AssetGenDefaultFormat, DefaultFormatValue);
set => SetOrDelete(EditorPrefKeys.AssetGenDefaultFormat, value);
}
/// <summary>Project-relative root under which generated assets are written. Defaults to Assets/Generated.</summary>
public static string OutputRoot
{
get
{
string v = EditorPrefs.GetString(EditorPrefKeys.AssetGenOutputRoot, string.Empty);
return string.IsNullOrWhiteSpace(v) ? DefaultOutputRoot : v;
}
set => SetOrDelete(EditorPrefKeys.AssetGenOutputRoot, value);
}
/// <summary>When true, imported models are uniformly scaled to a target size on import.</summary>
public static bool AutoNormalize
{
get => EditorPrefs.GetBool(EditorPrefKeys.AssetGenAutoNormalize, true);
set => EditorPrefs.SetBool(EditorPrefKeys.AssetGenAutoNormalize, value);
}
public static bool IsProviderEnabled(string providerId) =>
!string.IsNullOrEmpty(providerId)
&& EditorPrefs.GetBool(EditorPrefKeys.AssetGenProviderEnabledPrefix + providerId, false);
public static void SetProviderEnabled(string providerId, bool enabled)
{
if (string.IsNullOrEmpty(providerId)) return;
EditorPrefs.SetBool(EditorPrefKeys.AssetGenProviderEnabledPrefix + providerId, enabled);
}
private static void SetOrDelete(string key, string value)
{
if (string.IsNullOrWhiteSpace(value)) EditorPrefs.DeleteKey(key);
else EditorPrefs.SetString(key, value.Trim());
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: f7704064abf1412e890a066628bae30a
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,668 @@
using System;
using System.Collections.Generic;
using System.IO;
using MCPForUnity.Editor.Constants;
using MCPForUnity.Editor.Services;
using Newtonsoft.Json.Linq;
using UnityEditor;
using UnityEngine;
using PackageInfo = UnityEditor.PackageManager.PackageInfo;
namespace MCPForUnity.Editor.Helpers
{
/// <summary>
/// Provides common utility methods for working with Unity asset paths.
/// </summary>
public static class AssetPathUtility
{
/// <summary>
/// Normalizes path separators to forward slashes without modifying the path structure.
/// Use this for non-asset paths (e.g., file system paths, relative directories).
/// </summary>
public static string NormalizeSeparators(string path)
{
if (string.IsNullOrEmpty(path))
return path;
return path.Replace('\\', '/');
}
/// <summary>
/// Normalizes a Unity asset path by ensuring forward slashes are used and that it is rooted under "Assets/".
/// Also protects against path traversal attacks using "../" sequences.
/// </summary>
public static string SanitizeAssetPath(string path)
{
if (string.IsNullOrEmpty(path))
{
return path;
}
path = NormalizeSeparators(path);
// Check for path traversal sequences
if (path.Contains(".."))
{
McpLog.Warn($"[AssetPathUtility] Path contains potential traversal sequence: '{path}'");
return null;
}
// Ensure path starts with Assets/
if (string.Equals(path, "Assets", StringComparison.OrdinalIgnoreCase))
{
return "Assets";
}
if (!path.StartsWith("Assets/", StringComparison.OrdinalIgnoreCase))
{
return "Assets/" + path.TrimStart('/');
}
return path;
}
/// <summary>
/// Checks if a given asset path is valid and safe (no traversal, within Assets folder).
/// </summary>
/// <returns>True if the path is valid, false otherwise.</returns>
public static bool IsValidAssetPath(string path)
{
if (string.IsNullOrEmpty(path))
{
return false;
}
// Normalize for comparison
string normalized = NormalizeSeparators(path);
// Must start with Assets/
if (!normalized.StartsWith("Assets/", StringComparison.OrdinalIgnoreCase))
{
return false;
}
// Must not contain traversal sequences
if (normalized.Contains(".."))
{
return false;
}
// Must not contain invalid path characters
char[] invalidChars = { ':', '*', '?', '"', '<', '>', '|' };
foreach (char c in invalidChars)
{
if (normalized.IndexOf(c) >= 0)
{
return false;
}
}
return true;
}
/// <summary>
/// Gets the MCP for Unity package root path.
/// Works for registry Package Manager, local Package Manager, and Asset Store installations.
/// </summary>
/// <returns>The package root path (virtual for PM, absolute for Asset Store), or null if not found</returns>
public static string GetMcpPackageRootPath()
{
try
{
// Try Package Manager first (registry and local installs)
var packageInfo = PackageInfo.FindForAssembly(typeof(AssetPathUtility).Assembly);
if (packageInfo != null && !string.IsNullOrEmpty(packageInfo.assetPath))
{
return packageInfo.assetPath;
}
// Fallback to AssetDatabase for Asset Store installs (Assets/MCPForUnity)
string[] guids = AssetDatabase.FindAssets($"t:Script {nameof(AssetPathUtility)}");
if (guids.Length == 0)
{
McpLog.Warn("Could not find AssetPathUtility script in AssetDatabase");
return null;
}
string scriptPath = AssetDatabase.GUIDToAssetPath(guids[0]);
// Script is at: {packageRoot}/Editor/Helpers/AssetPathUtility.cs
// Extract {packageRoot}
int editorIndex = scriptPath.LastIndexOf("/Editor/", StringComparison.Ordinal);
if (editorIndex >= 0)
{
return scriptPath.Substring(0, editorIndex);
}
McpLog.Warn($"Could not determine package root from script path: {scriptPath}");
return null;
}
catch (Exception ex)
{
McpLog.Error($"Failed to get package root path: {ex.Message}");
return null;
}
}
/// <summary>
/// Reads and parses the package.json file for MCP for Unity.
/// Handles both Package Manager (registry/local) and Asset Store installations.
/// </summary>
/// <returns>JObject containing package.json data, or null if not found or parse failed</returns>
public static JObject GetPackageJson()
{
try
{
string packageRoot = GetMcpPackageRootPath();
if (string.IsNullOrEmpty(packageRoot))
{
return null;
}
string packageJsonPath = Path.Combine(packageRoot, "package.json");
// Convert virtual asset path to file system path
if (packageRoot.StartsWith("Packages/", StringComparison.OrdinalIgnoreCase))
{
// Package Manager install - must use PackageInfo.resolvedPath
// Virtual paths like "Packages/..." don't work with File.Exists()
// Registry packages live in Library/PackageCache/package@version/
var packageInfo = PackageInfo.FindForAssembly(typeof(AssetPathUtility).Assembly);
if (packageInfo != null && !string.IsNullOrEmpty(packageInfo.resolvedPath))
{
packageJsonPath = Path.Combine(packageInfo.resolvedPath, "package.json");
}
else
{
McpLog.Warn("Could not resolve Package Manager path for package.json");
return null;
}
}
else if (packageRoot.StartsWith("Assets/", StringComparison.OrdinalIgnoreCase))
{
// Asset Store install - convert to absolute file system path
// Application.dataPath is the absolute path to the Assets folder
string relativePath = packageRoot.Substring("Assets/".Length);
packageJsonPath = Path.Combine(Application.dataPath, relativePath, "package.json");
}
if (!File.Exists(packageJsonPath))
{
McpLog.Warn($"package.json not found at: {packageJsonPath}");
return null;
}
string json = File.ReadAllText(packageJsonPath);
return JObject.Parse(json);
}
catch (Exception ex)
{
McpLog.Warn($"Failed to read or parse package.json: {ex.Message}");
return null;
}
}
/// <summary>
/// Gets the package source for the MCP server (used with uvx --from).
/// Checks for EditorPrefs override first (supports git URLs, file:// paths, etc.),
/// then falls back to PyPI package reference.
/// When the override is a local path, auto-corrects to the "Server" subdirectory
/// if the path doesn't contain pyproject.toml but Server/pyproject.toml exists.
/// </summary>
/// <returns>Package source string for uvx --from argument</returns>
public static string GetMcpServerPackageSource()
{
// Check for override first (supports git URLs, file:// paths, local paths)
string sourceOverride = EditorPrefs.GetString(EditorPrefKeys.GitUrlOverride, "");
if (!string.IsNullOrEmpty(sourceOverride))
{
string resolved = ResolveLocalServerPath(sourceOverride);
// Persist the corrected path so future reads are consistent
if (resolved != sourceOverride)
{
EditorPrefs.SetString(EditorPrefKeys.GitUrlOverride, resolved);
McpLog.Info($"Auto-corrected server source override from '{sourceOverride}' to '{resolved}'");
}
return resolved;
}
// Default to PyPI package (avoids Windows long path issues with git clone)
string version = GetPackageVersion();
if (version == "unknown")
{
// Fall back to latest PyPI version so configs remain valid in test scenarios
return "mcpforunityserver";
}
// Package.json uses semver prerelease tags (e.g., 9.4.5-beta.1) that are not valid
// PEP 440 pins for uvx. Use the beta prerelease range instead of a pinned prerelease.
if (IsSemVerPreRelease(version))
{
return "mcpforunityserver>=0.0.0a0";
}
return $"mcpforunityserver=={version}";
}
/// <summary>
/// Validates and auto-corrects a local server source path to ensure it points to the
/// directory containing pyproject.toml. If the path points to a parent directory
/// (e.g. the repo root "unity-mcp") instead of the Python package directory ("Server"),
/// this checks for a "Server" subdirectory with pyproject.toml and returns that path.
/// Non-local paths (URLs, PyPI references) are returned unchanged.
/// </summary>
internal static string ResolveLocalServerPath(string path)
{
if (string.IsNullOrEmpty(path))
return path;
// Skip non-local paths (git URLs, PyPI package names, etc.)
if (path.StartsWith("http://", StringComparison.OrdinalIgnoreCase) ||
path.StartsWith("https://", StringComparison.OrdinalIgnoreCase) ||
path.StartsWith("git+", StringComparison.OrdinalIgnoreCase) ||
path.StartsWith("ssh://", StringComparison.OrdinalIgnoreCase))
{
return path;
}
// If it looks like a PyPI package reference (no path separators), skip
if (!path.Contains('/') && !path.Contains('\\') && !path.StartsWith("file:", StringComparison.OrdinalIgnoreCase))
{
return path;
}
// Strip file:// prefix for filesystem checks, preserve for return value
string checkPath = path;
string prefix = string.Empty;
if (checkPath.StartsWith("file://", StringComparison.OrdinalIgnoreCase))
{
prefix = checkPath.Substring(0, 7); // preserve original casing
checkPath = checkPath.Substring(7);
}
// Already correct — pyproject.toml exists at this path
if (System.IO.File.Exists(System.IO.Path.Combine(checkPath, "pyproject.toml")))
{
return path;
}
// Check if "Server" subdirectory contains pyproject.toml
string serverSubDir = System.IO.Path.Combine(checkPath, "Server");
if (System.IO.File.Exists(System.IO.Path.Combine(serverSubDir, "pyproject.toml")))
{
return prefix + serverSubDir;
}
// Return as-is; uvx will report the error if the path is truly invalid
return path;
}
/// <summary>
/// Deprecated: Use GetMcpServerPackageSource() instead.
/// Kept for backwards compatibility.
/// </summary>
[System.Obsolete("Use GetMcpServerPackageSource() instead")]
public static string GetMcpServerGitUrl() => GetMcpServerPackageSource();
/// <summary>
/// Gets structured uvx command parts for different client configurations
/// </summary>
/// <returns>Tuple containing (uvxPath, fromUrl, packageName)</returns>
public static (string uvxPath, string fromUrl, string packageName) GetUvxCommandParts()
{
string uvxPath = MCPServiceLocator.Paths.GetUvxPath();
string fromUrl = GetMcpServerPackageSource();
string packageName = "mcp-for-unity";
return (uvxPath, fromUrl, packageName);
}
/// <summary>
/// Builds the uvx package source arguments for the MCP server.
/// Handles prerelease package mode (prerelease from PyPI) vs stable mode (pinned version or override).
/// Centralizes the prerelease logic to avoid duplication between HTTP and stdio transports.
/// Priority: explicit fromUrl override > package-version-driven prerelease mode > stable pinned package.
/// NOTE: This overload reads from EditorPrefs/cache and MUST be called from the main thread.
/// For background threads, use the overload that accepts pre-captured parameters.
/// </summary>
/// <param name="quoteFromPath">Whether to quote the --from path (needed for command-line strings, not for arg lists)</param>
/// <returns>The package source arguments (e.g., "--prerelease explicit --from mcpforunityserver>=0.0.0a0")</returns>
public static string GetBetaServerFromArgs(bool quoteFromPath = false)
{
string gitUrlOverride = EditorPrefs.GetString(EditorPrefKeys.GitUrlOverride, "");
string packageSource = GetMcpServerPackageSource();
return GetBetaServerFromArgs(gitUrlOverride, packageSource, quoteFromPath);
}
/// <summary>
/// Thread-safe overload that accepts pre-captured values.
/// Use this when calling from background threads.
/// </summary>
/// <param name="gitUrlOverride">Pre-captured value from EditorPrefs GitUrlOverride</param>
/// <param name="packageSource">Pre-captured value from GetMcpServerPackageSource()</param>
/// <param name="quoteFromPath">Whether to quote the --from path</param>
public static string GetBetaServerFromArgs(string gitUrlOverride, string packageSource, bool quoteFromPath = false)
{
// Explicit override (local path, git URL, etc.) always wins
if (!string.IsNullOrEmpty(gitUrlOverride))
{
string fromValue = quoteFromPath ? $"\"{gitUrlOverride}\"" : gitUrlOverride;
return $"--from {fromValue}";
}
bool usePrereleaseRange = string.Equals(packageSource, "mcpforunityserver>=0.0.0a0", StringComparison.OrdinalIgnoreCase);
// Prerelease package mode: use prerelease from PyPI.
if (usePrereleaseRange)
{
// Use --prerelease explicit with version specifier to only get prereleases of our package,
// not of dependencies (which can be broken on PyPI).
string fromValue = quoteFromPath ? "\"mcpforunityserver>=0.0.0a0\"" : "mcpforunityserver>=0.0.0a0";
return $"--prerelease explicit --from {fromValue}";
}
// Standard mode: use pinned version from package.json
if (!string.IsNullOrEmpty(packageSource))
{
string fromValue = quoteFromPath ? $"\"{packageSource}\"" : packageSource;
return $"--from {fromValue}";
}
return string.Empty;
}
/// <summary>
/// Builds the uvx package source arguments as a list (for JSON config builders).
/// Priority: explicit fromUrl override > package-version-driven prerelease mode > stable pinned package.
/// NOTE: This overload reads from EditorPrefs/cache and MUST be called from the main thread.
/// For background threads, use the overload that accepts pre-captured parameters.
/// </summary>
/// <returns>List of arguments to add to uvx command</returns>
public static System.Collections.Generic.IList<string> GetBetaServerFromArgsList()
{
string gitUrlOverride = EditorPrefs.GetString(EditorPrefKeys.GitUrlOverride, "");
string packageSource = GetMcpServerPackageSource();
return GetBetaServerFromArgsList(gitUrlOverride, packageSource);
}
/// <summary>
/// Thread-safe overload that accepts pre-captured values.
/// Use this when calling from background threads.
/// </summary>
/// <param name="gitUrlOverride">Pre-captured value from EditorPrefs GitUrlOverride</param>
/// <param name="packageSource">Pre-captured value from GetMcpServerPackageSource()</param>
public static System.Collections.Generic.IList<string> GetBetaServerFromArgsList(string gitUrlOverride, string packageSource)
{
var args = new System.Collections.Generic.List<string>();
// Explicit override (local path, git URL, etc.) always wins
if (!string.IsNullOrEmpty(gitUrlOverride))
{
args.Add("--from");
args.Add(gitUrlOverride);
return args;
}
bool usePrereleaseRange = string.Equals(packageSource, "mcpforunityserver>=0.0.0a0", StringComparison.OrdinalIgnoreCase);
// Prerelease package mode: use prerelease from PyPI.
if (usePrereleaseRange)
{
args.Add("--prerelease");
args.Add("explicit");
args.Add("--from");
args.Add("mcpforunityserver>=0.0.0a0");
return args;
}
// Standard mode: use pinned version from package.json
if (!string.IsNullOrEmpty(packageSource))
{
args.Add("--from");
args.Add(packageSource);
}
return args;
}
/// <summary>
/// Determines whether uvx should use --no-cache --refresh flags.
/// Returns true if DevModeForceServerRefresh is enabled OR if the server URL is a local path.
/// Local paths (file:// or absolute) always need fresh builds to avoid stale uvx cache.
/// Note: --reinstall is not supported by uvx and will cause a warning.
/// </summary>
public static bool ShouldForceUvxRefresh()
{
bool devForceRefresh = false;
try { devForceRefresh = EditorPrefs.GetBool(EditorPrefKeys.DevModeForceServerRefresh, false); } catch { }
if (devForceRefresh)
return true;
// Auto-enable force refresh when using a local path override.
return IsLocalServerPath();
}
private static bool _offlineCacheResult;
private static double _offlineCacheTimestamp = -999;
private const double OfflineCacheTtlSeconds = 30.0;
/// <summary>
/// Determines whether uvx should use --offline mode for faster startup.
/// Runs a lightweight probe (uvx --offline ... mcp-for-unity --help) with a 3-second timeout
/// to check if the package is already cached. If cached, --offline skips the network
/// dependency check that can hang for 30+ seconds on poor connections.
/// Returns false if force refresh is enabled (new download needed).
/// The result is cached for 30 seconds to avoid redundant subprocess spawns.
/// Must be called on the main thread (reads EditorPrefs).
/// </summary>
public static bool ShouldUseUvxOffline()
{
if (ShouldForceUvxRefresh())
return false;
return GetCachedOfflineProbeResult();
}
private static bool GetCachedOfflineProbeResult()
{
double now = EditorApplication.timeSinceStartup;
if (now - _offlineCacheTimestamp < OfflineCacheTtlSeconds)
return _offlineCacheResult;
bool result = RunOfflineProbe();
_offlineCacheResult = result;
_offlineCacheTimestamp = now;
return result;
}
private static bool RunOfflineProbe()
{
try
{
string uvxPath = MCPServiceLocator.Paths.GetUvxPath();
if (string.IsNullOrEmpty(uvxPath))
return false;
string fromArgs = GetBetaServerFromArgs(quoteFromPath: false);
string probeArgs = string.IsNullOrEmpty(fromArgs)
? "--offline mcp-for-unity --help"
: $"--offline {fromArgs} mcp-for-unity --help";
return ExecPath.TryRun(uvxPath, probeArgs, null, out _, out _, timeoutMs: 3000);
}
catch
{
return false;
}
}
/// <summary>
/// Returns the uvx dev-mode flags as a single string for command-line builders.
/// Returns "--no-cache --refresh " if force refresh is enabled,
/// "--offline " if the cache is warm, or string.Empty otherwise.
/// Must be called on the main thread (reads EditorPrefs).
/// </summary>
public static string GetUvxDevFlags()
{
bool forceRefresh = ShouldForceUvxRefresh();
return GetUvxDevFlags(forceRefresh, !forceRefresh && GetCachedOfflineProbeResult());
}
/// <summary>
/// Returns the uvx dev-mode flags from pre-captured bool values.
/// Use this overload when values were captured on the main thread for background use.
/// </summary>
public static string GetUvxDevFlags(bool forceRefresh, bool useOffline)
{
if (forceRefresh) return "--no-cache --refresh ";
if (useOffline) return "--offline ";
return string.Empty;
}
/// <summary>
/// Returns the uvx dev-mode flags as a list of individual arguments.
/// Suitable for callers that build argument lists (ConfigJsonBuilder, CodexConfigHelper).
/// Must be called on the main thread (reads EditorPrefs).
/// </summary>
public static IReadOnlyList<string> GetUvxDevFlagsList()
{
bool forceRefresh = ShouldForceUvxRefresh();
if (forceRefresh) return new[] { "--no-cache", "--refresh" };
if (GetCachedOfflineProbeResult()) return new[] { "--offline" };
return Array.Empty<string>();
}
/// <summary>
/// Returns true if the server URL is a local path (file:// or absolute path).
/// </summary>
public static bool IsLocalServerPath()
{
string fromUrl = GetMcpServerPackageSource();
if (string.IsNullOrEmpty(fromUrl))
return false;
// Check for file:// protocol or absolute local path
if (fromUrl.StartsWith("file://", StringComparison.OrdinalIgnoreCase))
return true;
try
{
return System.IO.Path.IsPathRooted(fromUrl);
}
catch (System.ArgumentException)
{
// fromUrl contains characters illegal in paths (e.g. a remote URL)
return false;
}
}
/// <summary>
/// Gets the local server path if GitUrlOverride points to a local directory.
/// Returns null if not using a local path.
/// </summary>
public static string GetLocalServerPath()
{
if (!IsLocalServerPath())
return null;
string fromUrl = GetMcpServerPackageSource();
if (fromUrl.StartsWith("file://", StringComparison.OrdinalIgnoreCase))
{
// Strip file:// prefix
fromUrl = fromUrl.Substring(7);
}
return fromUrl;
}
/// <summary>
/// Cleans stale Python build artifacts from the local server path.
/// This is necessary because Python's build system doesn't remove deleted files from build/,
/// and the auto-discovery mechanism will pick up old .py files causing ghost resources/tools.
/// </summary>
/// <returns>True if cleaning was performed, false if not applicable or failed.</returns>
public static bool CleanLocalServerBuildArtifacts()
{
string localPath = GetLocalServerPath();
if (string.IsNullOrEmpty(localPath))
return false;
// Clean the build/ directory which can contain stale .py files
string buildPath = System.IO.Path.Combine(localPath, "build");
if (System.IO.Directory.Exists(buildPath))
{
try
{
System.IO.Directory.Delete(buildPath, recursive: true);
McpLog.Info($"Cleaned stale build artifacts from: {buildPath}");
return true;
}
catch (Exception ex)
{
McpLog.Warn($"Failed to clean build artifacts: {ex.Message}");
return false;
}
}
return false;
}
/// <summary>
/// Gets the package version from package.json
/// </summary>
/// <returns>Version string, or "unknown" if not found</returns>
public static string GetPackageVersion()
{
try
{
var packageJson = GetPackageJson();
if (packageJson == null)
{
return "unknown";
}
string version = packageJson["version"]?.ToString();
return string.IsNullOrEmpty(version) ? "unknown" : version;
}
catch (Exception ex)
{
McpLog.Warn($"Failed to get package version: {ex.Message}");
return "unknown";
}
}
/// <summary>
/// Returns true if the installed package version is a prerelease (beta, alpha, rc, etc.).
/// Used to auto-enable beta server mode for beta package users.
/// </summary>
public static bool IsPreReleaseVersion()
{
try
{
string version = GetPackageVersion();
if (string.IsNullOrEmpty(version) || version == "unknown")
return false;
return IsSemVerPreRelease(version);
}
catch
{
return false;
}
}
private static bool IsSemVerPreRelease(string version)
{
if (string.IsNullOrEmpty(version))
return false;
// Common semver prerelease indicators:
// e.g., "9.3.0-beta.1", "9.3.0-alpha", "9.3.0-rc.2", "9.3.0-preview"
return version.Contains("-beta", StringComparison.OrdinalIgnoreCase) ||
version.Contains("-alpha", StringComparison.OrdinalIgnoreCase) ||
version.Contains("-rc", StringComparison.OrdinalIgnoreCase) ||
version.Contains("-preview", StringComparison.OrdinalIgnoreCase) ||
version.Contains("-pre", StringComparison.OrdinalIgnoreCase);
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 1d42f5b5ea5d4d43ad1a771e14bda2a0
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,80 @@
using System;
using System.Collections.Generic;
using System.IO;
using UnityEngine;
namespace MCPForUnity.Editor.Helpers
{
/// <summary>
/// Best-effort detection of a locally installed Blender application, for the Asset Gen tab's
/// "Blender → Unity handoff" hint. This finds the Blender APP only — it cannot tell whether the
/// BlenderMCP server is configured in the user's AI client (that lives outside Unity).
/// </summary>
internal static class BlenderDetection
{
/// <summary>True if a Blender executable is found in a well-known location or on PATH.</summary>
public static bool IsInstalled()
{
try { return DetectIn(CandidatePaths(), File.Exists); }
catch { return false; }
}
/// <summary>Pure core: true if <paramref name="exists"/> reports any candidate present. Testable.</summary>
internal static bool DetectIn(IEnumerable<string> candidates, Func<string, bool> exists)
{
if (candidates == null || exists == null) return false;
foreach (string c in candidates)
if (!string.IsNullOrEmpty(c) && exists(c)) return true;
return false;
}
/// <summary>Well-known Blender executable paths for the current platform, plus PATH entries.</summary>
internal static IEnumerable<string> CandidatePaths()
{
var list = new List<string>();
bool win = Application.platform == RuntimePlatform.WindowsEditor;
string exeName = win ? "blender.exe" : "blender";
// PATH entries: <dir>/blender(.exe)
string pathVar = Environment.GetEnvironmentVariable("PATH") ?? string.Empty;
foreach (string dir in pathVar.Split(win ? ';' : ':'))
if (!string.IsNullOrWhiteSpace(dir)) list.Add(Path.Combine(dir.Trim(), exeName));
switch (Application.platform)
{
case RuntimePlatform.OSXEditor:
list.Add("/Applications/Blender.app/Contents/MacOS/Blender");
string home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
if (!string.IsNullOrEmpty(home))
list.Add(Path.Combine(home, "Applications/Blender.app/Contents/MacOS/Blender"));
break;
case RuntimePlatform.WindowsEditor:
foreach (string pf in new[]
{
Environment.GetEnvironmentVariable("ProgramFiles"),
Environment.GetEnvironmentVariable("ProgramFiles(x86)")
})
{
if (string.IsNullOrEmpty(pf)) continue;
string foundation = Path.Combine(pf, "Blender Foundation");
// Blender installs under a version subdir (Blender X.Y); enumerate them.
try
{
if (Directory.Exists(foundation))
foreach (string d in Directory.GetDirectories(foundation))
list.Add(Path.Combine(d, "blender.exe"));
}
catch { /* unreadable dir; ignore */ }
}
break;
case RuntimePlatform.LinuxEditor:
list.Add("/usr/bin/blender");
list.Add("/usr/local/bin/blender");
list.Add("/snap/bin/blender");
list.Add("/var/lib/flatpak/exports/bin/org.blender.Blender");
break;
}
return list;
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: bf9f02bd846b4e8db34827d949046f70
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,316 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using MCPForUnity.Editor.Constants;
using MCPForUnity.Editor.Services;
using MCPForUnity.External.Tommy;
using UnityEditor;
using UnityEngine;
namespace MCPForUnity.Editor.Helpers
{
/// <summary>
/// Codex CLI specific configuration helpers. Handles TOML snippet
/// generation and lightweight parsing so Codex can join the auto-setup
/// flow alongside JSON-based clients.
/// </summary>
public static class CodexConfigHelper
{
private static void AddUvxModeFlags(TomlArray args)
{
if (args == null) return;
foreach (var flag in AssetPathUtility.GetUvxDevFlagsList())
args.Add(new TomlString { Value = flag });
}
public static string BuildCodexServerBlock(string uvPath)
{
var table = new TomlTable();
var mcpServers = new TomlTable();
var unityMCP = new TomlTable();
// Check transport preference
bool useHttpTransport = EditorPrefs.GetBool(MCPForUnity.Editor.Constants.EditorPrefKeys.UseHttpTransport, true);
if (useHttpTransport)
{
// HTTP mode: Use url field
string httpUrl = HttpEndpointUtility.GetMcpRpcUrl();
unityMCP["url"] = new TomlString { Value = httpUrl };
// Enable Codex's Rust MCP client for HTTP/SSE transport
EnsureRmcpClientFeature(table);
}
else
{
// Stdio mode: Use command and args
var (uvxPath, _, packageName) = AssetPathUtility.GetUvxCommandParts();
unityMCP["command"] = uvxPath;
var args = new TomlArray();
AddUvxModeFlags(args);
// Use centralized helper for beta server / prerelease args
foreach (var arg in AssetPathUtility.GetBetaServerFromArgsList())
{
args.Add(new TomlString { Value = arg });
}
args.Add(new TomlString { Value = packageName });
args.Add(new TomlString { Value = "--transport" });
args.Add(new TomlString { Value = "stdio" });
unityMCP["args"] = args;
// Add Windows-specific environment configuration for stdio mode
var platformService = MCPServiceLocator.Platform;
if (platformService.IsWindows())
{
var envTable = new TomlTable { IsInline = true };
envTable["SystemRoot"] = new TomlString { Value = platformService.GetSystemRoot() };
unityMCP["env"] = envTable;
}
// Allow extra time for uvx to download packages on first run
unityMCP["startup_timeout_sec"] = new TomlInteger { Value = 60 };
}
mcpServers["unityMCP"] = unityMCP;
table["mcp_servers"] = mcpServers;
using var writer = new StringWriter();
table.WriteTo(writer);
return writer.ToString();
}
public static string UpsertCodexServerBlock(string existingToml, string uvPath)
{
// Parse existing TOML or create new root table
var root = TryParseToml(existingToml) ?? new TomlTable();
bool useHttpTransport = EditorPrefs.GetBool(MCPForUnity.Editor.Constants.EditorPrefKeys.UseHttpTransport, true);
// Ensure mcp_servers table exists
if (!root.TryGetNode("mcp_servers", out var mcpServersNode) || !(mcpServersNode is TomlTable))
{
root["mcp_servers"] = new TomlTable();
}
var mcpServers = root["mcp_servers"] as TomlTable;
// Create or update unityMCP table
mcpServers["unityMCP"] = CreateUnityMcpTable(uvPath);
if (useHttpTransport)
{
EnsureRmcpClientFeature(root);
}
// Serialize back to TOML
using var writer = new StringWriter();
root.WriteTo(writer);
return writer.ToString();
}
public static bool TryParseCodexServer(string toml, out string command, out string[] args)
{
return TryParseCodexServer(toml, out command, out args, out _);
}
public static bool TryParseCodexServer(string toml, out string command, out string[] args, out string url)
{
command = null;
args = null;
url = null;
var root = TryParseToml(toml);
if (root == null) return false;
if (!TryGetTable(root, "mcp_servers", out var servers)
&& !TryGetTable(root, "mcpServers", out servers))
{
return false;
}
if (!TryGetTable(servers, "unityMCP", out var unity))
{
return false;
}
// Check for HTTP mode (url field)
url = GetTomlString(unity, "url");
if (!string.IsNullOrEmpty(url))
{
// HTTP mode detected - return true with url
return true;
}
// Check for stdio mode (command + args)
command = GetTomlString(unity, "command");
args = GetTomlStringArray(unity, "args");
return !string.IsNullOrEmpty(command) && args != null;
}
/// <summary>
/// Safely parses TOML string, returning null on failure
/// </summary>
private static TomlTable TryParseToml(string toml)
{
if (string.IsNullOrWhiteSpace(toml)) return null;
try
{
using var reader = new StringReader(toml);
return TOML.Parse(reader);
}
catch (TomlParseException)
{
return null;
}
catch (TomlSyntaxException)
{
return null;
}
catch (FormatException)
{
return null;
}
}
/// <summary>
/// Creates a TomlTable for the unityMCP server configuration
/// </summary>
/// <param name="uvPath">Path to uv executable (used as fallback if uvx is not available)</param>
private static TomlTable CreateUnityMcpTable(string uvPath)
{
var unityMCP = new TomlTable();
// Check transport preference
bool useHttpTransport = EditorPrefs.GetBool(MCPForUnity.Editor.Constants.EditorPrefKeys.UseHttpTransport, true);
if (useHttpTransport)
{
// HTTP mode: Use url field
string httpUrl = HttpEndpointUtility.GetMcpRpcUrl();
unityMCP["url"] = new TomlString { Value = httpUrl };
}
else
{
// Stdio mode: Use command and args
var (uvxPath, _, packageName) = AssetPathUtility.GetUvxCommandParts();
unityMCP["command"] = new TomlString { Value = uvxPath };
var argsArray = new TomlArray();
AddUvxModeFlags(argsArray);
// Use centralized helper for beta server / prerelease args
foreach (var arg in AssetPathUtility.GetBetaServerFromArgsList())
{
argsArray.Add(new TomlString { Value = arg });
}
argsArray.Add(new TomlString { Value = packageName });
argsArray.Add(new TomlString { Value = "--transport" });
argsArray.Add(new TomlString { Value = "stdio" });
unityMCP["args"] = argsArray;
// Add Windows-specific environment configuration for stdio mode
var platformService = MCPServiceLocator.Platform;
if (platformService.IsWindows())
{
var envTable = new TomlTable { IsInline = true };
envTable["SystemRoot"] = new TomlString { Value = platformService.GetSystemRoot() };
unityMCP["env"] = envTable;
}
// Allow extra time for uvx to download packages on first run
unityMCP["startup_timeout_sec"] = new TomlInteger { Value = 60 };
}
return unityMCP;
}
/// <summary>
/// Ensures the features table contains the rmcp_client flag for HTTP/SSE transport.
/// </summary>
private static void EnsureRmcpClientFeature(TomlTable root)
{
if (root == null) return;
if (!root.TryGetNode("features", out var featuresNode) || featuresNode is not TomlTable features)
{
features = new TomlTable();
root["features"] = features;
}
features["rmcp_client"] = new TomlBoolean { Value = true };
}
private static bool TryGetTable(TomlTable parent, string key, out TomlTable table)
{
table = null;
if (parent == null) return false;
if (parent.TryGetNode(key, out var node))
{
if (node is TomlTable tbl)
{
table = tbl;
return true;
}
if (node is TomlArray array)
{
var firstTable = array.Children.OfType<TomlTable>().FirstOrDefault();
if (firstTable != null)
{
table = firstTable;
return true;
}
}
}
return false;
}
private static string GetTomlString(TomlTable table, string key)
{
if (table != null && table.TryGetNode(key, out var node))
{
if (node is TomlString str) return str.Value;
if (node.HasValue) return node.ToString();
}
return null;
}
private static string[] GetTomlStringArray(TomlTable table, string key)
{
if (table == null) return null;
if (!table.TryGetNode(key, out var node)) return null;
if (node is TomlArray array)
{
List<string> values = new List<string>();
foreach (TomlNode element in array.Children)
{
if (element is TomlString str)
{
values.Add(str.Value);
}
else if (element.HasValue)
{
values.Add(element.ToString());
}
}
return values.Count > 0 ? values.ToArray() : Array.Empty<string>();
}
if (node is TomlString single)
{
return new[] { single.Value };
}
return null;
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: b3e68082ffc0b4cd39d3747673a4cc22
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
+997
View File
@@ -0,0 +1,997 @@
using System;
using System.Collections.Generic;
using System.Reflection;
using Newtonsoft.Json.Linq;
using UnityEditor;
using UnityEngine;
using UnityEngine.Events;
using MCPForUnity.Runtime.Helpers;
namespace MCPForUnity.Editor.Helpers
{
/// <summary>
/// Low-level component operations extracted from ManageGameObject and ManageComponents.
/// Provides pure C# operations without JSON parsing or response formatting.
/// </summary>
public static class ComponentOps
{
/// <summary>
/// Adds a component to a GameObject with Undo support.
/// </summary>
/// <param name="target">The target GameObject</param>
/// <param name="componentType">The type of component to add</param>
/// <param name="error">Error message if operation fails</param>
/// <returns>The added component, or null if failed</returns>
public static Component AddComponent(GameObject target, Type componentType, out string error)
{
error = null;
if (target == null)
{
error = "Target GameObject is null.";
return null;
}
if (componentType == null || !typeof(Component).IsAssignableFrom(componentType))
{
error = $"Type '{componentType?.Name ?? "null"}' is not a valid Component type.";
return null;
}
// Prevent adding duplicate Transform
if (componentType == typeof(Transform))
{
error = "Cannot add another Transform component.";
return null;
}
// Check for 2D/3D physics conflicts
string conflictError = CheckPhysicsConflict(target, componentType);
if (conflictError != null)
{
error = conflictError;
return null;
}
// Produce a clearer error when this component already exists and cannot be duplicated.
Component existingComponent = target.GetComponent(componentType);
if (existingComponent != null && !AllowsMultiple(target, componentType))
{
error = $"Component '{componentType.Name}' already exists on '{target.name}' and this type does not allow multiple instances.";
return null;
}
try
{
Component newComponent = Undo.AddComponent(target, componentType);
if (newComponent == null)
{
if (target.GetComponent(componentType) != null && !AllowsMultiple(target, componentType))
{
error = $"Component '{componentType.Name}' already exists on '{target.name}' and this type does not allow multiple instances.";
}
else
{
error = $"Failed to add component '{componentType.Name}' to '{target.name}'. Unity may restrict this component on the current target.";
}
return null;
}
// Apply default values for specific component types
ApplyDefaultValues(newComponent);
return newComponent;
}
catch (Exception ex)
{
error = $"Error adding component '{componentType.Name}': {ex.Message}";
return null;
}
}
/// <summary>
/// Removes a component from a GameObject with Undo support.
/// </summary>
/// <param name="target">The target GameObject</param>
/// <param name="componentType">The type of component to remove</param>
/// <param name="error">Error message if operation fails</param>
/// <returns>True if component was removed successfully</returns>
public static bool RemoveComponent(GameObject target, Type componentType, out string error)
{
error = null;
if (target == null)
{
error = "Target GameObject is null.";
return false;
}
if (componentType == null)
{
error = "Component type is null.";
return false;
}
// Prevent removing Transform
if (componentType == typeof(Transform))
{
error = "Cannot remove Transform component.";
return false;
}
Component component = target.GetComponent(componentType);
if (component == null)
{
error = $"Component '{componentType.Name}' not found on '{target.name}'.";
return false;
}
try
{
Undo.DestroyObjectImmediate(component);
return true;
}
catch (Exception ex)
{
error = $"Error removing component '{componentType.Name}': {ex.Message}";
return false;
}
}
/// <summary>
/// Sets a property value on a component using reflection.
/// </summary>
/// <param name="component">The target component</param>
/// <param name="propertyName">The property or field name</param>
/// <param name="value">The value to set (JToken)</param>
/// <param name="error">Error message if operation fails</param>
/// <returns>True if property was set successfully</returns>
public static bool SetProperty(Component component, string propertyName, JToken value, out string error)
{
error = null;
if (component == null)
{
error = "Component is null.";
return false;
}
if (string.IsNullOrEmpty(propertyName))
{
error = "Property name is null or empty.";
return false;
}
Type type = component.GetType();
BindingFlags flags = BindingFlags.Public | BindingFlags.Instance | BindingFlags.IgnoreCase;
string normalizedName = ParamCoercion.NormalizePropertyName(propertyName);
// UnityEventBase-derived types must be set via SerializedProperty, not reflection.
// Reflection creates a disconnected object that Unity's serialization layer doesn't track,
// causing m_PersistentCalls to be empty when the scene is saved.
Type memberType = ResolveMemberType(type, propertyName, normalizedName);
if (memberType != null && typeof(UnityEventBase).IsAssignableFrom(memberType))
{
return SetViaSerializedProperty(component, propertyName, normalizedName, value, out error);
}
// Try reflection first (property, field, then non-public serialized field)
if (TrySetViaReflection(component, type, propertyName, normalizedName, flags, value, out error))
return true;
// Reflection failed — fall back to SerializedProperty which handles arrays,
// custom serialization (e.g. UdonSharp), and types reflection can't convert.
string reflectionError = error;
if (SetViaSerializedProperty(component, propertyName, normalizedName, value, out error))
return true;
// Both paths failed. If reflection found the member but couldn't convert,
// report that (more useful than the SerializedProperty error).
// If reflection didn't find it at all, report the SerializedProperty error.
if (reflectionError != null && !reflectionError.Contains("not found"))
error = reflectionError;
return false;
}
private static bool TrySetViaReflection(object component, Type type, string propertyName, string normalizedName, BindingFlags flags, JToken value, out string error)
{
error = null;
// Skip reflection for UnityEngine.Object types with JObject values
// so SerializedProperty can resolve guid/spriteName/fileID forms.
bool isJObjectValue = value != null && value.Type == JTokenType.Object;
// Try property first
PropertyInfo propInfo = type.GetProperty(propertyName, flags)
?? type.GetProperty(normalizedName, flags);
if (propInfo != null && propInfo.CanWrite)
{
if (isJObjectValue && typeof(UnityEngine.Object).IsAssignableFrom(propInfo.PropertyType))
{
// Let SerializedProperty path handle complex object references.
return false;
}
try
{
object convertedValue = PropertyConversion.ConvertToType(value, propInfo.PropertyType);
if (convertedValue == null && value.Type != JTokenType.Null)
{
error = $"Failed to convert value for property '{propertyName}' to type '{propInfo.PropertyType.Name}'.";
return false;
}
propInfo.SetValue(component, convertedValue);
return true;
}
catch (Exception ex)
{
error = $"Failed to set property '{propertyName}': {ex.Message}";
return false;
}
}
// Try field
FieldInfo fieldInfo = type.GetField(propertyName, flags)
?? type.GetField(normalizedName, flags);
if (fieldInfo != null && !fieldInfo.IsInitOnly)
{
if (isJObjectValue && typeof(UnityEngine.Object).IsAssignableFrom(fieldInfo.FieldType))
{
// Let SerializedProperty path handle complex object references.
return false;
}
try
{
object convertedValue = PropertyConversion.ConvertToType(value, fieldInfo.FieldType);
if (convertedValue == null && value.Type != JTokenType.Null)
{
error = $"Failed to convert value for field '{propertyName}' to type '{fieldInfo.FieldType.Name}'.";
return false;
}
fieldInfo.SetValue(component, convertedValue);
return true;
}
catch (Exception ex)
{
error = $"Failed to set field '{propertyName}': {ex.Message}";
return false;
}
}
// Try non-public serialized fields — traverse inheritance hierarchy
fieldInfo = FindSerializedFieldInHierarchy(type, propertyName)
?? FindSerializedFieldInHierarchy(type, normalizedName);
if (fieldInfo != null)
{
if (isJObjectValue && typeof(UnityEngine.Object).IsAssignableFrom(fieldInfo.FieldType))
{
// Let SerializedProperty path handle complex object references.
return false;
}
try
{
object convertedValue = PropertyConversion.ConvertToType(value, fieldInfo.FieldType);
if (convertedValue == null && value.Type != JTokenType.Null)
{
error = $"Failed to convert value for serialized field '{propertyName}' to type '{fieldInfo.FieldType.Name}'.";
return false;
}
fieldInfo.SetValue(component, convertedValue);
return true;
}
catch (Exception ex)
{
error = $"Failed to set serialized field '{propertyName}': {ex.Message}";
return false;
}
}
error = $"Property or field '{propertyName}' not found on component '{type.Name}'.";
return false;
}
/// <summary>
/// Gets all public properties and fields from a component type.
/// </summary>
public static List<string> GetAccessibleMembers(Type componentType)
{
var members = new List<string>();
if (componentType == null) return members;
BindingFlags flags = BindingFlags.Public | BindingFlags.Instance;
foreach (var prop in componentType.GetProperties(flags))
{
if (prop.CanWrite && prop.GetSetMethod() != null)
{
members.Add(prop.Name);
}
}
foreach (var field in componentType.GetFields(flags))
{
if (!field.IsInitOnly)
{
members.Add(field.Name);
}
}
// Include private [SerializeField] fields - traverse inheritance hierarchy
// Type.GetFields with NonPublic only returns fields declared directly on that type,
// so we need to walk up the chain to find inherited private serialized fields
var seenFieldNames = new HashSet<string>(members); // Avoid duplicates with public fields
Type currentType = componentType;
while (currentType != null && currentType != typeof(object))
{
foreach (var field in currentType.GetFields(BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly))
{
if (field.GetCustomAttribute<SerializeField>() != null && !seenFieldNames.Contains(field.Name))
{
members.Add(field.Name);
seenFieldNames.Add(field.Name);
}
}
currentType = currentType.BaseType;
}
members.Sort();
return members;
}
// --- Private Helpers ---
/// <summary>
/// Searches for a non-public [SerializeField] field through the entire inheritance hierarchy.
/// Type.GetField() with NonPublic only returns fields declared directly on that type,
/// so this method walks up the chain to find inherited private serialized fields.
/// </summary>
internal static FieldInfo FindSerializedFieldInHierarchy(Type type, string fieldName)
{
if (type == null || string.IsNullOrEmpty(fieldName))
return null;
BindingFlags privateFlags = BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly;
Type currentType = type;
// Walk up the inheritance chain
while (currentType != null && currentType != typeof(object))
{
// Search for the field on this specific type (case-insensitive)
foreach (var field in currentType.GetFields(privateFlags))
{
if (string.Equals(field.Name, fieldName, StringComparison.OrdinalIgnoreCase) &&
field.GetCustomAttribute<SerializeField>() != null)
{
return field;
}
}
currentType = currentType.BaseType;
}
return null;
}
private static string CheckPhysicsConflict(GameObject target, Type componentType)
{
bool isAdding2DPhysics =
typeof(Rigidbody2D).IsAssignableFrom(componentType) ||
typeof(Collider2D).IsAssignableFrom(componentType);
bool isAdding3DPhysics =
typeof(Rigidbody).IsAssignableFrom(componentType) ||
typeof(Collider).IsAssignableFrom(componentType);
if (isAdding2DPhysics)
{
if (target.GetComponent<Rigidbody>() != null || target.GetComponent<Collider>() != null)
{
return $"Cannot add 2D physics component '{componentType.Name}' because the GameObject '{target.name}' already has a 3D Rigidbody or Collider.";
}
}
else if (isAdding3DPhysics)
{
if (target.GetComponent<Rigidbody2D>() != null || target.GetComponent<Collider2D>() != null)
{
return $"Cannot add 3D physics component '{componentType.Name}' because the GameObject '{target.name}' already has a 2D Rigidbody or Collider.";
}
}
return null;
}
private static void ApplyDefaultValues(Component component)
{
// Default newly added Lights to Directional
if (component is Light light)
{
light.type = LightType.Directional;
}
}
private static bool AllowsMultiple(GameObject target, Type componentType)
{
if (target == null || componentType == null)
{
return false;
}
if (Attribute.IsDefined(componentType, typeof(DisallowMultipleComponent), inherit: true))
{
return false;
}
return true;
}
// --- UnityEvent SerializedProperty support ---
private static Type ResolveMemberType(Type componentType, string propertyName, string normalizedName)
{
BindingFlags flags = BindingFlags.Public | BindingFlags.Instance | BindingFlags.IgnoreCase;
PropertyInfo propInfo = componentType.GetProperty(propertyName, flags)
?? componentType.GetProperty(normalizedName, flags);
if (propInfo != null)
return propInfo.PropertyType;
FieldInfo fieldInfo = componentType.GetField(propertyName, flags)
?? componentType.GetField(normalizedName, flags);
if (fieldInfo != null)
return fieldInfo.FieldType;
fieldInfo = FindSerializedFieldInHierarchy(componentType, propertyName)
?? FindSerializedFieldInHierarchy(componentType, normalizedName);
if (fieldInfo != null)
return fieldInfo.FieldType;
return null;
}
private static bool SetViaSerializedProperty(Component component, string propertyName, string normalizedName, JToken value, out string error)
{
error = null;
using var so = new SerializedObject(component);
SerializedProperty prop = so.FindProperty(propertyName)
?? so.FindProperty(normalizedName);
if (prop == null)
{
error = $"SerializedProperty '{propertyName}' not found on component '{component.GetType().Name}'.";
return false;
}
if (!SetSerializedPropertyRecursive(prop, value, out error, 0))
return false;
so.ApplyModifiedProperties();
// Readback verification for ObjectReference — these can silently fail
if (prop.propertyType == SerializedPropertyType.ObjectReference
&& value != null
&& !(value is JValue jv && jv.Type == JTokenType.Null))
{
so.Update();
var verifyProp = so.FindProperty(propertyName)
?? so.FindProperty(normalizedName);
if (verifyProp != null
&& verifyProp.propertyType == SerializedPropertyType.ObjectReference
&& verifyProp.objectReferenceValue == null)
{
error = $"Property '{propertyName}' was set but the object reference did not persist. " +
"Check that the referenced object exists and is the correct type.";
return false;
}
}
return true;
}
private static bool SetSerializedPropertyRecursive(SerializedProperty prop, JToken value, out string error, int depth)
{
error = null;
const int MaxDepth = 20;
if (depth > MaxDepth)
{
error = $"Maximum recursion depth ({MaxDepth}) exceeded.";
return false;
}
try
{
// Array + JArray
if (prop.isArray && prop.propertyType != SerializedPropertyType.String && value is JArray jArray)
{
prop.arraySize = jArray.Count;
prop.serializedObject.ApplyModifiedProperties();
prop.serializedObject.Update();
for (int i = 0; i < jArray.Count; i++)
{
var element = prop.GetArrayElementAtIndex(i);
if (!SetSerializedPropertyRecursive(element, jArray[i], out error, depth + 1))
return false;
}
return true;
}
// Generic (struct/class) + JObject
if (prop.propertyType == SerializedPropertyType.Generic && !prop.isArray && value is JObject jObj)
{
foreach (var kvp in jObj)
{
var child = FindPropertyRelativeFuzzy(prop, kvp.Key);
if (child == null)
{
error = $"Sub-property '{kvp.Key}' not found under '{prop.propertyPath}'.";
return false;
}
if (!SetSerializedPropertyRecursive(child, kvp.Value, out error, depth + 1))
return false;
}
return true;
}
// ObjectReference
if (prop.propertyType == SerializedPropertyType.ObjectReference)
return SetObjectReference(prop, value, out error);
// Leaf types
switch (prop.propertyType)
{
case SerializedPropertyType.Integer:
if (value == null || value.Type == JTokenType.Null
|| (value.Type != JTokenType.Integer && value.Type != JTokenType.Float
&& !long.TryParse(value.ToString(), out _)))
{
error = "Expected integer value.";
return false;
}
if (prop.type == "long")
prop.longValue = ParamCoercion.CoerceLong(value, 0);
else
prop.intValue = ParamCoercion.CoerceInt(value, 0);
return true;
case SerializedPropertyType.Boolean:
if (value == null || value.Type == JTokenType.Null)
{
error = "Expected boolean value.";
return false;
}
prop.boolValue = ParamCoercion.CoerceBool(value, false);
return true;
case SerializedPropertyType.Float:
float floatVal = ParamCoercion.CoerceFloat(value, float.NaN);
if (float.IsNaN(floatVal))
{
error = "Expected float value.";
return false;
}
prop.floatValue = floatVal;
return true;
case SerializedPropertyType.String:
prop.stringValue = value == null || value.Type == JTokenType.Null ? string.Empty : value.ToString();
return true;
case SerializedPropertyType.Enum:
return SetEnum(prop, value, out error);
default:
error = $"Unsupported SerializedPropertyType: {prop.propertyType} at '{prop.propertyPath}'.";
return false;
}
}
catch (Exception ex)
{
error = $"Error setting '{prop.propertyPath}': {ex.Message}";
return false;
}
}
internal static bool SetObjectReference(SerializedProperty prop, JToken value, out string error)
{
error = null;
if (value == null || value.Type == JTokenType.Null)
{
prop.objectReferenceValue = null;
return true;
}
if (value.Type == JTokenType.Integer)
{
int id = value.Value<int>();
var resolved = GameObjectLookup.ResolveInstanceID(id);
if (resolved == null)
{
error = $"No object found with instanceID {id}.";
return false;
}
return AssignObjectReference(prop, resolved, null, out error);
}
if (value is JObject jObj)
{
// Optional component type filter — e.g. {"instanceID": 123, "component": "Button"}
string componentFilter = jObj["component"]?.ToString();
var idToken = jObj["instanceID"];
if (idToken != null)
{
int id = ParamCoercion.CoerceInt(idToken, 0);
var resolved = GameObjectLookup.ResolveInstanceID(id);
if (resolved == null)
{
error = $"No object found with instanceID {id}.";
return false;
}
return AssignObjectReference(prop, resolved, componentFilter, out error);
}
var guidToken = jObj["guid"];
if (guidToken != null)
{
string path = AssetDatabase.GUIDToAssetPath(guidToken.ToString());
if (string.IsNullOrEmpty(path))
{
error = $"No asset found for GUID '{guidToken}'.";
return false;
}
var spriteNameToken = jObj["spriteName"];
if (spriteNameToken != null)
{
string spriteName = spriteNameToken.ToString();
var allAssets = AssetDatabase.LoadAllAssetsAtPath(path);
var originalRef = prop.objectReferenceValue;
foreach (var asset in allAssets)
{
if (asset is Sprite sprite && sprite.name == spriteName)
{
prop.objectReferenceValue = sprite;
if (prop.objectReferenceValue != null)
return true;
// Unity rejected the type — restore and report
prop.objectReferenceValue = originalRef;
error = $"Sprite '{spriteName}' found but is not compatible with the property type.";
return false;
}
}
error = $"Sprite '{spriteName}' not found in atlas '{path}'.";
return false;
}
var fileIdToken = jObj["fileID"];
if (fileIdToken != null)
{
long targetFileId = fileIdToken.Value<long>();
if (targetFileId != 0)
{
var allAssets = AssetDatabase.LoadAllAssetsAtPath(path);
var originalRef = prop.objectReferenceValue;
foreach (var asset in allAssets)
{
if (asset is Sprite sprite)
{
long spriteFileId = GetSpriteFileId(sprite);
if (spriteFileId == targetFileId)
{
prop.objectReferenceValue = sprite;
if (prop.objectReferenceValue != null)
return true;
prop.objectReferenceValue = originalRef;
error = $"Sprite with fileID '{targetFileId}' found but is not compatible with the property type.";
return false;
}
}
}
}
error = $"Sprite with fileID '{targetFileId}' not found in atlas '{path}'.";
return false;
}
var loaded = AssetDatabase.LoadAssetAtPath<UnityEngine.Object>(path);
return AssignObjectReference(prop, loaded, componentFilter, out error);
}
var pathToken = jObj["path"];
if (pathToken != null)
{
string sanitized = AssetPathUtility.SanitizeAssetPath(pathToken.ToString());
var resolved = AssetDatabase.LoadAssetAtPath<UnityEngine.Object>(sanitized);
if (resolved == null)
{
error = $"No asset found at path '{pathToken}'.";
return false;
}
return AssignObjectReference(prop, resolved, componentFilter, out error);
}
var nameToken = jObj["name"];
if (nameToken != null)
{
return ResolveSceneObjectByName(prop, nameToken.ToString(), componentFilter, out error);
}
error = "Object reference must contain 'instanceID', 'guid', 'path', or 'name'.";
return false;
}
if (value.Type == JTokenType.String)
{
string strVal = value.ToString();
// Try as instanceID if the string is purely numeric
if (int.TryParse(strVal, out int parsedId))
{
var resolved = GameObjectLookup.ResolveInstanceID(parsedId);
if (resolved != null)
return AssignObjectReference(prop, resolved, null, out error);
// Not a valid instanceID — fall through to path/name resolution
}
if (strVal.StartsWith("Assets/", StringComparison.OrdinalIgnoreCase) || strVal.Contains("/"))
{
string sanitized = AssetPathUtility.SanitizeAssetPath(strVal);
var resolved = AssetDatabase.LoadAssetAtPath<UnityEngine.Object>(sanitized);
if (resolved == null)
{
error = $"No asset found at path '{strVal}'.";
return false;
}
return AssignObjectReference(prop, resolved, null, out error);
}
// Try as asset GUID (32-char hex string)
if (strVal.Length == 32 && IsHexString(strVal))
{
string assetPath = AssetDatabase.GUIDToAssetPath(strVal);
if (!string.IsNullOrEmpty(assetPath))
{
var resolved = AssetDatabase.LoadAssetAtPath<UnityEngine.Object>(assetPath);
if (resolved != null)
return AssignObjectReference(prop, resolved, null, out error);
}
}
// Fall back to scene hierarchy lookup by name.
return ResolveSceneObjectByName(prop, strVal, null, out error);
}
error = $"Unsupported object reference format: {value.Type}.";
return false;
}
/// <summary>
/// Assigns a resolved object to a SerializedProperty, with automatic component fallback.
/// If the resolved object is a GameObject but the property expects a Component type,
/// searches the GameObject's components for a compatible one.
/// Optionally filters by component type name (e.g. "Button", "Rigidbody").
/// </summary>
private static bool AssignObjectReference(SerializedProperty prop, UnityEngine.Object resolved, string componentFilter, out string error)
{
error = null;
if (resolved == null)
{
error = "Resolved object is null.";
return false;
}
// If a component filter is specified and the resolved object is a GameObject,
// find the specific component by type name.
if (!string.IsNullOrEmpty(componentFilter) && resolved is GameObject filterGo)
{
var components = filterGo.GetComponents<Component>();
foreach (var comp in components)
{
if (comp == null) continue;
if (string.Equals(comp.GetType().Name, componentFilter, StringComparison.OrdinalIgnoreCase) ||
string.Equals(comp.GetType().FullName, componentFilter, StringComparison.OrdinalIgnoreCase))
{
prop.objectReferenceValue = comp;
if (prop.objectReferenceValue != null)
return true;
}
}
error = $"Component '{componentFilter}' not found on GameObject '{filterGo.name}'.";
return false;
}
// Try direct assignment first
prop.objectReferenceValue = resolved;
if (prop.objectReferenceValue != null)
return true;
// Sub-asset fallback: e.g., Texture2D → Sprite
string subAssetPath = AssetDatabase.GetAssetPath(resolved);
if (!string.IsNullOrEmpty(subAssetPath))
{
var subAssets = AssetDatabase.LoadAllAssetsAtPath(subAssetPath);
UnityEngine.Object match = null;
int matchCount = 0;
foreach (var sub in subAssets)
{
if (sub == null || sub == resolved) continue;
prop.objectReferenceValue = sub;
if (prop.objectReferenceValue != null)
{
match = sub;
matchCount++;
if (matchCount > 1) break;
}
}
if (matchCount == 1)
{
prop.objectReferenceValue = match;
return true;
}
// Clean up: probing may have left the property dirty
prop.objectReferenceValue = null;
if (matchCount > 1)
{
error = $"Multiple compatible sub-assets found in '{subAssetPath}'. " +
"Use {\"guid\": \"...\", \"spriteName\": \"<name>\"} or " +
"{\"guid\": \"...\", \"fileID\": <id>} for precise selection.";
return false;
}
}
// If the resolved object is a GameObject but the property expects a Component,
// try each component on the GameObject until one is accepted.
if (resolved is GameObject go)
{
var components = go.GetComponents<Component>();
foreach (var comp in components)
{
if (comp == null) continue;
prop.objectReferenceValue = comp;
if (prop.objectReferenceValue != null)
return true;
}
error = $"GameObject '{go.name}' found but no compatible component for the property type.";
return false;
}
error = $"Object '{resolved.name}' (type: {resolved.GetType().Name}) is not compatible with the property type.";
return false;
}
/// <summary>
/// Resolves a scene GameObject by name and assigns it (or a component on it)
/// to a SerializedProperty. Uses GameObjectLookup for robust search
/// including inactive objects and prefab stage support.
/// </summary>
private static bool ResolveSceneObjectByName(SerializedProperty prop, string name, string componentFilter, out string error)
{
error = null;
if (string.IsNullOrWhiteSpace(name))
{
error = "Cannot resolve object reference from empty name.";
return false;
}
var ids = GameObjectLookup.SearchGameObjects(
GameObjectLookup.SearchMethod.ByName, name, includeInactive: true, maxResults: 1);
if (ids.Count == 0)
{
error = $"No GameObject named '{name}' found in scene.";
return false;
}
var go = GameObjectLookup.FindById(ids[0]);
if (go == null)
{
error = $"GameObject '{name}' found but could not be resolved.";
return false;
}
return AssignObjectReference(prop, go, componentFilter, out error);
}
/// <summary>
/// Finds a child SerializedProperty by name, falling back to underscore-insensitive matching.
/// The batch_execute transport can strip underscores from JSON keys
/// (e.g. m_PersistentCalls → mPersistentCalls), so we iterate immediate children
/// and compare with underscores removed.
/// </summary>
private static SerializedProperty FindPropertyRelativeFuzzy(SerializedProperty parent, string key)
{
var child = parent.FindPropertyRelative(key);
if (child != null) return child;
string normalizedKey = key.Replace("_", "").ToLowerInvariant();
var end = parent.GetEndProperty();
var iter = parent.Copy();
if (!iter.Next(true)) return null;
while (!SerializedProperty.EqualContents(iter, end))
{
if (iter.depth == parent.depth + 1)
{
string normalizedName = iter.name.Replace("_", "").ToLowerInvariant();
if (normalizedName == normalizedKey)
return parent.FindPropertyRelative(iter.name);
}
if (!iter.Next(false))
break;
}
return null;
}
private static bool SetEnum(SerializedProperty prop, JToken value, out string error)
{
error = null;
var names = prop.enumNames;
if (names == null || names.Length == 0)
{
error = "Enum has no names.";
return false;
}
if (value.Type == JTokenType.Integer)
{
int idx = value.Value<int>();
if (idx < 0 || idx >= names.Length)
{
error = $"Enum index out of range: {idx}.";
return false;
}
prop.enumValueIndex = idx;
return true;
}
string s = value.ToString();
for (int i = 0; i < names.Length; i++)
{
if (string.Equals(names[i], s, StringComparison.OrdinalIgnoreCase))
{
prop.enumValueIndex = i;
return true;
}
}
error = $"Unknown enum name '{s}'.";
return false;
}
private static long GetSpriteFileId(Sprite sprite)
{
if (sprite == null)
return 0;
try
{
var globalId = GlobalObjectId.GetGlobalObjectIdSlow(sprite);
return unchecked((long)globalId.targetObjectId);
}
catch (Exception ex)
{
McpLog.Warn($"Failed to get fileID for sprite '{sprite.name}' (instanceID={sprite.GetInstanceIDCompat()}): {ex.Message}");
return 0;
}
}
private static bool IsHexString(string str)
{
foreach (char c in str)
{
if (!((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F')))
return false;
}
return true;
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 13dead161bc4540eeb771961df437779
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,187 @@
using System;
using System.Collections.Generic;
using System.Linq;
using MCPForUnity.Editor.Constants;
using MCPForUnity.Editor.Services;
using MCPForUnity.Editor.Models;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using UnityEditor;
using UnityEngine;
namespace MCPForUnity.Editor.Helpers
{
public static class ConfigJsonBuilder
{
public static string BuildManualConfigJson(string uvPath, McpClient client)
{
var root = new JObject();
bool isVSCode = client?.IsVsCodeLayout == true;
if (!string.IsNullOrEmpty(client?.SchemaUrl)) root["$schema"] = client.SchemaUrl;
JObject container = EnsureObject(root, GetContainerKey(client, isVSCode));
var unity = new JObject();
PopulateUnityNode(unity, uvPath, client, isVSCode);
container["unityMCP"] = unity;
return root.ToString(Formatting.Indented);
}
public static JObject ApplyUnityServerToExistingConfig(JObject root, string uvPath, McpClient client)
{
if (root == null) root = new JObject();
bool isVSCode = client?.IsVsCodeLayout == true;
if (!string.IsNullOrEmpty(client?.SchemaUrl) && root["$schema"] == null) root["$schema"] = client.SchemaUrl;
JObject container = EnsureObject(root, GetContainerKey(client, isVSCode));
JObject unity = container["unityMCP"] as JObject ?? new JObject();
PopulateUnityNode(unity, uvPath, client, isVSCode);
container["unityMCP"] = unity;
return root;
}
/// <summary>
/// Centralized builder that applies all caveats consistently.
/// - Sets command/args with uvx and package version
/// - Ensures env exists
/// - Adds transport configuration (HTTP or stdio)
/// - Adds disabled:false for Windsurf/Kiro only when missing
/// </summary>
private static void PopulateUnityNode(JObject unity, string uvPath, McpClient client, bool isVSCode)
{
// Get transport preference (default to HTTP)
bool prefValue = EditorConfigurationCache.Instance.UseHttpTransport;
bool clientSupportsHttp = client?.SupportsHttpTransport != false;
bool useHttpTransport = clientSupportsHttp && prefValue;
string httpProperty = string.IsNullOrEmpty(client?.HttpUrlProperty) ? "url" : client.HttpUrlProperty;
var urlPropsToRemove = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "url", "serverUrl" };
urlPropsToRemove.Remove(httpProperty);
if (useHttpTransport)
{
// HTTP mode: Use URL, no command
string httpUrl = HttpEndpointUtility.GetMcpRpcUrl();
unity[httpProperty] = httpUrl;
foreach (var prop in urlPropsToRemove)
{
if (unity[prop] != null) unity.Remove(prop);
}
// Remove command/args if they exist from previous config
if (unity["command"] != null) unity.Remove("command");
if (unity["args"] != null) unity.Remove("args");
// Only include API key header for remote-hosted mode
if (HttpEndpointUtility.IsRemoteScope())
{
string apiKey = EditorPrefs.GetString(EditorPrefKeys.ApiKey, string.Empty);
if (!string.IsNullOrEmpty(apiKey))
{
var headers = new JObject { [AuthConstants.ApiKeyHeader] = apiKey };
unity["headers"] = headers;
}
else
{
if (unity["headers"] != null) unity.Remove("headers");
}
}
else
{
// Local HTTP doesn't use API keys; remove any stale headers
if (unity["headers"] != null) unity.Remove("headers");
}
// Per-client override of the HTTP "type" value: Cline/Roo expect "streamableHttp"
// and Kilo expects "remote"; both fall back to stdio when they see the generic
// "http". Defaults to "http" (standard MCP protocol field) when unset, so clients
// don't default to SSE on seeing a URL without a type.
unity["type"] = string.IsNullOrEmpty(client?.HttpTypeValue) ? "http" : client.HttpTypeValue;
}
else
{
// Stdio mode: Use uvx command
var (uvxPath, fromUrl, packageName) = AssetPathUtility.GetUvxCommandParts();
var toolArgs = BuildUvxArgs(fromUrl, packageName);
unity["command"] = uvxPath;
unity["args"] = JArray.FromObject(toolArgs.ToArray());
// Remove url/serverUrl if they exist from previous config
if (unity["url"] != null) unity.Remove("url");
if (unity["serverUrl"] != null) unity.Remove("serverUrl");
// Include type for all clients — standard MCP protocol field. A few clients use a
// different token for local transport (e.g. Kilo uses "local").
unity["type"] = string.IsNullOrEmpty(client?.StdioTypeValue) ? "stdio" : client.StdioTypeValue;
}
bool requiresEnv = client?.EnsureEnvObject == true;
bool stripEnv = client?.StripEnvWhenNotRequired == true;
if (requiresEnv)
{
if (unity["env"] == null)
{
unity["env"] = new JObject();
}
}
else if (stripEnv && unity["env"] != null)
{
unity.Remove("env");
}
if (client?.DefaultUnityFields != null)
{
foreach (var kvp in client.DefaultUnityFields)
{
if (unity[kvp.Key] == null)
{
unity[kvp.Key] = kvp.Value != null ? JToken.FromObject(kvp.Value) : JValue.CreateNull();
}
}
}
}
private static string GetContainerKey(McpClient client, bool isVSCode)
{
if (isVSCode) return "servers";
return string.IsNullOrEmpty(client?.ServerContainerKey) ? "mcpServers" : client.ServerContainerKey;
}
private static JObject EnsureObject(JObject parent, string name)
{
if (parent[name] is JObject o) return o;
var created = new JObject();
parent[name] = created;
return created;
}
private static IList<string> BuildUvxArgs(string fromUrl, string packageName)
{
// Dev mode: force a fresh install/resolution (avoids stale cached builds while iterating).
// `--no-cache` avoids reading from cache; `--refresh` ensures metadata is revalidated.
// Note: --reinstall is not supported by uvx and will cause a warning.
// Keep ordering consistent with other uvx builders: dev flags first, then --from <url>, then package name.
var args = new List<string>();
foreach (var flag in AssetPathUtility.GetUvxDevFlagsList())
args.Add(flag);
// Use centralized helper for beta server / prerelease args
foreach (var arg in AssetPathUtility.GetBetaServerFromArgsList())
{
args.Add(arg);
}
args.Add(packageName);
args.Add("--transport");
args.Add("stdio");
return args;
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 5c07c3369f73943919d9e086a81d1dcc
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,427 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Reflection;
using System.Runtime.ExceptionServices;
using System.Threading;
using MCPForUnity.Runtime.Helpers;
using UnityEditor;
using UnityEngine;
namespace MCPForUnity.Editor.Helpers
{
/// <summary>
/// Captures the pixels currently displayed in an editor window viewport.
/// Uses the editor view's own pixel grab path instead of re-rendering through a Camera.
/// </summary>
internal static class EditorWindowScreenshotUtility
{
// Keep capture synchronous so callers can immediately return the screenshot payload.
// The short sleep gives Unity a chance to flush repaint work before GrabPixels reads the viewport.
private const int RepaintSettlingDelayMs = 75;
private static readonly HashSet<string> WindowsReservedNames = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
"CON", "PRN", "AUX", "NUL",
"COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9",
"LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9",
};
/// <summary>
/// Captures the active Scene View viewport to a PNG asset.
/// </summary>
/// <param name="sceneView">Scene View window to capture.</param>
/// <param name="fileName">Optional file name, defaulting to a timestamped PNG.</param>
/// <param name="superSize">
/// Preserved in the result for API consistency, but Scene View capture always uses the current viewport resolution.
/// </param>
/// <param name="ensureUniqueFileName">If true, appends a suffix instead of overwriting an existing file.</param>
/// <param name="includeImage">If true, includes a base64 PNG in the returned result.</param>
/// <param name="maxResolution">Maximum edge length for the inline image payload.</param>
/// <param name="viewportWidth">Captured viewport width in pixels.</param>
/// <param name="viewportHeight">Captured viewport height in pixels.</param>
public static ScreenshotCaptureResult CaptureSceneViewViewportToProject(
SceneView sceneView,
string fileName,
int superSize,
bool ensureUniqueFileName,
bool includeImage,
int maxResolution,
out int viewportWidth,
out int viewportHeight,
string folderOverride = null)
{
if (sceneView == null)
throw new ArgumentNullException(nameof(sceneView));
int effectiveSuperSize = NormalizeSceneViewSuperSize(superSize);
FocusAndRepaint(sceneView);
Rect viewportRectPixels = GetSceneViewViewportPixelRect(sceneView);
viewportWidth = Mathf.RoundToInt(viewportRectPixels.width);
viewportHeight = Mathf.RoundToInt(viewportRectPixels.height);
if (viewportWidth <= 0 || viewportHeight <= 0)
throw new InvalidOperationException("Captured Scene view viewport is empty.");
Texture2D captured = null;
Texture2D downscaled = null;
try
{
captured = CaptureViewRect(sceneView, viewportRectPixels);
var result = PrepareCaptureResult(fileName, effectiveSuperSize, ensureUniqueFileName, folderOverride);
byte[] png = captured.EncodeToPNG();
File.WriteAllBytes(result.FullPath, png);
if (includeImage)
{
int targetMax = maxResolution > 0 ? maxResolution : 640;
string imageBase64;
int imageWidth;
int imageHeight;
if (captured.width > targetMax || captured.height > targetMax)
{
downscaled = ScreenshotUtility.DownscaleTexture(captured, targetMax);
imageBase64 = Convert.ToBase64String(downscaled.EncodeToPNG());
imageWidth = downscaled.width;
imageHeight = downscaled.height;
}
else
{
imageBase64 = Convert.ToBase64String(png);
imageWidth = captured.width;
imageHeight = captured.height;
}
return new ScreenshotCaptureResult(
result.FullPath,
result.ProjectRelativePath,
result.SuperSize,
false,
imageBase64,
imageWidth,
imageHeight);
}
return result;
}
finally
{
DestroyTexture(captured);
DestroyTexture(downscaled);
}
}
private static void FocusAndRepaint(SceneView sceneView)
{
try
{
sceneView.Focus();
}
catch (Exception ex)
{
McpLog.Debug($"[EditorWindowScreenshotUtility] SceneView focus failed: {ex.Message}");
}
try
{
sceneView.Repaint();
InvokeMethodIfExists(sceneView, "RepaintImmediately");
SceneView.RepaintAll();
UnityEditorInternal.InternalEditorUtility.RepaintAllViews();
EditorApplication.QueuePlayerLoopUpdate();
Thread.Sleep(RepaintSettlingDelayMs);
}
catch (Exception ex)
{
McpLog.Debug($"[EditorWindowScreenshotUtility] SceneView repaint failed: {ex.Message}");
}
}
private static Rect GetSceneViewViewportPixelRect(SceneView sceneView)
{
float pixelsPerPoint = EditorGUIUtility.pixelsPerPoint;
Rect viewportLocalPoints = GetViewportLocalRectPoints(sceneView, pixelsPerPoint);
if (viewportLocalPoints.width <= 0f || viewportLocalPoints.height <= 0f)
throw new InvalidOperationException("Failed to resolve Scene view viewport rect.");
return new Rect(
Mathf.Round(viewportLocalPoints.x * pixelsPerPoint),
Mathf.Round(viewportLocalPoints.y * pixelsPerPoint),
Mathf.Round(viewportLocalPoints.width * pixelsPerPoint),
Mathf.Round(viewportLocalPoints.height * pixelsPerPoint));
}
private static Rect GetViewportLocalRectPoints(SceneView sceneView, float pixelsPerPoint)
{
Rect? cameraViewport = GetRectProperty(sceneView, "cameraViewport");
if (cameraViewport.HasValue && cameraViewport.Value.width > 0f && cameraViewport.Value.height > 0f)
{
return cameraViewport.Value;
}
Camera camera = sceneView.camera;
if (camera == null)
throw new InvalidOperationException("Active Scene View has no camera to derive viewport size from.");
float viewportWidth = camera.pixelWidth / Mathf.Max(0.0001f, pixelsPerPoint);
float viewportHeight = camera.pixelHeight / Mathf.Max(0.0001f, pixelsPerPoint);
Rect windowRect = sceneView.position;
return new Rect(
0f,
Mathf.Max(0f, windowRect.height - viewportHeight),
Mathf.Min(windowRect.width, viewportWidth),
Mathf.Min(windowRect.height, viewportHeight));
}
private static Texture2D CaptureViewRect(SceneView sceneView, Rect viewportRectPixels)
{
object hostView = GetHostView(sceneView);
if (hostView == null)
throw new InvalidOperationException("Failed to resolve Scene view host view.");
// GrabPixels is an internal extern on GUIView (parent of HostView), present since at least Unity 2021.1.
// See: UnityCsReference/Editor/Mono/GUIView.bindings.cs — `internal extern void GrabPixels(RenderTexture, Rect)`
// If Unity removes this, the MissingMethodException below keeps the failure explicit.
MethodInfo grabPixels = hostView.GetType().GetMethod(
"GrabPixels",
BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic,
null,
new[] { typeof(RenderTexture), typeof(Rect) },
null);
if (grabPixels == null)
throw new MissingMethodException($"{hostView.GetType().FullName}.GrabPixels(RenderTexture, Rect)");
int width = Mathf.RoundToInt(viewportRectPixels.width);
int height = Mathf.RoundToInt(viewportRectPixels.height);
RenderTexture rt = null;
RenderTexture previousActive = RenderTexture.active;
try
{
rt = new RenderTexture(width, height, 0, RenderTextureFormat.ARGB32)
{
antiAliasing = 1,
filterMode = FilterMode.Bilinear,
hideFlags = HideFlags.HideAndDontSave,
};
rt.Create();
grabPixels.Invoke(hostView, new object[] { rt, viewportRectPixels });
RenderTexture.active = rt;
var texture = new Texture2D(width, height, TextureFormat.RGBA32, false);
texture.ReadPixels(new Rect(0, 0, width, height), 0, 0);
texture.Apply();
FlipTextureVertically(texture);
return texture;
}
catch (TargetInvocationException ex)
{
ExceptionDispatchInfo.Capture(ex.InnerException ?? ex).Throw();
throw;
}
finally
{
RenderTexture.active = previousActive;
if (rt != null)
{
rt.Release();
UnityEngine.Object.DestroyImmediate(rt);
}
}
}
private static object GetHostView(EditorWindow window)
{
if (window == null)
return null;
Type windowType = typeof(EditorWindow);
FieldInfo parentField = windowType.GetField("m_Parent", BindingFlags.Instance | BindingFlags.NonPublic);
if (parentField != null)
{
object parent = parentField.GetValue(window);
if (parent != null)
return parent;
}
PropertyInfo hostViewProperty = windowType.GetProperty("hostView", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic);
return hostViewProperty?.GetValue(window, null);
}
private static Rect? GetRectProperty(object instance, string propertyName)
{
if (instance == null)
return null;
Type type = instance.GetType();
PropertyInfo property = type.GetProperty(propertyName, BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic);
if (property == null || property.PropertyType != typeof(Rect))
return null;
try
{
return (Rect)property.GetValue(instance, null);
}
catch (Exception ex)
{
McpLog.Debug($"[EditorWindowScreenshotUtility] Failed to read rect property '{propertyName}': {ex.Message}");
return null;
}
}
private static void InvokeMethodIfExists(object instance, string methodName)
{
if (instance == null)
return;
MethodInfo method = instance.GetType().GetMethod(methodName, BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic);
if (method == null || method.GetParameters().Length != 0)
return;
try
{
method.Invoke(instance, null);
}
catch (Exception ex)
{
McpLog.Debug($"[EditorWindowScreenshotUtility] Best-effort invoke of '{methodName}' failed: {ex.Message}");
}
}
private static void FlipTextureVertically(Texture2D texture)
{
if (texture == null)
return;
int width = texture.width;
int height = texture.height;
Color32[] pixels = texture.GetPixels32();
var temp = new Color32[width];
for (int y = 0; y < height / 2; y++)
{
int topRow = y * width;
int bottomRow = (height - 1 - y) * width;
Array.Copy(pixels, topRow, temp, 0, width);
Array.Copy(pixels, bottomRow, pixels, topRow, width);
Array.Copy(temp, 0, pixels, bottomRow, width);
}
texture.SetPixels32(pixels);
texture.Apply();
}
private static ScreenshotCaptureResult PrepareCaptureResult(string fileName, int superSize, bool ensureUniqueFileName, string folderOverride)
{
int size = Mathf.Max(1, superSize);
string resolvedName = BuildFileName(fileName);
string folder = ScreenshotUtility.ResolveFolderAbsolute(folderOverride);
Directory.CreateDirectory(folder);
string fullPath = Path.Combine(folder, resolvedName);
if (ensureUniqueFileName)
{
fullPath = EnsureUnique(fullPath);
}
string normalizedFullPath = fullPath.Replace('\\', '/');
string projectRoot = Path.GetFullPath(Path.Combine(Application.dataPath, "..")).Replace('\\', '/');
string normalizedRoot = projectRoot.EndsWith("/") ? projectRoot : projectRoot + "/";
string projectRelativePath = normalizedFullPath.StartsWith(normalizedRoot, StringComparison.OrdinalIgnoreCase)
? normalizedFullPath.Substring(normalizedRoot.Length)
: normalizedFullPath;
return new ScreenshotCaptureResult(normalizedFullPath, projectRelativePath, size, false);
}
private static string BuildFileName(string fileName)
{
string baseName = string.IsNullOrWhiteSpace(fileName)
? $"screenshot-{DateTime.Now:yyyyMMdd-HHmmss}.png"
: SanitizeFileName(fileName);
if (!baseName.EndsWith(".png", StringComparison.OrdinalIgnoreCase))
baseName += ".png";
return baseName;
}
private static int NormalizeSceneViewSuperSize(int superSize)
{
if (superSize > 1)
{
McpLog.Warn("[EditorWindowScreenshotUtility] Scene View capture ignores superSize and uses the displayed viewport resolution.");
return 1;
}
return Mathf.Max(1, superSize);
}
private static string SanitizeFileName(string fileName)
{
string trimmed = (fileName ?? string.Empty).Trim();
if (string.IsNullOrEmpty(trimmed))
return $"screenshot-{DateTime.Now:yyyyMMdd-HHmmss}.png";
string candidate = trimmed;
string normalizedSeparators = candidate.Replace('\\', '/');
if (Path.IsPathRooted(candidate) || normalizedSeparators.Contains("/") || normalizedSeparators.Contains(".."))
{
string[] pathParts = normalizedSeparators.Split(new[] { '/' }, StringSplitOptions.RemoveEmptyEntries);
candidate = pathParts.Length > 0 ? pathParts[pathParts.Length - 1] : string.Empty;
}
if (string.IsNullOrWhiteSpace(candidate) || candidate == "." || candidate == "..")
candidate = $"screenshot-{DateTime.Now:yyyyMMdd-HHmmss}.png";
char[] invalidChars = Path.GetInvalidFileNameChars();
foreach (char invalidChar in invalidChars)
{
candidate = candidate.Replace(invalidChar, '_');
}
string extension = Path.GetExtension(candidate);
string stem = Path.GetFileNameWithoutExtension(candidate);
extension = extension.TrimEnd(' ', '.');
stem = stem.TrimEnd(' ', '.');
if (WindowsReservedNames.Contains(stem))
{
candidate = $"_{stem}{extension}";
}
return candidate;
}
private static string EnsureUnique(string fullPath)
{
if (!File.Exists(fullPath))
return fullPath;
string directory = Path.GetDirectoryName(fullPath) ?? string.Empty;
string fileNameWithoutExtension = Path.GetFileNameWithoutExtension(fullPath);
string extension = Path.GetExtension(fullPath);
for (int i = 1; i < 10000; i++)
{
string candidate = Path.Combine(directory, $"{fileNameWithoutExtension}-{i}{extension}");
if (!File.Exists(candidate))
return candidate;
}
throw new IOException($"Could not generate a unique screenshot filename for '{fullPath}'.");
}
private static void DestroyTexture(Texture2D texture)
{
if (texture == null)
return;
UnityEngine.Object.DestroyImmediate(texture);
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: b73350febfd6534436726d19b4d270fd
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
+332
View File
@@ -0,0 +1,332 @@
using System;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Runtime.InteropServices;
using System.Text;
using MCPForUnity.Editor.Constants;
using UnityEditor;
namespace MCPForUnity.Editor.Helpers
{
internal static class ExecPath
{
private const string PrefClaude = EditorPrefKeys.ClaudeCliPathOverride;
// Resolve Claude CLI absolute path. Pref → env → common locations → PATH.
internal static string ResolveClaude()
{
try
{
string pref = EditorPrefs.GetString(PrefClaude, string.Empty);
if (!string.IsNullOrEmpty(pref) && File.Exists(pref)) return pref;
}
catch { }
string env = Environment.GetEnvironmentVariable("CLAUDE_CLI");
if (!string.IsNullOrEmpty(env) && File.Exists(env)) return env;
if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
{
string home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile) ?? string.Empty;
string[] candidates =
{
"/opt/homebrew/bin/claude",
"/usr/local/bin/claude",
Path.Combine(home, ".local", "bin", "claude"),
Path.Combine(home, ".claude", "local", "claude"), // `claude migrate-installer` location
};
foreach (string c in candidates) { if (File.Exists(c)) return c; }
// Try NVM-installed claude under ~/.nvm/versions/node/*/bin/claude
string nvmClaude = ResolveClaudeFromNvm(home);
if (!string.IsNullOrEmpty(nvmClaude)) return nvmClaude;
#if UNITY_EDITOR_OSX || UNITY_EDITOR_LINUX
return Which("claude", "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin");
#else
return null;
#endif
}
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
#if UNITY_EDITOR_WIN
// Common npm global locations
string appData = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData) ?? string.Empty;
string localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData) ?? string.Empty;
string home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile) ?? string.Empty;
string[] candidates =
{
// Native installer locations
Path.Combine(localAppData, "Programs", "claude", "claude.exe"),
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), "claude", "claude.exe"),
Path.Combine(home, ".local", "bin", "claude.exe"),
Path.Combine(home, ".claude", "local", "claude.exe"), // `claude migrate-installer` location
// npm global locations (.cmd preferred for non-interactive processes)
Path.Combine(appData, "npm", "claude.cmd"),
Path.Combine(localAppData, "npm", "claude.cmd"),
// Fall back to PowerShell shim if only .ps1 is present
Path.Combine(appData, "npm", "claude.ps1"),
Path.Combine(localAppData, "npm", "claude.ps1"),
};
foreach (string c in candidates) { if (File.Exists(c)) return c; }
string fromWhere = FindInPathWindows("claude.exe") ?? FindInPathWindows("claude.cmd") ?? FindInPathWindows("claude.ps1") ?? FindInPathWindows("claude");
if (!string.IsNullOrEmpty(fromWhere)) return fromWhere;
#endif
return null;
}
// Linux
{
string home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile) ?? string.Empty;
string[] candidates =
{
"/usr/local/bin/claude",
"/usr/bin/claude",
Path.Combine(home, ".local", "bin", "claude"),
Path.Combine(home, ".claude", "local", "claude"), // `claude migrate-installer` location
};
foreach (string c in candidates) { if (File.Exists(c)) return c; }
// Try NVM-installed claude under ~/.nvm/versions/node/*/bin/claude
string nvmClaude = ResolveClaudeFromNvm(home);
if (!string.IsNullOrEmpty(nvmClaude)) return nvmClaude;
#if UNITY_EDITOR_OSX || UNITY_EDITOR_LINUX
return Which("claude", "/usr/local/bin:/usr/bin:/bin");
#else
return null;
#endif
}
}
// Attempt to resolve claude from NVM-managed Node installations, choosing the newest version
private static string ResolveClaudeFromNvm(string home)
{
try
{
if (string.IsNullOrEmpty(home)) return null;
string nvmNodeDir = Path.Combine(home, ".nvm", "versions", "node");
if (!Directory.Exists(nvmNodeDir)) return null;
string bestPath = null;
Version bestVersion = null;
foreach (string versionDir in Directory.EnumerateDirectories(nvmNodeDir))
{
string name = Path.GetFileName(versionDir);
if (string.IsNullOrEmpty(name)) continue;
if (name.StartsWith("v", StringComparison.OrdinalIgnoreCase))
{
// Extract numeric portion: e.g., v18.19.0-nightly -> 18.19.0
string versionStr = name.Substring(1);
int dashIndex = versionStr.IndexOf('-');
if (dashIndex > 0)
{
versionStr = versionStr.Substring(0, dashIndex);
}
if (Version.TryParse(versionStr, out Version parsed))
{
string candidate = Path.Combine(versionDir, "bin", "claude");
if (File.Exists(candidate))
{
if (bestVersion == null || parsed > bestVersion)
{
bestVersion = parsed;
bestPath = candidate;
}
}
}
}
}
return bestPath;
}
catch { return null; }
}
// Explicitly set the Claude CLI absolute path override in EditorPrefs
internal static void SetClaudeCliPath(string absolutePath)
{
try
{
if (!string.IsNullOrEmpty(absolutePath) && File.Exists(absolutePath))
{
EditorPrefs.SetString(PrefClaude, absolutePath);
}
}
catch { }
}
// Clear any previously set Claude CLI override path
internal static void ClearClaudeCliPath()
{
try
{
if (EditorPrefs.HasKey(PrefClaude))
{
EditorPrefs.DeleteKey(PrefClaude);
}
}
catch { }
}
internal static bool TryRun(
string file,
string args,
string workingDir,
out string stdout,
out string stderr,
int timeoutMs = 15000,
string extraPathPrepend = null)
{
stdout = string.Empty;
stderr = string.Empty;
try
{
// Handle PowerShell scripts on Windows by invoking through powershell.exe
bool isPs1 = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) &&
file.EndsWith(".ps1", StringComparison.OrdinalIgnoreCase);
var psi = new ProcessStartInfo
{
FileName = isPs1 ? "powershell.exe" : file,
Arguments = isPs1
? $"-NoProfile -ExecutionPolicy Bypass -File \"{file}\" {args}".Trim()
: args,
WorkingDirectory = string.IsNullOrEmpty(workingDir) ? Environment.CurrentDirectory : workingDir,
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true,
};
if (!string.IsNullOrEmpty(extraPathPrepend))
{
string currentPath = Environment.GetEnvironmentVariable("PATH") ?? string.Empty;
psi.EnvironmentVariables["PATH"] = string.IsNullOrEmpty(currentPath)
? extraPathPrepend
: (extraPathPrepend + System.IO.Path.PathSeparator + currentPath);
}
using var process = new Process { StartInfo = psi, EnableRaisingEvents = false };
var sb = new StringBuilder();
var se = new StringBuilder();
process.OutputDataReceived += (_, e) => { if (e.Data != null) sb.AppendLine(e.Data); };
process.ErrorDataReceived += (_, e) => { if (e.Data != null) se.AppendLine(e.Data); };
if (!process.Start()) return false;
process.BeginOutputReadLine();
process.BeginErrorReadLine();
if (!process.WaitForExit(timeoutMs))
{
try { process.Kill(); } catch { }
return false;
}
// Ensure async buffers are flushed
process.WaitForExit();
stdout = sb.ToString();
stderr = se.ToString();
return process.ExitCode == 0;
}
catch
{
return false;
}
}
/// <summary>
/// Cross-platform path lookup. Uses 'where' on Windows, 'which' on macOS/Linux.
/// Returns the full path if found, null otherwise.
/// </summary>
internal static string FindInPath(string executable, string extraPathPrepend = null)
{
#if UNITY_EDITOR_WIN
return FindInPathWindows(executable, extraPathPrepend);
#elif UNITY_EDITOR_OSX || UNITY_EDITOR_LINUX
return Which(executable, extraPathPrepend ?? string.Empty);
#else
return null;
#endif
}
#if UNITY_EDITOR_OSX || UNITY_EDITOR_LINUX
private static string Which(string exe, string prependPath)
{
try
{
var psi = new ProcessStartInfo("/usr/bin/which", exe)
{
UseShellExecute = false,
RedirectStandardOutput = true,
CreateNoWindow = true,
};
string path = Environment.GetEnvironmentVariable("PATH") ?? string.Empty;
psi.EnvironmentVariables["PATH"] = string.IsNullOrEmpty(path) ? prependPath : (prependPath + Path.PathSeparator + path);
using var p = Process.Start(psi);
if (p == null) return null;
var so = new StringBuilder();
p.OutputDataReceived += (_, e) => { if (e.Data != null) so.AppendLine(e.Data); };
p.BeginOutputReadLine();
if (!p.WaitForExit(1500))
{
try { p.Kill(); } catch { }
return null;
}
p.WaitForExit();
string output = so.ToString().Trim();
return (!string.IsNullOrEmpty(output) && File.Exists(output)) ? output : null;
}
catch { return null; }
}
#endif
#if UNITY_EDITOR_WIN
internal static string FindInPathWindows(string exe, string extraPathPrepend = null)
{
try
{
string currentPath = Environment.GetEnvironmentVariable("PATH") ?? string.Empty;
string effectivePath = string.IsNullOrEmpty(extraPathPrepend)
? currentPath
: (string.IsNullOrEmpty(currentPath) ? extraPathPrepend : extraPathPrepend + Path.PathSeparator + currentPath);
var psi = new ProcessStartInfo("where", exe)
{
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true,
};
if (!string.IsNullOrEmpty(effectivePath))
{
psi.EnvironmentVariables["PATH"] = effectivePath;
}
using var p = Process.Start(psi);
if (p == null) return null;
var so = new StringBuilder();
p.OutputDataReceived += (_, e) => { if (e.Data != null) so.AppendLine(e.Data); };
p.BeginOutputReadLine();
if (!p.WaitForExit(1500))
{
try { p.Kill(); } catch { }
return null;
}
p.WaitForExit();
string first = so.ToString()
.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries)
.FirstOrDefault();
return (!string.IsNullOrEmpty(first) && File.Exists(first)) ? first : null;
}
catch { return null; }
}
#endif
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 8f2b7b3e9c3e4a0f9b2a1d4c7e6f5a12
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,375 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Newtonsoft.Json.Linq;
using UnityEditor;
using UnityEditor.SceneManagement;
using UnityEngine;
using UnityEngine.SceneManagement;
using MCPForUnity.Runtime.Helpers;
namespace MCPForUnity.Editor.Helpers
{
/// <summary>
/// Utility class for finding and looking up GameObjects in the scene.
/// Provides search functionality by name, tag, layer, component, path, and instance ID.
/// </summary>
public static class GameObjectLookup
{
/// <summary>
/// Supported search methods for finding GameObjects.
/// </summary>
public enum SearchMethod
{
ByName,
ByTag,
ByLayer,
ByComponent,
ByPath,
ById
}
/// <summary>
/// Parses a search method string into the enum value.
/// </summary>
public static SearchMethod ParseSearchMethod(string method)
{
if (string.IsNullOrEmpty(method))
return SearchMethod.ByName;
return method.ToLowerInvariant() switch
{
"by_name" => SearchMethod.ByName,
"by_tag" => SearchMethod.ByTag,
"by_layer" => SearchMethod.ByLayer,
"by_component" => SearchMethod.ByComponent,
"by_path" => SearchMethod.ByPath,
"by_id" => SearchMethod.ById,
_ => SearchMethod.ByName
};
}
/// <summary>
/// Finds a single GameObject based on the target and search method.
/// </summary>
/// <param name="target">The target identifier (name, ID, path, etc.)</param>
/// <param name="searchMethod">The search method to use</param>
/// <param name="includeInactive">Whether to include inactive objects</param>
/// <returns>The found GameObject or null</returns>
public static GameObject FindByTarget(JToken target, string searchMethod, bool includeInactive = false)
{
if (target == null)
return null;
var results = SearchGameObjects(searchMethod, target.ToString(), includeInactive, 1);
return results.Count > 0 ? FindById(results[0]) : null;
}
/// <summary>
/// Resolves an instance ID to a UnityEngine.Object.
/// </summary>
public static UnityEngine.Object ResolveInstanceID(int instanceId)
{
return UnityObjectIdCompat.InstanceIDToObjectCompat(instanceId);
}
/// <summary>
/// Finds a GameObject by its instance ID.
/// </summary>
public static GameObject FindById(int instanceId)
{
return ResolveInstanceID(instanceId) as GameObject;
}
/// <summary>
/// Searches for GameObjects and returns their instance IDs.
/// </summary>
/// <param name="searchMethod">The search method string (by_name, by_tag, etc.)</param>
/// <param name="searchTerm">The term to search for</param>
/// <param name="includeInactive">Whether to include inactive objects</param>
/// <param name="maxResults">Maximum number of results to return (0 = unlimited)</param>
/// <returns>List of instance IDs</returns>
public static List<int> SearchGameObjects(string searchMethod, string searchTerm, bool includeInactive = false, int maxResults = 0)
{
var method = ParseSearchMethod(searchMethod);
return SearchGameObjects(method, searchTerm, includeInactive, maxResults);
}
/// <summary>
/// Searches for GameObjects and returns their instance IDs.
/// </summary>
/// <param name="method">The search method</param>
/// <param name="searchTerm">The term to search for</param>
/// <param name="includeInactive">Whether to include inactive objects</param>
/// <param name="maxResults">Maximum number of results to return (0 = unlimited)</param>
/// <returns>List of instance IDs</returns>
public static List<int> SearchGameObjects(SearchMethod method, string searchTerm, bool includeInactive = false, int maxResults = 0)
{
var results = new List<int>();
switch (method)
{
case SearchMethod.ById:
if (int.TryParse(searchTerm, out int instanceId))
{
var obj = ResolveInstanceID(instanceId) as GameObject;
if (obj != null && (includeInactive || obj.activeInHierarchy))
{
results.Add(instanceId);
}
}
break;
case SearchMethod.ByName:
results.AddRange(SearchByName(searchTerm, includeInactive, maxResults));
break;
case SearchMethod.ByPath:
results.AddRange(SearchByPath(searchTerm, includeInactive));
break;
case SearchMethod.ByTag:
results.AddRange(SearchByTag(searchTerm, includeInactive, maxResults));
break;
case SearchMethod.ByLayer:
results.AddRange(SearchByLayer(searchTerm, includeInactive, maxResults));
break;
case SearchMethod.ByComponent:
results.AddRange(SearchByComponent(searchTerm, includeInactive, maxResults));
break;
}
return results;
}
private static IEnumerable<int> SearchByName(string name, bool includeInactive, int maxResults)
{
var allObjects = GetAllSceneObjects(includeInactive);
var matching = allObjects.Where(go => go.name == name);
if (maxResults > 0)
matching = matching.Take(maxResults);
return matching.Select(go => go.GetInstanceIDCompat());
}
private static IEnumerable<int> SearchByPath(string path, bool includeInactive)
{
// Check Prefab Stage first - GameObject.Find() doesn't work in Prefab Stage
var prefabStage = PrefabStageUtility.GetCurrentPrefabStage();
if (prefabStage != null)
{
// Use GetAllSceneObjects which already handles Prefab Stage
var allObjects = GetAllSceneObjects(includeInactive);
foreach (var go in allObjects)
{
if (MatchesPath(go, path))
{
yield return go.GetInstanceIDCompat();
}
}
yield break;
}
// Normal scene mode
// NOTE: Unity's GameObject.Find(path) only finds ACTIVE GameObjects.
// If includeInactive=true, we need to search manually to find inactive objects.
if (includeInactive)
{
// Search manually to support inactive objects
var allObjects = GetAllSceneObjects(true);
foreach (var go in allObjects)
{
if (MatchesPath(go, path))
{
yield return go.GetInstanceIDCompat();
}
}
}
else
{
// Use GameObject.Find for active objects only (Unity API limitation)
var found = GameObject.Find(path);
if (found != null)
{
yield return found.GetInstanceIDCompat();
}
}
}
private static IEnumerable<int> SearchByTag(string tag, bool includeInactive, int maxResults)
{
GameObject[] taggedObjects;
try
{
if (includeInactive)
{
// FindGameObjectsWithTag doesn't find inactive, so we need to iterate all
var allObjects = GetAllSceneObjects(true);
taggedObjects = allObjects.Where(go => go.CompareTag(tag)).ToArray();
}
else
{
taggedObjects = GameObject.FindGameObjectsWithTag(tag);
}
}
catch (UnityException)
{
// Tag doesn't exist
yield break;
}
var results = taggedObjects.AsEnumerable();
if (maxResults > 0)
results = results.Take(maxResults);
foreach (var go in results)
{
yield return go.GetInstanceIDCompat();
}
}
private static IEnumerable<int> SearchByLayer(string layerName, bool includeInactive, int maxResults)
{
int layer = LayerMask.NameToLayer(layerName);
if (layer == -1)
{
// Try parsing as layer number
if (!int.TryParse(layerName, out layer) || layer < 0 || layer > 31)
{
yield break;
}
}
var allObjects = GetAllSceneObjects(includeInactive);
var matching = allObjects.Where(go => go.layer == layer);
if (maxResults > 0)
matching = matching.Take(maxResults);
foreach (var go in matching)
{
yield return go.GetInstanceIDCompat();
}
}
private static IEnumerable<int> SearchByComponent(string componentTypeName, bool includeInactive, int maxResults)
{
Type componentType = FindComponentType(componentTypeName);
if (componentType == null)
{
McpLog.Warn($"[GameObjectLookup] Component type '{componentTypeName}' not found.");
yield break;
}
var allObjects = GetAllSceneObjects(includeInactive);
var count = 0;
foreach (var go in allObjects)
{
if (go.GetComponent(componentType) != null)
{
yield return go.GetInstanceIDCompat();
count++;
if (maxResults > 0 && count >= maxResults)
yield break;
}
}
}
/// <summary>
/// Gets all GameObjects in the current scene.
/// </summary>
public static IEnumerable<GameObject> GetAllSceneObjects(bool includeInactive)
{
// Check Prefab Stage first
var prefabStage = PrefabStageUtility.GetCurrentPrefabStage();
if (prefabStage != null && prefabStage.prefabContentsRoot != null)
{
// Use Prefab Stage's prefabContentsRoot
foreach (var go in GetObjectAndDescendants(prefabStage.prefabContentsRoot, includeInactive))
{
yield return go;
}
yield break;
}
// Normal scene mode
var scene = SceneManager.GetActiveScene();
if (!scene.IsValid())
yield break;
var rootObjects = scene.GetRootGameObjects();
foreach (var root in rootObjects)
{
foreach (var go in GetObjectAndDescendants(root, includeInactive))
{
yield return go;
}
}
}
private static IEnumerable<GameObject> GetObjectAndDescendants(GameObject obj, bool includeInactive)
{
if (!includeInactive && !obj.activeInHierarchy)
yield break;
yield return obj;
foreach (Transform child in obj.transform)
{
foreach (var descendant in GetObjectAndDescendants(child.gameObject, includeInactive))
{
yield return descendant;
}
}
}
/// <summary>
/// Finds a component type by name, searching loaded assemblies.
/// </summary>
/// <remarks>
/// Delegates to UnityTypeResolver.ResolveComponent() for unified type resolution.
/// </remarks>
public static Type FindComponentType(string typeName)
{
return UnityTypeResolver.ResolveComponent(typeName);
}
/// <summary>
/// Checks whether a GameObject matches a path or trailing path segment.
/// </summary>
internal static bool MatchesPath(GameObject go, string path)
{
if (go == null || string.IsNullOrEmpty(path))
return false;
var goPath = GetGameObjectPath(go);
return goPath == path || goPath.EndsWith("/" + path);
}
/// <summary>
/// Gets the hierarchical path of a GameObject.
/// </summary>
public static string GetGameObjectPath(GameObject obj)
{
if (obj == null)
return string.Empty;
var path = obj.name;
var parent = obj.transform.parent;
while (parent != null)
{
path = parent.name + "/" + path;
parent = parent.parent;
}
return path;
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 4964205faa8dd4f8a960e58fd8c0d4f7
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,747 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using MCPForUnity.Runtime.Serialization; // For Converters
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using UnityEditor;
using UnityEngine;
using MCPForUnity.Runtime.Helpers;
namespace MCPForUnity.Editor.Helpers
{
/// <summary>
/// Handles serialization of GameObjects and Components for MCP responses.
/// Includes reflection helpers and caching for performance.
/// </summary>
public static class GameObjectSerializer
{
// --- Data Serialization ---
/// <summary>
/// Creates a serializable representation of a GameObject.
/// </summary>
public static object GetGameObjectData(GameObject go)
{
if (go == null)
return null;
return new
{
name = go.name,
instanceID = go.GetInstanceIDCompat(),
tag = go.tag,
layer = go.layer,
activeSelf = go.activeSelf,
activeInHierarchy = go.activeInHierarchy,
isStatic = go.isStatic,
scenePath = go.scene.path, // Identify which scene it belongs to
transform = new // Serialize transform components carefully to avoid JSON issues
{
// Serialize Vector3 components individually to prevent self-referencing loops.
// The default serializer can struggle with properties like Vector3.normalized.
position = new
{
x = go.transform.position.x,
y = go.transform.position.y,
z = go.transform.position.z,
},
localPosition = new
{
x = go.transform.localPosition.x,
y = go.transform.localPosition.y,
z = go.transform.localPosition.z,
},
rotation = new
{
x = go.transform.rotation.eulerAngles.x,
y = go.transform.rotation.eulerAngles.y,
z = go.transform.rotation.eulerAngles.z,
},
localRotation = new
{
x = go.transform.localRotation.eulerAngles.x,
y = go.transform.localRotation.eulerAngles.y,
z = go.transform.localRotation.eulerAngles.z,
},
scale = new
{
x = go.transform.localScale.x,
y = go.transform.localScale.y,
z = go.transform.localScale.z,
},
forward = new
{
x = go.transform.forward.x,
y = go.transform.forward.y,
z = go.transform.forward.z,
},
up = new
{
x = go.transform.up.x,
y = go.transform.up.y,
z = go.transform.up.z,
},
right = new
{
x = go.transform.right.x,
y = go.transform.right.y,
z = go.transform.right.z,
},
},
parentInstanceID = go.transform.parent?.gameObject.GetInstanceIDCompat() ?? 0, // 0 if no parent
// Optionally include components, but can be large
// components = go.GetComponents<Component>().Select(c => GetComponentData(c)).ToList()
// Or just component names:
componentNames = go.GetComponents<Component>()
.Select(c => c.GetType().FullName)
.ToList(),
};
}
// --- Metadata Caching for Reflection ---
private class CachedMetadata
{
public readonly List<PropertyInfo> SerializableProperties;
public readonly List<FieldInfo> SerializableFields;
public CachedMetadata(List<PropertyInfo> properties, List<FieldInfo> fields)
{
SerializableProperties = properties;
SerializableFields = fields;
}
}
// Key becomes Tuple<Type, bool>
private static readonly Dictionary<Tuple<Type, bool>, CachedMetadata> _metadataCache = new Dictionary<Tuple<Type, bool>, CachedMetadata>();
// --- End Metadata Caching ---
/// <summary>
/// Checks if a type is or derives from a type with the specified full name.
/// Used to detect special-case components including their subclasses.
/// </summary>
private static bool IsOrDerivedFrom(Type type, string baseTypeFullName)
{
Type current = type;
while (current != null)
{
if (current.FullName == baseTypeFullName)
return true;
current = current.BaseType;
}
return false;
}
// Type full names that are known to crash the Editor when accessed via reflection.
// Photon Fusion uses IL weaving to inject fields with these types into NetworkBehaviour
// subclasses. They contain native/unmanaged memory and cannot be safely serialized.
private static readonly HashSet<string> _crashingTypeNames = new HashSet<string>
{
"Fusion.NetworkBehaviourBuffer",
"Fusion.NetworkBehaviourCallbackBuffer",
"Fusion.Networked+Internals",
"Fusion.Changed`1",
};
private static readonly PropertyInfo _isByRefLikeProperty = typeof(Type).GetProperty("IsByRefLike");
/// <summary>
/// Checks if a type is unsafe to access via reflection or serialize.
/// Returns true for ref structs (Span, ReadOnlySpan), pointer types,
/// by-ref types, and known IL-weaved types that crash the Editor.
/// </summary>
private static bool IsUnsafeType(Type type)
{
return IsUnsafeType(type, new HashSet<Type>());
}
private static bool IsUnsafeType(Type type, HashSet<Type> visitedTypes)
{
if (type == null) return false;
if (!visitedTypes.Add(type)) return false;
// Pointer and by-ref types cannot be serialized
if (type.IsPointer || type.IsByRef)
return true;
// Ref structs (Span<>, ReadOnlySpan<>, etc.) cannot be boxed. Use reflection
// so Unity versions without Type.IsByRefLike still compile.
if (type.IsValueType && _isByRefLikeProperty != null && (bool)_isByRefLikeProperty.GetValue(type, null))
return true;
// Check the type and its generic definition against the blacklist
string fullName = type.FullName;
if (fullName != null && _crashingTypeNames.Contains(fullName))
return true;
if (type.IsGenericType)
{
string genericFullName = type.GetGenericTypeDefinition()?.FullName;
if (genericFullName != null && _crashingTypeNames.Contains(genericFullName))
return true;
}
// Catch-all for Fusion buffer types injected by IL weaving
if (fullName != null && fullName.StartsWith("Fusion.") && fullName.Contains("Buffer"))
return true;
// Arrays and generic containers can wrap unsafe Fusion/ref-like types.
// Newtonsoft.Json would still recurse into those values during serialization.
Type elementType = type.GetElementType();
if (elementType != null && IsUnsafeType(elementType, visitedTypes))
return true;
foreach (Type genericArgument in type.GetGenericArguments())
{
if (IsUnsafeType(genericArgument, visitedTypes))
return true;
}
return false;
}
/// <summary>
/// Serializes a UnityEngine.Object reference to a dictionary with name, instanceID, and assetPath.
/// Used for consistent serialization of asset references in special-case component handlers.
/// </summary>
/// <param name="obj">The Unity object to serialize</param>
/// <param name="includeAssetPath">Whether to include the asset path (default true)</param>
/// <returns>A dictionary with the object's reference info, or null if obj is null</returns>
private static Dictionary<string, object> SerializeAssetReference(UnityEngine.Object obj, bool includeAssetPath = true)
{
if (obj == null) return null;
var result = new Dictionary<string, object>
{
{ "name", obj.name },
{ "instanceID", obj.GetInstanceIDCompat() }
};
if (includeAssetPath)
{
var assetPath = AssetDatabase.GetAssetPath(obj);
result["assetPath"] = string.IsNullOrEmpty(assetPath) ? null : assetPath;
}
return result;
}
/// <summary>
/// Creates a serializable representation of a Component, attempting to serialize
/// public properties and fields using reflection, with caching and control over non-public fields.
/// </summary>
// Add the flag parameter here
public static object GetComponentData(Component c, bool includeNonPublicSerializedFields = true)
{
// --- Add Early Logging ---
// McpLog.Info($"[GetComponentData] Starting for component: {c?.GetType()?.FullName ?? "null"} (ID: {c?.GetInstanceIDCompat() ?? 0})");
// --- End Early Logging ---
if (c == null) return null;
Type componentType = c.GetType();
// --- Special handling for Transform to avoid reflection crashes and problematic properties ---
if (componentType == typeof(Transform))
{
Transform tr = c as Transform;
// McpLog.Info($"[GetComponentData] Manually serializing Transform (ID: {tr.GetInstanceIDCompat()})");
return new Dictionary<string, object>
{
{ "typeName", componentType.FullName },
{ "instanceID", tr.GetInstanceIDCompat() },
// Manually extract known-safe properties. Avoid Quaternion 'rotation' and 'lossyScale'.
{ "position", CreateTokenFromValue(tr.position, typeof(Vector3))?.ToObject<object>() ?? new JObject() },
{ "localPosition", CreateTokenFromValue(tr.localPosition, typeof(Vector3))?.ToObject<object>() ?? new JObject() },
{ "eulerAngles", CreateTokenFromValue(tr.eulerAngles, typeof(Vector3))?.ToObject<object>() ?? new JObject() }, // Use Euler angles
{ "localEulerAngles", CreateTokenFromValue(tr.localEulerAngles, typeof(Vector3))?.ToObject<object>() ?? new JObject() },
{ "localScale", CreateTokenFromValue(tr.localScale, typeof(Vector3))?.ToObject<object>() ?? new JObject() },
{ "right", CreateTokenFromValue(tr.right, typeof(Vector3))?.ToObject<object>() ?? new JObject() },
{ "up", CreateTokenFromValue(tr.up, typeof(Vector3))?.ToObject<object>() ?? new JObject() },
{ "forward", CreateTokenFromValue(tr.forward, typeof(Vector3))?.ToObject<object>() ?? new JObject() },
{ "parentInstanceID", tr.parent?.gameObject.GetInstanceIDCompat() ?? 0 },
{ "rootInstanceID", tr.root?.gameObject.GetInstanceIDCompat() ?? 0 },
{ "childCount", tr.childCount },
// Include standard Object/Component properties
{ "name", tr.name },
{ "tag", tr.tag },
{ "gameObjectInstanceID", tr.gameObject?.GetInstanceIDCompat() ?? 0 }
};
}
// --- End Special handling for Transform ---
// --- Special handling for Camera to avoid matrix-related crashes ---
if (componentType == typeof(Camera))
{
Camera cam = c as Camera;
var cameraProperties = new Dictionary<string, object>();
// List of safe properties to serialize
var safeProperties = new Dictionary<string, Func<object>>
{
{ "nearClipPlane", () => cam.nearClipPlane },
{ "farClipPlane", () => cam.farClipPlane },
{ "fieldOfView", () => cam.fieldOfView },
{ "renderingPath", () => (int)cam.renderingPath },
{ "actualRenderingPath", () => (int)cam.actualRenderingPath },
{ "allowHDR", () => cam.allowHDR },
{ "allowMSAA", () => cam.allowMSAA },
{ "allowDynamicResolution", () => cam.allowDynamicResolution },
{ "forceIntoRenderTexture", () => cam.forceIntoRenderTexture },
{ "orthographicSize", () => cam.orthographicSize },
{ "orthographic", () => cam.orthographic },
{ "opaqueSortMode", () => (int)cam.opaqueSortMode },
{ "transparencySortMode", () => (int)cam.transparencySortMode },
{ "depth", () => cam.depth },
{ "aspect", () => cam.aspect },
{ "cullingMask", () => cam.cullingMask },
{ "eventMask", () => cam.eventMask },
{ "backgroundColor", () => cam.backgroundColor },
{ "clearFlags", () => (int)cam.clearFlags },
{ "stereoEnabled", () => cam.stereoEnabled },
{ "stereoSeparation", () => cam.stereoSeparation },
{ "stereoConvergence", () => cam.stereoConvergence },
{ "enabled", () => cam.enabled },
{ "name", () => cam.name },
{ "tag", () => cam.tag },
{ "gameObject", () => new { name = cam.gameObject.name, instanceID = cam.gameObject.GetInstanceIDCompat() } }
};
foreach (var prop in safeProperties)
{
try
{
var value = prop.Value();
if (value != null)
{
AddSerializableValue(cameraProperties, prop.Key, value.GetType(), value);
}
}
catch (Exception)
{
// Silently skip any property that fails
continue;
}
}
return new Dictionary<string, object>
{
{ "typeName", componentType.FullName },
{ "instanceID", cam.GetInstanceIDCompat() },
{ "properties", cameraProperties }
};
}
// --- End Special handling for Camera ---
// --- Special handling for UIDocument to avoid infinite loops from VisualElement hierarchy (Issue #585) ---
// UIDocument.rootVisualElement contains circular parent/child references that cause infinite serialization loops.
// Use IsOrDerivedFrom to also catch subclasses of UIDocument.
if (IsOrDerivedFrom(componentType, "UnityEngine.UIElements.UIDocument"))
{
var uiDocProperties = new Dictionary<string, object>();
try
{
// Get panelSettings reference safely
var panelSettingsProp = componentType.GetProperty("panelSettings");
if (panelSettingsProp != null)
{
var panelSettings = panelSettingsProp.GetValue(c) as UnityEngine.Object;
uiDocProperties["panelSettings"] = SerializeAssetReference(panelSettings);
}
// Get visualTreeAsset reference safely (the UXML file)
var visualTreeAssetProp = componentType.GetProperty("visualTreeAsset");
if (visualTreeAssetProp != null)
{
var visualTreeAsset = visualTreeAssetProp.GetValue(c) as UnityEngine.Object;
uiDocProperties["visualTreeAsset"] = SerializeAssetReference(visualTreeAsset);
}
// Get sortingOrder safely
var sortingOrderProp = componentType.GetProperty("sortingOrder");
if (sortingOrderProp != null)
{
uiDocProperties["sortingOrder"] = sortingOrderProp.GetValue(c);
}
// Get enabled state (from Behaviour base class)
var enabledProp = componentType.GetProperty("enabled");
if (enabledProp != null)
{
uiDocProperties["enabled"] = enabledProp.GetValue(c);
}
// Get parentUI reference safely (no asset path needed - it's a scene reference)
var parentUIProp = componentType.GetProperty("parentUI");
if (parentUIProp != null)
{
var parentUI = parentUIProp.GetValue(c) as UnityEngine.Object;
uiDocProperties["parentUI"] = SerializeAssetReference(parentUI, includeAssetPath: false);
}
// NOTE: rootVisualElement is intentionally skipped - it contains circular
// parent/child references that cause infinite serialization loops
uiDocProperties["_note"] = "rootVisualElement skipped to prevent circular reference loops";
}
catch (Exception e)
{
McpLog.Warn($"[GetComponentData] Error reading UIDocument properties: {e.Message}");
}
// Return structure matches Camera special handling (typeName, instanceID, properties)
return new Dictionary<string, object>
{
{ "typeName", componentType.FullName },
{ "instanceID", c.GetInstanceIDCompat() },
{ "properties", uiDocProperties }
};
}
// --- End Special handling for UIDocument ---
var data = new Dictionary<string, object>
{
{ "typeName", componentType.FullName },
{ "instanceID", c.GetInstanceIDCompat() }
};
// --- Get Cached or Generate Metadata (using new cache key) ---
Tuple<Type, bool> cacheKey = new Tuple<Type, bool>(componentType, includeNonPublicSerializedFields);
if (!_metadataCache.TryGetValue(cacheKey, out CachedMetadata cachedData))
{
var propertiesToCache = new List<PropertyInfo>();
var fieldsToCache = new List<FieldInfo>();
// Traverse the hierarchy from the component type up to MonoBehaviour
Type currentType = componentType;
while (currentType != null && currentType != typeof(MonoBehaviour) && currentType != typeof(object))
{
// Get properties declared only at the current type level
BindingFlags propFlags = BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly;
foreach (var propInfo in currentType.GetProperties(propFlags))
{
// Basic filtering (readable, not indexer, not transform which is handled elsewhere)
if (!propInfo.CanRead || propInfo.GetIndexParameters().Length > 0 || propInfo.Name == "transform") continue;
// Skip properties whose return type would crash when accessed via reflection
// (e.g. Fusion IL-weaved types, Span<>, ReadOnlySpan<>, pointers)
if (IsUnsafeType(propInfo.PropertyType)) continue;
// Add if not already added (handles overrides - keep the most derived version)
if (!propertiesToCache.Any(p => p.Name == propInfo.Name))
{
propertiesToCache.Add(propInfo);
}
}
// Get fields declared only at the current type level (both public and non-public)
BindingFlags fieldFlags = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly;
var declaredFields = currentType.GetFields(fieldFlags);
// Process the declared Fields for caching
foreach (var fieldInfo in declaredFields)
{
if (fieldInfo.Name.EndsWith("k__BackingField")) continue; // Skip backing fields
// Skip fields whose type would crash when accessed via reflection
// (e.g. Fusion IL-weaved types, Span<>, ReadOnlySpan<>, pointers)
if (IsUnsafeType(fieldInfo.FieldType)) continue;
// Add if not already added (handles hiding - keep the most derived version)
if (fieldsToCache.Any(f => f.Name == fieldInfo.Name)) continue;
bool shouldInclude = false;
if (includeNonPublicSerializedFields)
{
// If TRUE, include Public OR any NonPublic with [SerializeField] (private/protected/internal)
var hasSerializeField = fieldInfo.IsDefined(typeof(SerializeField), inherit: true);
shouldInclude = fieldInfo.IsPublic || (!fieldInfo.IsPublic && hasSerializeField);
}
else // includeNonPublicSerializedFields is FALSE
{
// If FALSE, include ONLY if it is explicitly Public.
shouldInclude = fieldInfo.IsPublic;
}
if (shouldInclude)
{
fieldsToCache.Add(fieldInfo);
}
}
// Move to the base type
currentType = currentType.BaseType;
}
// --- End Hierarchy Traversal ---
cachedData = new CachedMetadata(propertiesToCache, fieldsToCache);
_metadataCache[cacheKey] = cachedData; // Add to cache with combined key
}
// --- End Get Cached or Generate Metadata ---
// --- Use cached metadata ---
var serializablePropertiesOutput = new Dictionary<string, object>();
// --- Add Logging Before Property Loop ---
// McpLog.Info($"[GetComponentData] Starting property loop for {componentType.Name}...");
// --- End Logging Before Property Loop ---
// Use cached properties
foreach (var propInfo in cachedData.SerializableProperties)
{
string propName = propInfo.Name;
// --- Skip known obsolete/problematic Component shortcut properties ---
bool skipProperty = false;
if (propName == "rigidbody" || propName == "rigidbody2D" || propName == "camera" ||
propName == "light" || propName == "animation" || propName == "constantForce" ||
propName == "renderer" || propName == "audio" || propName == "networkView" ||
propName == "collider" || propName == "collider2D" || propName == "hingeJoint" ||
propName == "particleSystem" ||
// Also skip potentially problematic Matrix properties prone to cycles/errors
propName == "worldToLocalMatrix" || propName == "localToWorldMatrix")
{
// McpLog.Info($"[GetComponentData] Explicitly skipping generic property: {propName}"); // Optional log
skipProperty = true;
}
// --- End Skip Generic Properties ---
// --- Skip specific potentially problematic Camera properties ---
if (componentType == typeof(Camera) &&
(propName == "pixelRect" ||
propName == "rect" ||
propName == "cullingMatrix" ||
propName == "useOcclusionCulling" ||
propName == "worldToCameraMatrix" ||
propName == "projectionMatrix" ||
propName == "nonJitteredProjectionMatrix" ||
propName == "previousViewProjectionMatrix" ||
propName == "cameraToWorldMatrix"))
{
// McpLog.Info($"[GetComponentData] Explicitly skipping Camera property: {propName}");
skipProperty = true;
}
// --- End Skip Camera Properties ---
// --- Skip specific potentially problematic Transform properties ---
if (componentType == typeof(Transform) &&
(propName == "lossyScale" ||
propName == "rotation" ||
propName == "worldToLocalMatrix" ||
propName == "localToWorldMatrix"))
{
skipProperty = true;
}
// --- End Skip Transform Properties ---
// --- Skip Collider properties that cause native crashes via PhysX ---
if (typeof(Collider).IsAssignableFrom(componentType) &&
propName == "GeometryHolder")
{
skipProperty = true;
}
// --- End Skip Collider Properties ---
// Skip if flagged
if (skipProperty)
{
continue;
}
try
{
// --- Add detailed logging ---
// McpLog.Info($"[GetComponentData] Accessing: {componentType.Name}.{propName}");
// --- End detailed logging ---
// --- Special handling for material/mesh properties in edit mode ---
object value;
if (!Application.isPlaying && (propName == "material" || propName == "materials" || propName == "mesh"))
{
// In edit mode, use sharedMaterial/sharedMesh to avoid instantiation warnings
if ((propName == "material" || propName == "materials") && c is Renderer renderer)
{
if (propName == "material")
value = renderer.sharedMaterial;
else // materials
value = renderer.sharedMaterials;
}
else if (propName == "mesh" && c is MeshFilter meshFilter)
{
value = meshFilter.sharedMesh;
}
else
{
// Fallback to normal property access if type doesn't match
value = propInfo.GetValue(c);
}
}
else
{
value = propInfo.GetValue(c);
}
// --- End special handling ---
Type propType = propInfo.PropertyType;
AddSerializableValue(serializablePropertiesOutput, propName, propType, value);
}
catch (Exception)
{
// McpLog.Warn($"Could not read property {propName} on {componentType.Name}");
}
}
// --- Add Logging Before Field Loop ---
// McpLog.Info($"[GetComponentData] Starting field loop for {componentType.Name}...");
// --- End Logging Before Field Loop ---
// Use cached fields
foreach (var fieldInfo in cachedData.SerializableFields)
{
try
{
// --- Add detailed logging for fields ---
// McpLog.Info($"[GetComponentData] Accessing Field: {componentType.Name}.{fieldInfo.Name}");
// --- End detailed logging for fields ---
object value = fieldInfo.GetValue(c);
string fieldName = fieldInfo.Name;
Type fieldType = fieldInfo.FieldType;
AddSerializableValue(serializablePropertiesOutput, fieldName, fieldType, value);
}
catch (Exception)
{
// McpLog.Warn($"Could not read field {fieldInfo.Name} on {componentType.Name}");
}
}
// --- End Use cached metadata ---
if (serializablePropertiesOutput.Count > 0)
{
data["properties"] = serializablePropertiesOutput;
}
return data;
}
// Helper function to decide how to serialize different types
private static void AddSerializableValue(Dictionary<string, object> dict, string name, Type type, object value)
{
// Simplified: Directly use CreateTokenFromValue which uses the serializer
if (value == null)
{
dict[name] = null;
return;
}
try
{
// Use the helper that employs our custom serializer settings
JToken token = CreateTokenFromValue(value, type);
if (token != null) // Check if serialization succeeded in the helper
{
// Convert JToken back to a basic object structure for the dictionary
dict[name] = ConvertJTokenToPlainObject(token);
}
// If token is null, it means serialization failed and a warning was logged.
}
catch (Exception e)
{
// Catch potential errors during JToken conversion or addition to dictionary
McpLog.Warn($"[AddSerializableValue] Error processing value for '{name}' (Type: {type.FullName}): {e.Message}. Skipping.");
}
}
// Helper to convert JToken back to basic object structure
private static object ConvertJTokenToPlainObject(JToken token)
{
if (token == null) return null;
switch (token.Type)
{
case JTokenType.Object:
var objDict = new Dictionary<string, object>();
foreach (var prop in ((JObject)token).Properties())
{
objDict[prop.Name] = ConvertJTokenToPlainObject(prop.Value);
}
return objDict;
case JTokenType.Array:
var list = new List<object>();
foreach (var item in (JArray)token)
{
list.Add(ConvertJTokenToPlainObject(item));
}
return list;
case JTokenType.Integer:
return token.ToObject<long>(); // Use long for safety
case JTokenType.Float:
return token.ToObject<double>(); // Use double for safety
case JTokenType.String:
return token.ToObject<string>();
case JTokenType.Boolean:
return token.ToObject<bool>();
case JTokenType.Date:
return token.ToObject<DateTime>();
case JTokenType.Guid:
return token.ToObject<Guid>();
case JTokenType.Uri:
return token.ToObject<Uri>();
case JTokenType.TimeSpan:
return token.ToObject<TimeSpan>();
case JTokenType.Bytes:
return token.ToObject<byte[]>();
case JTokenType.Null:
return null;
case JTokenType.Undefined:
return null; // Treat undefined as null
default:
// Fallback for simple value types not explicitly listed
if (token is JValue jValue && jValue.Value != null)
{
return jValue.Value;
}
// McpLog.Warn($"Unsupported JTokenType encountered: {token.Type}. Returning null.");
return null;
}
}
// --- Define custom JsonSerializerSettings for OUTPUT ---
private static readonly JsonSerializerSettings _outputSerializerSettings = new JsonSerializerSettings
{
Converters = new List<JsonConverter>
{
new Vector3Converter(),
new Vector2Converter(),
new QuaternionConverter(),
new ColorConverter(),
new RectConverter(),
new BoundsConverter(),
new Matrix4x4Converter(), // Fix #478: Safe Matrix4x4 serialization for Cinemachine
new UnityEngineObjectConverter() // Handles serialization of references
},
ReferenceLoopHandling = ReferenceLoopHandling.Ignore,
// ContractResolver = new DefaultContractResolver { NamingStrategy = new CamelCaseNamingStrategy() } // Example if needed
};
private static readonly JsonSerializer _outputSerializer = JsonSerializer.Create(_outputSerializerSettings);
// --- End Define custom JsonSerializerSettings ---
// Helper to create JToken using the output serializer
private static JToken CreateTokenFromValue(object value, Type type)
{
if (value == null) return JValue.CreateNull();
try
{
// Use the pre-configured OUTPUT serializer instance
return JToken.FromObject(value, _outputSerializer);
}
catch (JsonSerializationException e)
{
McpLog.Warn($"[GameObjectSerializer] Newtonsoft.Json Error serializing value of type {type.FullName}: {e.Message}. Skipping property/field.");
return null; // Indicate serialization failure
}
catch (Exception e) // Catch other unexpected errors
{
McpLog.Warn($"[GameObjectSerializer] Unexpected error serializing value of type {type.FullName}: {e}. Skipping property/field.");
return null; // Indicate serialization failure
}
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 64b8ff807bc9a401c82015cbafccffac
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,358 @@
using System;
using System.Net;
using MCPForUnity.Editor.Constants;
using MCPForUnity.Editor.Models;
using MCPForUnity.Editor.Services;
using UnityEditor;
namespace MCPForUnity.Editor.Helpers
{
/// <summary>
/// Helper methods for managing HTTP endpoint URLs used by the MCP bridge.
/// Ensures the stored value is always the base URL (without trailing path),
/// and provides convenience accessors for specific endpoints.
///
/// HTTP Local and HTTP Remote use separate EditorPrefs keys so that switching
/// between scopes does not overwrite the other scope's URL.
/// </summary>
public static class HttpEndpointUtility
{
private const string LocalPrefKey = EditorPrefKeys.HttpBaseUrl;
private const string RemotePrefKey = EditorPrefKeys.HttpRemoteBaseUrl;
private const string DefaultLocalBaseUrl = "http://127.0.0.1:8080";
private const string DefaultRemoteBaseUrl = "";
/// <summary>
/// Returns the normalized base URL for the currently active HTTP scope.
/// If the scope is "remote", returns the remote URL; otherwise returns the local URL.
/// </summary>
public static string GetBaseUrl()
{
return IsRemoteScope() ? GetRemoteBaseUrl() : GetLocalBaseUrl();
}
/// <summary>
/// Saves a user-provided URL to the currently active HTTP scope's pref.
/// </summary>
public static void SaveBaseUrl(string userValue)
{
if (IsRemoteScope())
{
SaveRemoteBaseUrl(userValue);
}
else
{
SaveLocalBaseUrl(userValue);
}
}
/// <summary>
/// Returns the normalized local HTTP base URL (always reads local pref).
/// </summary>
public static string GetLocalBaseUrl()
{
string stored = EditorPrefs.GetString(LocalPrefKey, DefaultLocalBaseUrl);
return NormalizeBaseUrl(stored, DefaultLocalBaseUrl, remoteScope: false);
}
/// <summary>
/// Saves a user-provided URL to the local HTTP pref.
/// </summary>
public static void SaveLocalBaseUrl(string userValue)
{
string normalized = NormalizeBaseUrl(userValue, DefaultLocalBaseUrl, remoteScope: false);
EditorPrefs.SetString(LocalPrefKey, normalized);
}
/// <summary>
/// Returns the normalized remote HTTP base URL (always reads remote pref).
/// Returns empty string if no remote URL is configured.
/// </summary>
public static string GetRemoteBaseUrl()
{
string stored = EditorPrefs.GetString(RemotePrefKey, DefaultRemoteBaseUrl);
if (string.IsNullOrWhiteSpace(stored))
{
return DefaultRemoteBaseUrl;
}
return NormalizeBaseUrl(stored, DefaultRemoteBaseUrl, remoteScope: true);
}
/// <summary>
/// Saves a user-provided URL to the remote HTTP pref.
/// </summary>
public static void SaveRemoteBaseUrl(string userValue)
{
if (string.IsNullOrWhiteSpace(userValue))
{
EditorPrefs.SetString(RemotePrefKey, DefaultRemoteBaseUrl);
return;
}
string normalized = NormalizeBaseUrl(userValue, DefaultRemoteBaseUrl, remoteScope: true);
EditorPrefs.SetString(RemotePrefKey, normalized);
}
/// <summary>
/// Builds the JSON-RPC endpoint for the currently active scope (base + /mcp).
/// </summary>
public static string GetMcpRpcUrl()
{
return AppendPathSegment(GetBaseUrl(), "mcp");
}
/// <summary>
/// Builds the local JSON-RPC endpoint (local base + /mcp).
/// </summary>
public static string GetLocalMcpRpcUrl()
{
return AppendPathSegment(GetLocalBaseUrl(), "mcp");
}
/// <summary>
/// Builds the remote JSON-RPC endpoint (remote base + /mcp).
/// Returns empty string if no remote URL is configured.
/// </summary>
public static string GetRemoteMcpRpcUrl()
{
string remoteBase = GetRemoteBaseUrl();
return string.IsNullOrEmpty(remoteBase) ? string.Empty : AppendPathSegment(remoteBase, "mcp");
}
/// <summary>
/// Builds the endpoint used when POSTing custom-tool registration payloads.
/// </summary>
public static string GetRegisterToolsUrl()
{
return AppendPathSegment(GetBaseUrl(), "register-tools");
}
/// <summary>
/// Returns true if the active HTTP transport scope is "remote".
/// </summary>
public static bool IsRemoteScope()
{
string scope = EditorConfigurationCache.Instance.HttpTransportScope;
return string.Equals(scope, "remote", StringComparison.OrdinalIgnoreCase);
}
/// <summary>
/// Returns the <see cref="ConfiguredTransport"/> that matches the current server-side
/// transport selection (Stdio, Http, or HttpRemote).
/// Centralises the 3-way determination so callers avoid duplicated logic.
/// </summary>
public static ConfiguredTransport GetCurrentServerTransport()
{
bool useHttp = EditorConfigurationCache.Instance.UseHttpTransport;
if (!useHttp) return ConfiguredTransport.Stdio;
return IsRemoteScope() ? ConfiguredTransport.HttpRemote : ConfiguredTransport.Http;
}
/// <summary>
/// Returns true when advanced settings allow binding HTTP Local to all interfaces
/// (e.g. 0.0.0.0 / ::). Disabled by default.
/// </summary>
public static bool AllowLanHttpBind()
{
return EditorPrefs.GetBool(EditorPrefKeys.AllowLanHttpBind, false);
}
/// <summary>
/// Returns true when advanced settings allow insecure HTTP/WS for remote endpoints.
/// Disabled by default.
/// </summary>
public static bool AllowInsecureRemoteHttp()
{
return EditorPrefs.GetBool(EditorPrefKeys.AllowInsecureRemoteHttp, false);
}
/// <summary>
/// Returns true if the host is loopback-only.
/// </summary>
public static bool IsLoopbackHost(string host)
{
if (string.IsNullOrWhiteSpace(host))
{
return false;
}
string normalized = host.Trim().Trim('[', ']').ToLowerInvariant();
if (normalized == "localhost")
{
return true;
}
if (IPAddress.TryParse(normalized, out IPAddress parsedIp))
{
return IPAddress.IsLoopback(parsedIp);
}
return false;
}
/// <summary>
/// Returns true if the host is a bind-all-interfaces address.
/// </summary>
public static bool IsBindAllInterfacesHost(string host)
{
if (string.IsNullOrWhiteSpace(host))
{
return false;
}
string normalized = host.Trim().Trim('[', ']').ToLowerInvariant();
if (IPAddress.TryParse(normalized, out IPAddress parsedIp))
{
return parsedIp.Equals(IPAddress.Any) || parsedIp.Equals(IPAddress.IPv6Any);
}
return false;
}
/// <summary>
/// Returns true when the URL host is acceptable for HTTP Local launch.
/// Loopback is always allowed. Bind-all interfaces requires explicit opt-in.
/// </summary>
public static bool IsHttpLocalUrlAllowedForLaunch(string url, out string error)
{
error = null;
if (string.IsNullOrWhiteSpace(url))
{
error = "HTTP Local requires a loopback URL (localhost/127.0.0.1/::1).";
return false;
}
if (!Uri.TryCreate(url, UriKind.Absolute, out var uri))
{
error = $"Invalid URL: {url}";
return false;
}
string host = uri.Host;
if (IsLoopbackHost(host))
{
return true;
}
if (IsBindAllInterfacesHost(host))
{
if (AllowLanHttpBind())
{
return true;
}
error = "Binding to all interfaces (0.0.0.0/::) is disabled by default. " +
"Enable \"Allow LAN bind for HTTP Local\" in Advanced Settings to opt in.";
return false;
}
error = "HTTP Local requires a loopback URL (localhost/127.0.0.1/::1).";
return false;
}
/// <summary>
/// Returns true when remote URL is allowed by current security policy.
/// HTTPS is required by default; HTTP needs explicit opt-in.
/// </summary>
public static bool IsRemoteUrlAllowed(string remoteBaseUrl, out string error)
{
error = null;
if (string.IsNullOrWhiteSpace(remoteBaseUrl))
{
error = "HTTP Remote requires a configured URL.";
return false;
}
if (!Uri.TryCreate(remoteBaseUrl, UriKind.Absolute, out var uri))
{
error = $"Invalid HTTP Remote URL: {remoteBaseUrl}";
return false;
}
if (uri.Scheme.Equals("https", StringComparison.OrdinalIgnoreCase))
{
return true;
}
if (uri.Scheme.Equals("http", StringComparison.OrdinalIgnoreCase))
{
if (AllowInsecureRemoteHttp())
{
return true;
}
error = "HTTP Remote requires HTTPS by default. Enable \"Allow insecure HTTP for HTTP Remote\" in Advanced Settings to opt in.";
return false;
}
error = $"Unsupported URL scheme '{uri.Scheme}'. Use https:// (or http:// only with explicit insecure opt-in).";
return false;
}
/// <summary>
/// Returns true when the currently configured remote URL satisfies security policy.
/// </summary>
public static bool IsCurrentRemoteUrlAllowed(out string error)
{
return IsRemoteUrlAllowed(GetRemoteBaseUrl(), out error);
}
/// <summary>
/// Human-readable host requirement for HTTP Local based on current security settings.
/// </summary>
public static string GetHttpLocalHostRequirementText()
{
return AllowLanHttpBind()
? "localhost/127.0.0.1/::1/0.0.0.0/::"
: "localhost/127.0.0.1/::1";
}
/// <summary>
/// Normalizes a URL so that we consistently store just the base (no trailing slash/path).
/// </summary>
private static string NormalizeBaseUrl(string value, string defaultUrl, bool remoteScope)
{
if (string.IsNullOrWhiteSpace(value))
{
return defaultUrl;
}
string trimmed = value.Trim();
// Ensure scheme exists.
// For HTTP Remote, default to https:// to avoid accidental plaintext transport.
// For HTTP Local, default to http:// for zero-friction local setup.
if (!trimmed.Contains("://"))
{
string defaultScheme = remoteScope ? "https" : "http";
trimmed = $"{defaultScheme}://{trimmed}";
}
// Remove trailing slash segments.
trimmed = trimmed.TrimEnd('/');
// Strip trailing "/mcp" (case-insensitive) if provided.
if (trimmed.EndsWith("/mcp", StringComparison.OrdinalIgnoreCase))
{
trimmed = trimmed[..^4];
}
// For local scope, force 127.0.0.1 over "localhost". Windows getaddrinfo returns ::1
// first; clients without Happy Eyeballs (e.g., Codex/reqwest) hit the v6 socket while
// our server binds v4-only, breaking the handshake. The default server bind is
// 127.0.0.1, so emitting the literal v4 keeps every client unambiguous.
if (!remoteScope && Uri.TryCreate(trimmed, UriKind.Absolute, out Uri parsed)
&& string.Equals(parsed.Host, "localhost", StringComparison.OrdinalIgnoreCase))
{
var builder = new UriBuilder(parsed) { Host = "127.0.0.1" };
trimmed = builder.Uri.GetLeftPart(UriPartial.Authority);
}
return trimmed;
}
private static string AppendPathSegment(string baseUrl, string segment)
{
return $"{baseUrl.TrimEnd('/')}/{segment}";
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 2051d90316ea345c09240c80c7138e3b
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
+397
View File
@@ -0,0 +1,397 @@
using System;
using System.Collections.Generic;
using System.Linq;
using MCPForUnity.Editor.Tools;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using UnityEditor;
using UnityEngine;
namespace MCPForUnity.Editor.Helpers
{
public static class MaterialOps
{
/// <summary>
/// Applies a set of properties (JObject) to a material, handling aliases and structured formats.
/// </summary>
public static bool ApplyProperties(Material mat, JObject properties, JsonSerializer serializer)
{
if (mat == null || properties == null)
return false;
bool modified = false;
// Helper for case-insensitive lookup
JToken GetValue(string key)
{
return properties.Properties()
.FirstOrDefault(p => string.Equals(p.Name, key, StringComparison.OrdinalIgnoreCase))?.Value;
}
// --- Structured / Legacy Format Handling ---
// Example: Set shader
var shaderToken = GetValue("shader");
if (shaderToken?.Type == JTokenType.String)
{
string shaderRequest = shaderToken.ToString();
// Set shader
Shader newShader = RenderPipelineUtility.ResolveShader(shaderRequest);
if (newShader != null && mat.shader != newShader)
{
mat.shader = newShader;
modified = true;
}
}
// Example: Set color property (structured)
var colorToken = GetValue("color");
if (colorToken is JObject colorProps)
{
string propName = colorProps["name"]?.ToString() ?? GetMainColorPropertyName(mat);
if (colorProps["value"] is JArray colArr && colArr.Count >= 3)
{
try
{
Color newColor = ParseColor(colArr, serializer);
if (mat.HasProperty(propName))
{
if (mat.GetColor(propName) != newColor)
{
mat.SetColor(propName, newColor);
modified = true;
}
}
}
catch (Exception ex)
{
McpLog.Warn($"[MaterialOps] Failed to parse color for property '{propName}': {ex.Message}");
}
}
}
else if (colorToken is JArray colorArr) // Structured shorthand
{
string propName = GetMainColorPropertyName(mat);
try
{
Color newColor = ParseColor(colorArr, serializer);
if (mat.HasProperty(propName) && mat.GetColor(propName) != newColor)
{
mat.SetColor(propName, newColor);
modified = true;
}
}
catch (Exception ex)
{
McpLog.Warn($"[MaterialOps] Failed to parse color array: {ex.Message}");
}
}
// Example: Set float property (structured)
var floatToken = GetValue("float");
if (floatToken is JObject floatProps)
{
string propName = floatProps["name"]?.ToString();
if (!string.IsNullOrEmpty(propName) &&
(floatProps["value"]?.Type == JTokenType.Float || floatProps["value"]?.Type == JTokenType.Integer))
{
try
{
float newVal = floatProps["value"].ToObject<float>();
if (mat.HasProperty(propName) && mat.GetFloat(propName) != newVal)
{
mat.SetFloat(propName, newVal);
modified = true;
}
}
catch (Exception ex)
{
McpLog.Warn($"[MaterialOps] Failed to set float property '{propName}': {ex.Message}");
}
}
}
// Example: Set texture property (structured)
{
var texToken = GetValue("texture");
if (texToken is JObject texProps)
{
string rawName = (texProps["name"] ?? texProps["Name"])?.ToString();
string texPath = (texProps["path"] ?? texProps["Path"])?.ToString();
if (!string.IsNullOrEmpty(texPath))
{
var sanitizedPath = AssetPathUtility.SanitizeAssetPath(texPath);
var newTex = AssetDatabase.LoadAssetAtPath<Texture>(sanitizedPath);
// Use ResolvePropertyName to handle aliases even for structured texture names
string candidateName = string.IsNullOrEmpty(rawName) ? "_BaseMap" : rawName;
string targetProp = ResolvePropertyName(mat, candidateName);
if (!string.IsNullOrEmpty(targetProp) && mat.HasProperty(targetProp))
{
if (mat.GetTexture(targetProp) != newTex)
{
mat.SetTexture(targetProp, newTex);
modified = true;
}
}
}
}
}
// --- Direct Property Assignment (Flexible) ---
var reservedKeys = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "shader", "color", "float", "texture" };
foreach (var prop in properties.Properties())
{
if (reservedKeys.Contains(prop.Name)) continue;
string shaderProp = ResolvePropertyName(mat, prop.Name);
JToken v = prop.Value;
if (TrySetShaderProperty(mat, shaderProp, v, serializer))
{
modified = true;
}
}
return modified;
}
/// <summary>
/// Resolves common property aliases (e.g. "metallic" -> "_Metallic").
/// </summary>
public static string ResolvePropertyName(Material mat, string name)
{
if (mat == null || string.IsNullOrEmpty(name)) return name;
string[] candidates;
var lower = name.ToLowerInvariant();
switch (lower)
{
case "_color": candidates = new[] { "_Color", "_BaseColor" }; break;
case "_basecolor": candidates = new[] { "_BaseColor", "_Color" }; break;
case "_maintex": candidates = new[] { "_MainTex", "_BaseMap" }; break;
case "_basemap": candidates = new[] { "_BaseMap", "_MainTex" }; break;
case "_glossiness": candidates = new[] { "_Glossiness", "_Smoothness" }; break;
case "_smoothness": candidates = new[] { "_Smoothness", "_Glossiness" }; break;
// Friendly names → shader property names
case "metallic": candidates = new[] { "_Metallic" }; break;
case "smoothness": candidates = new[] { "_Smoothness", "_Glossiness" }; break;
case "albedo": candidates = new[] { "_BaseMap", "_MainTex" }; break;
default: candidates = new[] { name }; break; // keep original as-is
}
foreach (var candidate in candidates)
{
if (mat.HasProperty(candidate)) return candidate;
}
return name;
}
/// <summary>
/// Auto-detects the main color property name for a material's shader.
/// </summary>
public static string GetMainColorPropertyName(Material mat)
{
if (mat == null || mat.shader == null)
return "_Color";
string[] commonColorProps = { "_BaseColor", "_Color", "_MainColor", "_Tint", "_TintColor" };
foreach (var prop in commonColorProps)
{
if (mat.HasProperty(prop))
return prop;
}
return "_Color";
}
/// <summary>
/// Tries to set a shader property on a material based on a JToken value.
/// Handles Colors, Vectors, Floats, Ints, Booleans, and Textures.
/// </summary>
public static bool TrySetShaderProperty(Material material, string propertyName, JToken value, JsonSerializer serializer)
{
if (material == null || string.IsNullOrEmpty(propertyName) || value == null)
return false;
// Handle stringified JSON
if (value.Type == JTokenType.String)
{
string s = value.ToString();
if (s.TrimStart().StartsWith("[") || s.TrimStart().StartsWith("{"))
{
try
{
JToken parsed = JToken.Parse(s);
return TrySetShaderProperty(material, propertyName, parsed, serializer);
}
catch { }
}
}
// Use the serializer to convert the JToken value first
if (value is JArray jArray)
{
if (jArray.Count == 4)
{
if (material.HasProperty(propertyName))
{
try { material.SetColor(propertyName, ParseColor(value, serializer)); return true; }
catch (Exception ex)
{
// Log at Debug level since we'll try other conversions
McpLog.Info($"[MaterialOps] SetColor attempt for '{propertyName}' failed: {ex.Message}");
}
try { Vector4 vec = value.ToObject<Vector4>(serializer); material.SetVector(propertyName, vec); return true; }
catch (Exception ex)
{
McpLog.Info($"[MaterialOps] SetVector (Vec4) attempt for '{propertyName}' failed: {ex.Message}");
}
}
}
else if (jArray.Count == 3)
{
if (material.HasProperty(propertyName))
{
try { material.SetColor(propertyName, ParseColor(value, serializer)); return true; }
catch (Exception ex)
{
McpLog.Info($"[MaterialOps] SetColor (Vec3) attempt for '{propertyName}' failed: {ex.Message}");
}
}
}
else if (jArray.Count == 2)
{
if (material.HasProperty(propertyName))
{
try { Vector2 vec = value.ToObject<Vector2>(serializer); material.SetVector(propertyName, vec); return true; }
catch (Exception ex)
{
McpLog.Info($"[MaterialOps] SetVector (Vec2) attempt for '{propertyName}' failed: {ex.Message}");
}
}
}
}
else if (value.Type == JTokenType.Float || value.Type == JTokenType.Integer)
{
if (!material.HasProperty(propertyName))
return false;
try { material.SetFloat(propertyName, value.ToObject<float>(serializer)); return true; }
catch (Exception ex)
{
McpLog.Info($"[MaterialOps] SetFloat attempt for '{propertyName}' failed: {ex.Message}");
}
}
else if (value.Type == JTokenType.Boolean)
{
if (!material.HasProperty(propertyName))
return false;
try { material.SetFloat(propertyName, value.ToObject<bool>(serializer) ? 1f : 0f); return true; }
catch (Exception ex)
{
McpLog.Info($"[MaterialOps] SetFloat (bool) attempt for '{propertyName}' failed: {ex.Message}");
}
}
else if (value.Type == JTokenType.String)
{
try
{
// Try loading as asset path first (most common case for strings in this context)
string path = value.ToString();
if (!string.IsNullOrEmpty(path) && path.Contains("/")) // Heuristic: paths usually have slashes
{
// We need to handle texture assignment here.
// Since we don't have easy access to AssetDatabase here directly without using UnityEditor namespace (which is imported),
// we can try to load it.
var sanitizedPath = AssetPathUtility.SanitizeAssetPath(path);
Texture tex = AssetDatabase.LoadAssetAtPath<Texture>(sanitizedPath);
if (tex != null && material.HasProperty(propertyName))
{
material.SetTexture(propertyName, tex);
return true;
}
}
}
catch (Exception ex)
{
McpLog.Warn($"SetTexture (string path) for '{propertyName}' failed: {ex.Message}");
}
}
if (value.Type == JTokenType.Object)
{
try
{
Texture texture = value.ToObject<Texture>(serializer);
if (texture != null && material.HasProperty(propertyName))
{
material.SetTexture(propertyName, texture);
return true;
}
}
catch (Exception ex)
{
McpLog.Warn($"SetTexture (object) for '{propertyName}' failed: {ex.Message}");
}
}
McpLog.Warn(
$"[MaterialOps] Unsupported or failed conversion for material property '{propertyName}' from value: {value.ToString(Formatting.None)}"
);
return false;
}
/// <summary>
/// Helper to parse color from JToken (array or object).
/// </summary>
public static Color ParseColor(JToken token, JsonSerializer serializer)
{
if (token.Type == JTokenType.String)
{
string s = token.ToString();
if (s.TrimStart().StartsWith("[") || s.TrimStart().StartsWith("{"))
{
try
{
return ParseColor(JToken.Parse(s), serializer);
}
catch { }
}
}
if (token is JArray jArray)
{
if (jArray.Count == 4)
{
return new Color(
(float)jArray[0],
(float)jArray[1],
(float)jArray[2],
(float)jArray[3]
);
}
else if (jArray.Count == 3)
{
return new Color(
(float)jArray[0],
(float)jArray[1],
(float)jArray[2],
1f
);
}
else
{
throw new ArgumentException("Color array must have 3 or 4 elements.");
}
}
try
{
return token.ToObject<Color>(serializer);
}
catch (Exception ex)
{
McpLog.Warn($"[MaterialOps] Failed to parse color from token: {ex.Message}");
throw;
}
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: a59e8545e32664dae9a696d449f82c3d
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,283 @@
using System;
using System.IO;
using System.Linq;
using System.Runtime.InteropServices;
using System.Text;
using MCPForUnity.Editor.Constants;
using MCPForUnity.Editor.Dependencies;
using MCPForUnity.Editor.Helpers;
using MCPForUnity.Editor.Models;
using MCPForUnity.Editor.Services;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using UnityEditor;
using UnityEngine;
namespace MCPForUnity.Editor.Helpers
{
/// <summary>
/// Shared helper for MCP client configuration management with sophisticated
/// logic for preserving existing configs and handling different client types
/// </summary>
public static class McpConfigurationHelper
{
private const string LOCK_CONFIG_KEY = EditorPrefKeys.LockCursorConfig;
/// <summary>
/// Writes MCP configuration to the specified path using sophisticated logic
/// that preserves existing configuration and only writes when necessary
/// </summary>
public static string WriteMcpConfiguration(string configPath, McpClient mcpClient = null)
{
// 0) Respect explicit lock (hidden pref or UI toggle)
try
{
if (EditorPrefs.GetBool(LOCK_CONFIG_KEY, false))
return "Skipped (locked)";
}
catch { }
JsonSerializerSettings jsonSettings = new() { Formatting = Formatting.Indented };
// Read existing config if it exists
string existingJson = "{}";
if (File.Exists(configPath))
{
try
{
existingJson = File.ReadAllText(configPath);
}
catch (Exception e)
{
McpLog.Warn($"Error reading existing config: {e.Message}.");
}
}
// Parse the existing JSON while preserving all properties
dynamic existingConfig;
try
{
if (string.IsNullOrWhiteSpace(existingJson))
{
existingConfig = new JObject();
}
else
{
existingConfig = JsonConvert.DeserializeObject(existingJson) ?? new JObject();
}
}
catch
{
// If user has partial/invalid JSON (e.g., mid-edit), start from a fresh object
if (!string.IsNullOrWhiteSpace(existingJson))
{
McpLog.Warn("UnityMCP: Configuration file could not be parsed; rewriting server block.");
}
existingConfig = new JObject();
}
// Determine existing entry references (command/args)
string existingCommand = null;
string[] existingArgs = null;
bool isVSCode = (mcpClient?.IsVsCodeLayout == true);
try
{
if (isVSCode)
{
existingCommand = existingConfig?.servers?.unityMCP?.command?.ToString();
existingArgs = existingConfig?.servers?.unityMCP?.args?.ToObject<string[]>();
}
else
{
existingCommand = existingConfig?.mcpServers?.unityMCP?.command?.ToString();
existingArgs = existingConfig?.mcpServers?.unityMCP?.args?.ToObject<string[]>();
}
}
catch { }
// 1) Start from existing, only fill gaps (prefer trusted resolver)
string uvxPath = MCPServiceLocator.Paths.GetUvxPath();
if (uvxPath == null) return "uv package manager not found. Please install uv first.";
// Ensure containers exist and write back configuration
JObject existingRoot;
if (existingConfig is JObject eo)
existingRoot = eo;
else
existingRoot = JObject.FromObject(existingConfig);
existingRoot = ConfigJsonBuilder.ApplyUnityServerToExistingConfig(existingRoot, uvxPath, mcpClient);
string mergedJson = JsonConvert.SerializeObject(existingRoot, jsonSettings);
EnsureConfigDirectoryExists(configPath);
WriteAtomicFile(configPath, mergedJson);
return "Configured successfully";
}
/// <summary>
/// Configures a Codex client with sophisticated TOML handling
/// </summary>
public static string ConfigureCodexClient(string configPath, McpClient mcpClient)
{
try
{
if (EditorPrefs.GetBool(LOCK_CONFIG_KEY, false))
return "Skipped (locked)";
}
catch { }
string existingToml = string.Empty;
if (File.Exists(configPath))
{
try
{
existingToml = File.ReadAllText(configPath);
}
catch (Exception e)
{
McpLog.Warn($"UnityMCP: Failed to read Codex config '{configPath}': {e.Message}");
existingToml = string.Empty;
}
}
string existingCommand = null;
string[] existingArgs = null;
if (!string.IsNullOrWhiteSpace(existingToml))
{
CodexConfigHelper.TryParseCodexServer(existingToml, out existingCommand, out existingArgs);
}
string uvxPath = MCPServiceLocator.Paths.GetUvxPath();
if (uvxPath == null)
{
return "uv package manager not found. Please install uv first.";
}
string updatedToml = CodexConfigHelper.UpsertCodexServerBlock(existingToml, uvxPath);
EnsureConfigDirectoryExists(configPath);
WriteAtomicFile(configPath, updatedToml);
return "Configured successfully";
}
/// <summary>
/// Gets the appropriate config file path for the given MCP client based on OS
/// </summary>
public static string GetClientConfigPath(McpClient mcpClient)
{
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
return mcpClient.windowsConfigPath;
}
else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
{
return string.IsNullOrEmpty(mcpClient.macConfigPath)
? mcpClient.linuxConfigPath
: mcpClient.macConfigPath;
}
else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
{
return mcpClient.linuxConfigPath;
}
else
{
return mcpClient.linuxConfigPath; // fallback
}
}
/// <summary>
/// Creates the directory for the config file if it doesn't exist
/// </summary>
public static void EnsureConfigDirectoryExists(string configPath)
{
Directory.CreateDirectory(Path.GetDirectoryName(configPath));
}
public static string ExtractUvxUrl(string[] args)
{
if (args == null) return null;
for (int i = 0; i < args.Length - 1; i++)
{
if (string.Equals(args[i], "--from", StringComparison.OrdinalIgnoreCase))
{
return args[i + 1];
}
}
return null;
}
public static bool PathsEqual(string a, string b)
{
if (string.IsNullOrEmpty(a) || string.IsNullOrEmpty(b)) return false;
try
{
string na = Path.GetFullPath(a.Trim());
string nb = Path.GetFullPath(b.Trim());
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
return string.Equals(na, nb, StringComparison.OrdinalIgnoreCase);
}
return string.Equals(na, nb, StringComparison.Ordinal);
}
catch
{
return false;
}
}
public static void WriteAtomicFile(string path, string contents)
{
string tmp = path + ".tmp";
string backup = path + ".backup";
bool writeDone = false;
try
{
File.WriteAllText(tmp, contents, new UTF8Encoding(false));
try
{
File.Replace(tmp, path, backup);
writeDone = true;
}
catch (FileNotFoundException)
{
File.Move(tmp, path);
writeDone = true;
}
catch (PlatformNotSupportedException)
{
if (File.Exists(path))
{
try
{
if (File.Exists(backup)) File.Delete(backup);
}
catch { }
File.Move(path, backup);
}
File.Move(tmp, path);
writeDone = true;
}
}
catch (Exception ex)
{
try
{
if (!writeDone && File.Exists(backup))
{
try { File.Copy(backup, path, true); } catch { }
}
}
catch { }
throw new Exception($"Failed to write config file '{path}': {ex.Message}", ex);
}
finally
{
try { if (File.Exists(tmp)) File.Delete(tmp); } catch { }
try { if (writeDone && File.Exists(backup)) File.Delete(backup); } catch { }
}
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: e45ac2a13b4c1ba468b8e3aa67b292ca
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,62 @@
using System;
using System.IO;
using Newtonsoft.Json;
using UnityEngine;
namespace MCPForUnity.Editor.Helpers
{
/// <summary>
/// Utility for persisting tool state across domain reloads. State is stored in
/// Library so it stays local to the project and is cleared by Unity as needed.
/// </summary>
public static class McpJobStateStore
{
private static string GetStatePath(string toolName)
{
if (string.IsNullOrEmpty(toolName))
{
throw new ArgumentException("toolName cannot be null or empty", nameof(toolName));
}
var libraryPath = Path.Combine(Application.dataPath, "..", "Library");
var fileName = $"McpState_{toolName}.json";
return Path.GetFullPath(Path.Combine(libraryPath, fileName));
}
public static void SaveState<T>(string toolName, T state)
{
var path = GetStatePath(toolName);
Directory.CreateDirectory(Path.GetDirectoryName(path));
var json = JsonConvert.SerializeObject(state ?? Activator.CreateInstance<T>());
File.WriteAllText(path, json);
}
public static T LoadState<T>(string toolName)
{
var path = GetStatePath(toolName);
if (!File.Exists(path))
{
return default;
}
try
{
var json = File.ReadAllText(path);
return JsonConvert.DeserializeObject<T>(json);
}
catch (Exception)
{
return default;
}
}
public static void ClearState(string toolName)
{
var path = GetStatePath(toolName);
if (File.Exists(path))
{
File.Delete(path);
}
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 28912085dd68342f8a9fda8a43c83a59
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
+53
View File
@@ -0,0 +1,53 @@
using MCPForUnity.Editor.Constants;
using UnityEditor;
using UnityEngine;
namespace MCPForUnity.Editor.Helpers
{
internal static class McpLog
{
private const string InfoPrefix = "<b><color=#2EA3FF>MCP-FOR-UNITY</color></b>:";
private const string DebugPrefix = "<b><color=#6AA84F>MCP-FOR-UNITY</color></b>:";
private const string WarnPrefix = "<b><color=#cc7a00>MCP-FOR-UNITY</color></b>:";
private const string ErrorPrefix = "<b><color=#cc3333>MCP-FOR-UNITY</color></b>:";
private static volatile bool _debugEnabled = ReadDebugPreference();
private static bool IsDebugEnabled() => _debugEnabled;
private static bool ReadDebugPreference()
{
try { return EditorPrefs.GetBool(EditorPrefKeys.DebugLogs, false); }
catch { return false; }
}
public static void SetDebugLoggingEnabled(bool enabled)
{
_debugEnabled = enabled;
try { EditorPrefs.SetBool(EditorPrefKeys.DebugLogs, enabled); }
catch { }
}
public static void Debug(string message)
{
if (!IsDebugEnabled()) return;
UnityEngine.Debug.Log($"{DebugPrefix} {message}");
}
public static void Info(string message, bool always = true)
{
if (!always && !IsDebugEnabled()) return;
UnityEngine.Debug.Log($"{InfoPrefix} {message}");
}
public static void Warn(string message)
{
UnityEngine.Debug.LogWarning($"{WarnPrefix} {message}");
}
public static void Error(string message)
{
UnityEngine.Debug.LogError($"{ErrorPrefix} {message}");
}
}
}
+11
View File
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 9e2c3f8a4f4f48d8a4c1b7b8e3f5a1c2
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
+117
View File
@@ -0,0 +1,117 @@
using System;
using System.IO;
using MCPForUnity.Editor.Constants;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using UnityEditor;
using UnityEngine;
namespace MCPForUnity.Editor.Helpers
{
internal static class McpLogRecord
{
private static readonly string LogDir = Path.Combine(Application.dataPath, "UnityMCP", "Log");
private static readonly string LogPath = Path.Combine(LogDir, "mcp.log");
private static readonly string ErrorLogPath = Path.Combine(LogDir, "mcpError.log");
private const long MaxLogSizeBytes = 1024 * 1024; // 1 MB
private static bool _sessionStarted;
private static readonly object _logLock = new();
private static volatile bool _isEnabledCached;
[InitializeOnLoadMethod]
private static void RefreshFromPrefs()
{
_isEnabledCached = EditorPrefs.GetBool(EditorPrefKeys.LogRecordEnabled, false);
}
internal static bool IsEnabled
{
get => _isEnabledCached;
set
{
EditorPrefs.SetBool(EditorPrefKeys.LogRecordEnabled, value);
_isEnabledCached = value;
}
}
internal static void Log(string commandType, JObject parameters, string type, string status, long durationMs, string error = null)
{
if (!IsEnabled) return;
try
{
var entry = new JObject
{
["ts"] = DateTime.UtcNow.ToString("yyyy-MM-ddTHH:mm:ss.fffZ"),
["tool"] = commandType,
["type"] = type,
["status"] = status,
["ms"] = durationMs
};
var action = parameters?.Value<string>("action");
if (!string.IsNullOrEmpty(action))
entry["action"] = action;
if (parameters != null)
entry["params"] = parameters;
if (error != null)
entry["error"] = error;
var line = entry.ToString(Formatting.None);
lock (_logLock)
{
if (!_sessionStarted)
{
_sessionStarted = true;
var sessionEntry = new JObject
{
["ts"] = DateTime.UtcNow.ToString("yyyy-MM-ddTHH:mm:ss.fffZ"),
["event"] = "session_start",
["unity"] = Application.unityVersion
};
RotateAndAppend(LogPath, sessionEntry.ToString(Formatting.None));
}
RotateAndAppend(LogPath, line);
if (status == "ERROR")
{
RotateAndAppend(ErrorLogPath, line);
}
}
}
catch (Exception ex)
{
McpLog.Warn($"[McpLogRecord] Failed to write log: {ex.Message}");
}
}
private static void RotateAndAppend(string path, string line)
{
Directory.CreateDirectory(LogDir);
RotateIfNeeded(path);
File.AppendAllText(path, line + Environment.NewLine);
}
private static void RotateIfNeeded(string path)
{
try
{
if (!File.Exists(path)) return;
var info = new FileInfo(path);
if (info.Length <= MaxLogSizeBytes) return;
var lines = File.ReadAllLines(path);
var half = lines.Length / 2;
File.WriteAllLines(path, lines[half..]);
}
catch
{
// Best-effort rotation
}
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 925ef3d40ecf53649a6af9e94df6114b
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,202 @@
using System;
using MCPForUnity.Editor.Helpers;
using Newtonsoft.Json.Linq;
using UnityEditor;
using UnityEngine;
namespace MCPForUnity.Editor.Helpers
{
/// <summary>
/// Resolves Unity Objects by instruction (handles GameObjects, Components, Assets).
/// Extracted from ManageGameObject to eliminate cross-tool dependencies.
/// </summary>
public static class ObjectResolver
{
/// <summary>
/// Resolves any Unity Object by instruction.
/// </summary>
/// <typeparam name="T">The type of Unity Object to resolve</typeparam>
/// <param name="instruction">JObject with "find" (required), "method" (optional), "component" (optional)</param>
/// <returns>The resolved object, or null if not found</returns>
public static T Resolve<T>(JObject instruction) where T : UnityEngine.Object
{
return Resolve(instruction, typeof(T)) as T;
}
/// <summary>
/// Resolves any Unity Object by instruction.
/// </summary>
/// <param name="instruction">JObject with "find" (required), "method" (optional), "component" (optional)</param>
/// <param name="targetType">The type of Unity Object to resolve</param>
/// <returns>The resolved object, or null if not found</returns>
public static UnityEngine.Object Resolve(JObject instruction, Type targetType)
{
if (instruction == null)
return null;
string findTerm = instruction["find"]?.ToString();
string method = instruction["method"]?.ToString()?.ToLower();
string componentName = instruction["component"]?.ToString();
if (string.IsNullOrEmpty(findTerm))
{
McpLog.Warn("[ObjectResolver] Find instruction missing 'find' term.");
return null;
}
// Use a flexible default search method if none provided
string searchMethodToUse = string.IsNullOrEmpty(method) ? "by_id_or_name_or_path" : method;
// --- Asset Search ---
// Normalize path separators before checking asset paths
string normalizedPath = AssetPathUtility.NormalizeSeparators(findTerm);
// If the target is an asset type, try AssetDatabase first
if (IsAssetType(targetType) ||
(typeof(GameObject).IsAssignableFrom(targetType) && normalizedPath.StartsWith("Assets/")))
{
UnityEngine.Object asset = TryLoadAsset(normalizedPath, targetType);
if (asset != null)
return asset;
// If still not found, fall through to scene search
}
// --- Scene Object Search ---
GameObject foundGo = GameObjectLookup.FindByTarget(new JValue(findTerm), searchMethodToUse, includeInactive: false);
if (foundGo == null)
{
return null;
}
// Get the target object/component from the found GameObject
if (targetType == typeof(GameObject))
{
return foundGo;
}
else if (typeof(Component).IsAssignableFrom(targetType))
{
Type componentToGetType = targetType;
if (!string.IsNullOrEmpty(componentName))
{
Type specificCompType = GameObjectLookup.FindComponentType(componentName);
if (specificCompType != null && typeof(Component).IsAssignableFrom(specificCompType))
{
componentToGetType = specificCompType;
}
else
{
McpLog.Warn($"[ObjectResolver] Could not find component type '{componentName}'. Falling back to target type '{targetType.Name}'.");
}
}
Component foundComp = foundGo.GetComponent(componentToGetType);
if (foundComp == null)
{
McpLog.Warn($"[ObjectResolver] Found GameObject '{foundGo.name}' but could not find component of type '{componentToGetType.Name}'.");
}
return foundComp;
}
else
{
McpLog.Warn($"[ObjectResolver] Find instruction handling not implemented for target type: {targetType.Name}");
return null;
}
}
/// <summary>
/// Convenience method to resolve a GameObject.
/// </summary>
public static GameObject ResolveGameObject(JToken target, string searchMethod = null)
{
if (target == null)
return null;
// If target is a simple value, use GameObjectLookup directly
if (target.Type != JTokenType.Object)
{
return GameObjectLookup.FindByTarget(target, searchMethod ?? "by_id_or_name_or_path");
}
// If target is an instruction object
var instruction = target as JObject;
if (instruction != null)
{
return Resolve<GameObject>(instruction);
}
return null;
}
/// <summary>
/// Convenience method to resolve a Material.
/// </summary>
public static Material ResolveMaterial(string pathOrName)
{
if (string.IsNullOrEmpty(pathOrName))
return null;
var instruction = new JObject { ["find"] = pathOrName };
return Resolve<Material>(instruction);
}
/// <summary>
/// Convenience method to resolve a Texture.
/// </summary>
public static Texture ResolveTexture(string pathOrName)
{
if (string.IsNullOrEmpty(pathOrName))
return null;
var instruction = new JObject { ["find"] = pathOrName };
return Resolve<Texture>(instruction);
}
// --- Private Helpers ---
private static bool IsAssetType(Type type)
{
return typeof(Material).IsAssignableFrom(type) ||
typeof(Texture).IsAssignableFrom(type) ||
typeof(ScriptableObject).IsAssignableFrom(type) ||
type.FullName?.StartsWith("UnityEngine.U2D") == true ||
typeof(AudioClip).IsAssignableFrom(type) ||
typeof(AnimationClip).IsAssignableFrom(type) ||
typeof(Font).IsAssignableFrom(type) ||
typeof(Shader).IsAssignableFrom(type) ||
typeof(ComputeShader).IsAssignableFrom(type);
}
private static UnityEngine.Object TryLoadAsset(string findTerm, Type targetType)
{
// Try loading directly by path first
UnityEngine.Object asset = AssetDatabase.LoadAssetAtPath(findTerm, targetType);
if (asset != null)
return asset;
// Try generic load if type-specific failed
asset = AssetDatabase.LoadAssetAtPath<UnityEngine.Object>(findTerm);
if (asset != null && targetType.IsAssignableFrom(asset.GetType()))
return asset;
// Try finding by name/type using FindAssets
string searchFilter = $"t:{targetType.Name} {System.IO.Path.GetFileNameWithoutExtension(findTerm)}";
string[] guids = AssetDatabase.FindAssets(searchFilter);
if (guids.Length == 1)
{
asset = AssetDatabase.LoadAssetAtPath(AssetDatabase.GUIDToAssetPath(guids[0]), targetType);
if (asset != null)
return asset;
}
else if (guids.Length > 1)
{
McpLog.Warn($"[ObjectResolver] Ambiguous asset find: Found {guids.Length} assets matching filter '{searchFilter}'. Provide a full path or unique name.");
return null;
}
return null;
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: ad678f7b0a2e6458bbdb38a15d857acf
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
+149
View File
@@ -0,0 +1,149 @@
using System.Collections.Generic;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
namespace MCPForUnity.Editor.Helpers
{
/// <summary>
/// Standard pagination request for all paginated tool operations.
/// Provides consistent handling of page_size/pageSize and cursor/page_number parameters.
/// </summary>
public class PaginationRequest
{
/// <summary>
/// Number of items per page. Default is 50.
/// </summary>
public int PageSize { get; set; } = 50;
/// <summary>
/// 0-based cursor position for the current page.
/// </summary>
public int Cursor { get; set; } = 0;
/// <summary>
/// Creates a PaginationRequest from JObject parameters.
/// Accepts both snake_case and camelCase parameter names for flexibility.
/// Converts 1-based page_number to 0-based cursor if needed.
/// </summary>
public static PaginationRequest FromParams(JObject @params, int defaultPageSize = 50)
{
if (@params == null)
return new PaginationRequest { PageSize = defaultPageSize };
// Accept both page_size and pageSize
int pageSize = ParamCoercion.CoerceInt(
@params["page_size"] ?? @params["pageSize"],
defaultPageSize
);
// Accept both cursor (0-based) and page_number (convert 1-based to 0-based)
var cursorToken = @params["cursor"];
var pageNumberToken = @params["page_number"] ?? @params["pageNumber"];
int cursor;
if (cursorToken != null)
{
cursor = ParamCoercion.CoerceInt(cursorToken, 0);
}
else if (pageNumberToken != null)
{
// Convert 1-based page_number to 0-based cursor
int pageNumber = ParamCoercion.CoerceInt(pageNumberToken, 1);
cursor = (pageNumber - 1) * pageSize;
if (cursor < 0) cursor = 0;
}
else
{
cursor = 0;
}
return new PaginationRequest
{
PageSize = pageSize > 0 ? pageSize : defaultPageSize,
Cursor = cursor
};
}
}
/// <summary>
/// Standard pagination response for all paginated tool operations.
/// Provides consistent response structure across all tools.
/// </summary>
/// <typeparam name="T">The type of items in the paginated list</typeparam>
public class PaginationResponse<T>
{
/// <summary>
/// The items on the current page.
/// </summary>
[JsonProperty("items")]
public List<T> Items { get; set; } = new List<T>();
/// <summary>
/// The cursor position for the current page (0-based).
/// </summary>
[JsonProperty("cursor")]
public int Cursor { get; set; }
/// <summary>
/// The cursor for the next page, or null if this is the last page.
/// </summary>
[JsonProperty("nextCursor")]
public int? NextCursor { get; set; }
/// <summary>
/// Total number of items across all pages.
/// </summary>
[JsonProperty("totalCount")]
public int TotalCount { get; set; }
/// <summary>
/// Number of items per page.
/// </summary>
[JsonProperty("pageSize")]
public int PageSize { get; set; }
/// <summary>
/// Whether there are more items after this page.
/// </summary>
[JsonProperty("hasMore")]
public bool HasMore => NextCursor.HasValue;
/// <summary>
/// Creates a PaginationResponse from a full list of items and pagination parameters.
/// </summary>
/// <param name="allItems">The full list of items to paginate</param>
/// <param name="request">The pagination request parameters</param>
/// <returns>A paginated response with the appropriate slice of items</returns>
public static PaginationResponse<T> Create(IList<T> allItems, PaginationRequest request)
{
int totalCount = allItems.Count;
int cursor = request.Cursor;
int pageSize = request.PageSize;
// Clamp cursor to valid range
if (cursor < 0) cursor = 0;
if (cursor > totalCount) cursor = totalCount;
// Get the page of items
var items = new List<T>();
int endIndex = System.Math.Min(cursor + pageSize, totalCount);
for (int i = cursor; i < endIndex; i++)
{
items.Add(allItems[i]);
}
// Calculate next cursor
int? nextCursor = endIndex < totalCount ? endIndex : (int?)null;
return new PaginationResponse<T>
{
Items = items,
Cursor = cursor,
NextCursor = nextCursor,
TotalCount = totalCount,
PageSize = pageSize
};
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 745564d5894d74c0ca24db39c77bab2c
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
+394
View File
@@ -0,0 +1,394 @@
using System;
using System.Globalization;
using Newtonsoft.Json.Linq;
namespace MCPForUnity.Editor.Helpers
{
/// <summary>
/// Utility class for coercing JSON parameter values to strongly-typed values.
/// Handles various input formats (strings, numbers, booleans) gracefully.
/// </summary>
public static class ParamCoercion
{
/// <summary>
/// Coerces a JToken to an integer value, handling strings and floats.
/// </summary>
/// <param name="token">The JSON token to coerce</param>
/// <param name="defaultValue">Default value if coercion fails</param>
/// <returns>The coerced integer value or default</returns>
public static int CoerceInt(JToken token, int defaultValue)
{
if (token == null || token.Type == JTokenType.Null)
return defaultValue;
try
{
if (token.Type == JTokenType.Integer)
return token.Value<int>();
var s = token.ToString().Trim();
if (s.Length == 0)
return defaultValue;
if (int.TryParse(s, NumberStyles.Integer, CultureInfo.InvariantCulture, out var i))
return i;
if (double.TryParse(s, NumberStyles.Float, CultureInfo.InvariantCulture, out var d))
return (int)d;
}
catch
{
// Swallow and return default
}
return defaultValue;
}
/// <summary>
/// Coerces a JToken to a long value, handling strings and floats.
/// </summary>
public static long CoerceLong(JToken token, long defaultValue)
{
if (token == null || token.Type == JTokenType.Null)
return defaultValue;
try
{
if (token.Type == JTokenType.Integer)
return token.Value<long>();
var s = token.ToString().Trim();
if (s.Length == 0)
return defaultValue;
if (long.TryParse(s, NumberStyles.Integer, CultureInfo.InvariantCulture, out var l))
return l;
if (double.TryParse(s, NumberStyles.Float, CultureInfo.InvariantCulture, out var d))
return (long)d;
}
catch
{
// Swallow and return default
}
return defaultValue;
}
/// <summary>
/// Coerces a JToken to a nullable integer value.
/// Returns null if token is null, empty, or cannot be parsed.
/// </summary>
/// <param name="token">The JSON token to coerce</param>
/// <returns>The coerced integer value or null</returns>
public static int? CoerceIntNullable(JToken token)
{
if (token == null || token.Type == JTokenType.Null)
return null;
try
{
if (token.Type == JTokenType.Integer)
return token.Value<int>();
var s = token.ToString().Trim();
if (s.Length == 0)
return null;
if (int.TryParse(s, NumberStyles.Integer, CultureInfo.InvariantCulture, out var i))
return i;
if (double.TryParse(s, NumberStyles.Float, CultureInfo.InvariantCulture, out var d))
return (int)d;
}
catch
{
// Swallow and return null
}
return null;
}
/// <summary>
/// Coerces a JToken to a boolean value, handling strings like "true", "1", etc.
/// </summary>
/// <param name="token">The JSON token to coerce</param>
/// <param name="defaultValue">Default value if coercion fails</param>
/// <returns>The coerced boolean value or default</returns>
public static bool CoerceBool(JToken token, bool defaultValue)
{
if (token == null || token.Type == JTokenType.Null)
return defaultValue;
try
{
if (token.Type == JTokenType.Boolean)
return token.Value<bool>();
var s = token.ToString().Trim().ToLowerInvariant();
if (s.Length == 0)
return defaultValue;
if (bool.TryParse(s, out var b))
return b;
if (s == "1" || s == "yes" || s == "on")
return true;
if (s == "0" || s == "no" || s == "off")
return false;
}
catch
{
// Swallow and return default
}
return defaultValue;
}
/// <summary>
/// Coerces a JToken to a nullable boolean value.
/// Returns null if token is null, empty, or cannot be parsed.
/// </summary>
/// <param name="token">The JSON token to coerce</param>
/// <returns>The coerced boolean value or null</returns>
public static bool? CoerceBoolNullable(JToken token)
{
if (token == null || token.Type == JTokenType.Null)
return null;
try
{
if (token.Type == JTokenType.Boolean)
return token.Value<bool>();
var s = token.ToString().Trim().ToLowerInvariant();
if (s.Length == 0)
return null;
if (bool.TryParse(s, out var b))
return b;
if (s == "1" || s == "yes" || s == "on")
return true;
if (s == "0" || s == "no" || s == "off")
return false;
}
catch
{
// Swallow and return null
}
return null;
}
/// <summary>
/// Coerces a JToken to a float value, handling strings and integers.
/// </summary>
/// <param name="token">The JSON token to coerce</param>
/// <param name="defaultValue">Default value if coercion fails</param>
/// <returns>The coerced float value or default</returns>
public static float CoerceFloat(JToken token, float defaultValue)
{
if (token == null || token.Type == JTokenType.Null)
return defaultValue;
try
{
if (token.Type == JTokenType.Float || token.Type == JTokenType.Integer)
return token.Value<float>();
var s = token.ToString().Trim();
if (s.Length == 0)
return defaultValue;
if (float.TryParse(s, NumberStyles.Float, CultureInfo.InvariantCulture, out var f))
return f;
}
catch
{
// Swallow and return default
}
return defaultValue;
}
/// <summary>
/// Coerces a JToken to a nullable float value.
/// Returns null if token is null, empty, or cannot be parsed.
/// </summary>
/// <param name="token">The JSON token to coerce</param>
/// <returns>The coerced float value or null</returns>
public static float? CoerceFloatNullable(JToken token)
{
if (token == null || token.Type == JTokenType.Null)
return null;
try
{
if (token.Type == JTokenType.Float || token.Type == JTokenType.Integer)
return token.Value<float>();
var s = token.ToString().Trim();
if (s.Length == 0)
return null;
if (float.TryParse(s, NumberStyles.Float, CultureInfo.InvariantCulture, out var f))
return f;
}
catch
{
// Swallow and return null
}
return null;
}
/// <summary>
/// Coerces a JToken to a string value, with null handling.
/// </summary>
/// <param name="token">The JSON token to coerce</param>
/// <param name="defaultValue">Default value if null or empty</param>
/// <returns>The string value or default</returns>
public static string CoerceString(JToken token, string defaultValue = null)
{
if (token == null || token.Type == JTokenType.Null)
return defaultValue;
var s = token.ToString();
return string.IsNullOrEmpty(s) ? defaultValue : s;
}
/// <summary>
/// Coerces a JToken to an enum value, handling strings.
/// </summary>
/// <typeparam name="T">The enum type</typeparam>
/// <param name="token">The JSON token to coerce</param>
/// <param name="defaultValue">Default value if coercion fails</param>
/// <returns>The coerced enum value or default</returns>
public static T CoerceEnum<T>(JToken token, T defaultValue) where T : struct, Enum
{
if (token == null || token.Type == JTokenType.Null)
return defaultValue;
try
{
var s = token.ToString().Trim();
if (s.Length == 0)
return defaultValue;
if (Enum.TryParse<T>(s, ignoreCase: true, out var result))
return result;
}
catch
{
// Swallow and return default
}
return defaultValue;
}
/// <summary>
/// Checks if a JToken represents a numeric value (integer or float).
/// Useful for validating JSON values before parsing.
/// </summary>
/// <param name="token">The JSON token to check</param>
/// <returns>True if the token is an integer or float, false otherwise</returns>
public static bool IsNumericToken(JToken token)
{
return token != null && (token.Type == JTokenType.Integer || token.Type == JTokenType.Float);
}
/// <summary>
/// Validates that an optional field in a JObject is numeric if present.
/// Used for dry-run validation of complex type formats.
/// </summary>
/// <param name="obj">The JSON object containing the field</param>
/// <param name="fieldName">The name of the field to validate</param>
/// <param name="error">Output error message if validation fails</param>
/// <returns>True if the field is absent, null, or numeric; false if present but non-numeric</returns>
public static bool ValidateNumericField(JObject obj, string fieldName, out string error)
{
error = null;
var token = obj[fieldName];
if (token == null || token.Type == JTokenType.Null)
{
return true; // Field not present, valid (will use default)
}
if (!IsNumericToken(token))
{
error = $"must be a number, got {token.Type}";
return false;
}
return true;
}
/// <summary>
/// Validates that an optional field in a JObject is an integer if present.
/// Used for dry-run validation of complex type formats.
/// </summary>
/// <param name="obj">The JSON object containing the field</param>
/// <param name="fieldName">The name of the field to validate</param>
/// <param name="error">Output error message if validation fails</param>
/// <returns>True if the field is absent, null, or integer; false if present but non-integer</returns>
public static bool ValidateIntegerField(JObject obj, string fieldName, out string error)
{
error = null;
var token = obj[fieldName];
if (token == null || token.Type == JTokenType.Null)
{
return true; // Field not present, valid
}
if (token.Type != JTokenType.Integer)
{
error = $"must be an integer, got {token.Type}";
return false;
}
return true;
}
/// <summary>
/// Normalizes a property name by removing separators and converting to camelCase.
/// Handles common naming variations from LLMs and humans.
/// Examples:
/// "Use Gravity" → "useGravity"
/// "is_kinematic" → "isKinematic"
/// "max-angular-velocity" → "maxAngularVelocity"
/// "Angular Drag" → "angularDrag"
/// </summary>
/// <param name="input">The property name to normalize</param>
/// <returns>The normalized camelCase property name</returns>
public static string NormalizePropertyName(string input)
{
if (string.IsNullOrEmpty(input))
return input;
// Split on common separators: space, underscore, dash
var parts = input.Split(new[] { ' ', '_', '-' }, StringSplitOptions.RemoveEmptyEntries);
if (parts.Length == 0)
return input;
// First word is lowercase, subsequent words are Title case (camelCase)
var sb = new System.Text.StringBuilder();
for (int i = 0; i < parts.Length; i++)
{
string part = parts[i];
if (i == 0)
{
// First word: all lowercase
sb.Append(part.ToLowerInvariant());
}
else
{
// Subsequent words: capitalize first letter, lowercase rest
sb.Append(char.ToUpperInvariant(part[0]));
if (part.Length > 1)
sb.Append(part.Substring(1).ToLowerInvariant());
}
}
return sb.ToString();
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: db54fbbe3ac7f429fbf808f72831374a
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
+347
View File
@@ -0,0 +1,347 @@
using System;
using System.IO;
using System.Net;
using System.Net.Sockets;
using System.Security.Cryptography;
using System.Text;
using System.Threading;
using MCPForUnity.Editor.Constants;
using Newtonsoft.Json;
using UnityEditor;
using UnityEngine;
namespace MCPForUnity.Editor.Helpers
{
/// <summary>
/// Manages dynamic port allocation and persistent storage for MCP for Unity
/// </summary>
public static class PortManager
{
private static bool IsDebugEnabled()
{
try { return EditorPrefs.GetBool(EditorPrefKeys.DebugLogs, false); }
catch { return false; }
}
private const int DefaultPort = 6400;
private const int MaxPortAttempts = 100;
private const string RegistryFileName = "unity-mcp-port.json";
[Serializable]
public class PortConfig
{
public int unity_port;
public string created_date;
public string project_path;
}
/// <summary>
/// Get the port to use from storage, or return the default if none has been saved yet.
/// </summary>
/// <returns>Port number to use</returns>
public static int GetPortWithFallback()
{
var storedConfig = GetStoredPortConfig();
if (storedConfig != null &&
storedConfig.unity_port > 0 &&
string.Equals(storedConfig.project_path ?? string.Empty, Application.dataPath ?? string.Empty, StringComparison.OrdinalIgnoreCase))
{
return storedConfig.unity_port;
}
return DefaultPort;
}
/// <summary>
/// Discover and save a new available port (used by Auto-Connect button)
/// </summary>
/// <returns>New available port</returns>
public static int DiscoverNewPort()
{
int newPort = FindAvailablePort();
SavePort(newPort);
if (IsDebugEnabled()) McpLog.Info($"Discovered and saved new port: {newPort}");
return newPort;
}
/// <summary>
/// How long the configured port may keep returning AddressAlreadyInUse before the
/// bridge gives up on it and discovers a new one. The common cause of a transient
/// conflict is our own previous listener whose OS socket has not been released yet
/// after a domain reload (slower on Windows/macOS); this window covers that race so
/// the bridge keeps the configured port instead of stranding the client on the
/// orphan (#1173).
/// </summary>
public const double BusyPortFallbackWindowSeconds = 3.0;
/// <summary>
/// Decide whether a configured port that keeps reporting AddressAlreadyInUse should be
/// abandoned for a freshly discovered port. Returns false (keep retrying the same port)
/// until it has been continuously busy for <see cref="BusyPortFallbackWindowSeconds"/>,
/// which distinguishes a domain-reload socket-release race from a foreign occupant (#1173).
/// </summary>
public static bool ShouldAbandonBusyPort(double busyForSeconds)
=> busyForSeconds >= BusyPortFallbackWindowSeconds;
/// <summary>
/// Persist a user-selected port and return the value actually stored.
/// If <paramref name="port"/> is unavailable, the next available port is chosen instead.
/// </summary>
public static int SetPreferredPort(int port)
{
if (port <= 0)
{
throw new ArgumentOutOfRangeException(nameof(port), "Port must be positive.");
}
if (!IsPortAvailable(port))
{
throw new InvalidOperationException($"Port {port} is already in use.");
}
SavePort(port);
return port;
}
/// <summary>
/// Find an available port starting from the default port
/// </summary>
/// <returns>Available port number</returns>
private static int FindAvailablePort()
{
// Always try default port first
if (IsPortAvailable(DefaultPort))
{
if (IsDebugEnabled()) McpLog.Info($"Using default port {DefaultPort}");
return DefaultPort;
}
if (IsDebugEnabled()) McpLog.Info($"Default port {DefaultPort} is in use, searching for alternative...");
// Search for alternatives
for (int port = DefaultPort + 1; port < DefaultPort + MaxPortAttempts; port++)
{
if (IsPortAvailable(port))
{
if (IsDebugEnabled()) McpLog.Info($"Found available port {port}");
return port;
}
}
throw new Exception($"No available ports found in range {DefaultPort}-{DefaultPort + MaxPortAttempts}");
}
/// <summary>
/// Check if a specific port is available for binding
/// </summary>
/// <param name="port">Port to check</param>
/// <returns>True if port is available</returns>
public static bool IsPortAvailable(int port)
{
try
{
var testListener = new TcpListener(IPAddress.Loopback, port);
#if UNITY_EDITOR_OSX
// On macOS, SO_REUSEADDR (the default) lets multiple processes bind the same
// port — including AssetImportWorkers. ExclusiveAddressUse prevents this so
// the test bind fails when another process already holds the port.
try { testListener.Server.ExclusiveAddressUse = true; } catch { }
#endif
testListener.Start();
testListener.Stop();
}
catch (SocketException)
{
return false;
}
return true;
}
/// <summary>
/// Check if a port is currently being used by MCP for Unity
/// This helps avoid unnecessary port changes when Unity itself is using the port
/// </summary>
/// <param name="port">Port to check</param>
/// <returns>True if port appears to be used by MCP for Unity</returns>
public static bool IsPortUsedByMCPForUnity(int port)
{
try
{
// Try to make a quick connection to see if it's an MCP for Unity server
using var client = new TcpClient();
var connectTask = client.ConnectAsync(IPAddress.Loopback, port);
if (connectTask.Wait(100)) // 100ms timeout
{
// If connection succeeded, it's likely the MCP for Unity server
return client.Connected;
}
return false;
}
catch
{
return false;
}
}
/// <summary>
/// Wait for a port to become available for a limited amount of time.
/// Used to bridge the gap during domain reload when the old listener
/// hasn't released the socket yet.
/// </summary>
private static bool WaitForPortRelease(int port, int timeoutMs)
{
int waited = 0;
const int step = 100;
while (waited < timeoutMs)
{
if (IsPortAvailable(port))
{
return true;
}
// If the port is in use by an MCP instance, continue waiting briefly
if (!IsPortUsedByMCPForUnity(port))
{
// In use by something else; don't keep waiting
return false;
}
Thread.Sleep(step);
waited += step;
}
return IsPortAvailable(port);
}
/// <summary>
/// Save port to persistent storage
/// </summary>
/// <param name="port">Port to save</param>
private static void SavePort(int port)
{
try
{
var portConfig = new PortConfig
{
unity_port = port,
created_date = DateTime.UtcNow.ToString("O"),
project_path = Application.dataPath
};
string registryDir = GetRegistryDirectory();
Directory.CreateDirectory(registryDir);
string registryFile = GetRegistryFilePath();
string json = JsonConvert.SerializeObject(portConfig, Formatting.Indented);
// Write to hashed, project-scoped file
File.WriteAllText(registryFile, json, new System.Text.UTF8Encoding(false));
// Also write to legacy stable filename to avoid hash/case drift across reloads
string legacy = Path.Combine(GetRegistryDirectory(), RegistryFileName);
File.WriteAllText(legacy, json, new System.Text.UTF8Encoding(false));
if (IsDebugEnabled()) McpLog.Info($"Saved port {port} to storage");
}
catch (Exception ex)
{
McpLog.Warn($"Could not save port to storage: {ex.Message}");
}
}
/// <summary>
/// Load port from persistent storage
/// </summary>
/// <returns>Stored port number, or 0 if not found</returns>
private static int LoadStoredPort()
{
try
{
string registryFile = GetRegistryFilePath();
if (!File.Exists(registryFile))
{
// Backwards compatibility: try the legacy file name
string legacy = Path.Combine(GetRegistryDirectory(), RegistryFileName);
if (!File.Exists(legacy))
{
return 0;
}
registryFile = legacy;
}
string json = File.ReadAllText(registryFile);
var portConfig = JsonConvert.DeserializeObject<PortConfig>(json);
return portConfig?.unity_port ?? 0;
}
catch (Exception ex)
{
McpLog.Warn($"Could not load port from storage: {ex.Message}");
return 0;
}
}
/// <summary>
/// Get the current stored port configuration
/// </summary>
/// <returns>Port configuration if exists, null otherwise</returns>
public static PortConfig GetStoredPortConfig()
{
try
{
string registryFile = GetRegistryFilePath();
if (!File.Exists(registryFile))
{
// Backwards compatibility: try the legacy file
string legacy = Path.Combine(GetRegistryDirectory(), RegistryFileName);
if (!File.Exists(legacy))
{
return null;
}
registryFile = legacy;
}
string json = File.ReadAllText(registryFile);
return JsonConvert.DeserializeObject<PortConfig>(json);
}
catch (Exception ex)
{
McpLog.Warn($"Could not load port config: {ex.Message}");
return null;
}
}
private static string GetRegistryDirectory()
{
return Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".unity-mcp");
}
private static string GetRegistryFilePath()
{
string dir = GetRegistryDirectory();
string hash = ComputeProjectHash(Application.dataPath);
string fileName = $"unity-mcp-port-{hash}.json";
return Path.Combine(dir, fileName);
}
private static string ComputeProjectHash(string input)
{
try
{
using SHA1 sha1 = SHA1.Create();
byte[] bytes = Encoding.UTF8.GetBytes(input ?? string.Empty);
byte[] hashBytes = sha1.ComputeHash(bytes);
var sb = new StringBuilder();
foreach (byte b in hashBytes)
{
sb.Append(b.ToString("x2"));
}
return sb.ToString()[..8]; // short, sufficient for filenames
}
catch
{
return "default";
}
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 28c39813a10b4331afc764a04089cbef
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,228 @@
using System;
using System.Collections.Generic;
using UnityEditor;
using UnityEngine;
namespace MCPForUnity.Editor.Helpers
{
/// <summary>
/// Provides common utility methods for working with Unity Prefab assets.
/// </summary>
public static class PrefabUtilityHelper
{
/// <summary>
/// Gets the GUID for a prefab asset path.
/// </summary>
/// <param name="assetPath">The Unity asset path (e.g., "Assets/Prefabs/MyPrefab.prefab")</param>
/// <returns>The GUID string, or null if the path is invalid.</returns>
public static string GetPrefabGUID(string assetPath)
{
if (string.IsNullOrEmpty(assetPath))
{
return null;
}
try
{
return AssetDatabase.AssetPathToGUID(assetPath);
}
catch (Exception ex)
{
McpLog.Warn($"Failed to get GUID for asset path '{assetPath}': {ex.Message}");
return null;
}
}
/// <summary>
/// Gets variant information if the prefab is a variant.
/// </summary>
/// <param name="prefabAsset">The prefab GameObject to check.</param>
/// <returns>A tuple containing (isVariant, parentPath, parentGuid).</returns>
public static (bool isVariant, string parentPath, string parentGuid) GetVariantInfo(GameObject prefabAsset)
{
if (prefabAsset == null)
{
return (false, null, null);
}
try
{
PrefabAssetType assetType = PrefabUtility.GetPrefabAssetType(prefabAsset);
if (assetType != PrefabAssetType.Variant)
{
return (false, null, null);
}
GameObject parentAsset = PrefabUtility.GetCorrespondingObjectFromSource(prefabAsset);
if (parentAsset == null)
{
return (true, null, null);
}
string parentPath = AssetDatabase.GetAssetPath(parentAsset);
string parentGuid = GetPrefabGUID(parentPath);
return (true, parentPath, parentGuid);
}
catch (Exception ex)
{
McpLog.Warn($"Failed to get variant info for '{prefabAsset.name}': {ex.Message}");
return (false, null, null);
}
}
/// <summary>
/// Gets the list of component type names on a GameObject.
/// </summary>
/// <param name="obj">The GameObject to inspect.</param>
/// <returns>A list of component type full names.</returns>
public static List<string> GetComponentTypeNames(GameObject obj)
{
var typeNames = new List<string>();
if (obj == null)
{
return typeNames;
}
try
{
var components = obj.GetComponents<Component>();
foreach (var component in components)
{
if (component != null)
{
typeNames.Add(component.GetType().FullName);
}
}
}
catch (Exception ex)
{
McpLog.Warn($"Failed to get component types for '{obj.name}': {ex.Message}");
}
return typeNames;
}
/// <summary>
/// Recursively counts all children in the hierarchy.
/// </summary>
/// <param name="transform">The root transform to count from.</param>
/// <returns>Total number of children in the hierarchy.</returns>
public static int CountChildrenRecursive(Transform transform)
{
if (transform == null)
{
return 0;
}
int count = transform.childCount;
for (int i = 0; i < transform.childCount; i++)
{
count += CountChildrenRecursive(transform.GetChild(i));
}
return count;
}
/// <summary>
/// Gets the source prefab path for a nested prefab instance.
/// </summary>
/// <param name="gameObject">The GameObject to check.</param>
/// <returns>The asset path of the source prefab, or null if not a nested prefab.</returns>
public static string GetNestedPrefabPath(GameObject gameObject)
{
if (gameObject == null || !PrefabUtility.IsAnyPrefabInstanceRoot(gameObject))
{
return null;
}
try
{
var sourcePrefab = PrefabUtility.GetCorrespondingObjectFromSource(gameObject);
if (sourcePrefab != null)
{
return AssetDatabase.GetAssetPath(sourcePrefab);
}
}
catch (Exception ex)
{
McpLog.Warn($"Failed to get nested prefab path for '{gameObject.name}': {ex.Message}");
}
return null;
}
/// <summary>
/// Gets the nesting depth of a prefab instance within the prefab hierarchy.
/// Returns 0 for main prefab root, 1 for first-level nested, 2 for second-level, etc.
/// Returns -1 for non-prefab-root objects.
/// </summary>
/// <param name="gameObject">The GameObject to analyze.</param>
/// <param name="mainPrefabRoot">The root transform of the main prefab asset.</param>
/// <returns>Nesting depth (0=main root, 1+=nested), or -1 if not a prefab root.</returns>
public static int GetPrefabNestingDepth(GameObject gameObject, Transform mainPrefabRoot)
{
if (gameObject == null)
return -1;
// Main prefab root
if (gameObject.transform == mainPrefabRoot)
return 0;
// Not a prefab instance root
if (!PrefabUtility.IsAnyPrefabInstanceRoot(gameObject))
return -1;
// Calculate depth by walking up the hierarchy
int depth = 0;
Transform current = gameObject.transform;
while (current != null && current != mainPrefabRoot)
{
if (PrefabUtility.IsAnyPrefabInstanceRoot(current.gameObject))
{
depth++;
}
current = current.parent;
}
return depth;
}
/// <summary>
/// Gets the parent prefab path for a nested prefab instance.
/// Returns null for main prefab root or non-prefab objects.
/// </summary>
/// <param name="gameObject">The GameObject to analyze.</param>
/// <param name="mainPrefabRoot">The root transform of the main prefab asset.</param>
/// <returns>The asset path of the parent prefab, or null if none.</returns>
public static string GetParentPrefabPath(GameObject gameObject, Transform mainPrefabRoot)
{
if (gameObject == null || gameObject.transform == mainPrefabRoot)
return null;
if (!PrefabUtility.IsAnyPrefabInstanceRoot(gameObject))
return null;
// Walk up the hierarchy to find the parent prefab instance
Transform current = gameObject.transform.parent;
while (current != null && current != mainPrefabRoot)
{
if (PrefabUtility.IsAnyPrefabInstanceRoot(current.gameObject))
{
return GetNestedPrefabPath(current.gameObject);
}
current = current.parent;
}
// Parent is the main prefab root - get its asset path
if (mainPrefabRoot != null)
{
return AssetDatabase.GetAssetPath(mainPrefabRoot.gameObject);
}
return null;
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: ebe2be77e64f4d4f811614b198210017
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,260 @@
using System;
using System.IO;
using System.Security.Cryptography;
using System.Text;
using MCPForUnity.Editor.Constants;
using UnityEditor;
using UnityEngine;
namespace MCPForUnity.Editor.Helpers
{
/// <summary>
/// Provides shared utilities for deriving deterministic project identity information
/// used by transport clients (hash, name, persistent session id).
/// </summary>
[InitializeOnLoad]
internal static class ProjectIdentityUtility
{
private const string SessionPrefKey = EditorPrefKeys.SessionId;
private static bool _legacyKeyCleared;
private static string _cachedProjectName = "Unknown";
private static string _cachedProjectHash = "default";
private static string _fallbackSessionId;
private static bool _cacheScheduled;
static ProjectIdentityUtility()
{
ScheduleCacheRefresh();
EditorApplication.projectChanged += ScheduleCacheRefresh;
}
private static void ScheduleCacheRefresh()
{
if (_cacheScheduled)
{
return;
}
_cacheScheduled = true;
EditorApplication.delayCall += CacheIdentityOnMainThread;
}
private static void CacheIdentityOnMainThread()
{
EditorApplication.delayCall -= CacheIdentityOnMainThread;
_cacheScheduled = false;
UpdateIdentityCache();
}
private static void UpdateIdentityCache()
{
try
{
string dataPath = Application.dataPath;
if (string.IsNullOrEmpty(dataPath))
{
return;
}
_cachedProjectHash = ComputeProjectHash(dataPath);
_cachedProjectName = ComputeProjectName(dataPath);
}
catch
{
// Ignore and keep defaults
}
}
/// <summary>
/// Returns the SHA1 hash of the current project path (truncated to 16 characters).
/// Matches the legacy hash used by the stdio bridge and server registry.
/// </summary>
public static string GetProjectHash()
{
EnsureIdentityCache();
return _cachedProjectHash;
}
/// <summary>
/// Returns a human friendly project name derived from the Assets directory path,
/// or "Unknown" if the name cannot be determined.
/// </summary>
public static string GetProjectName()
{
EnsureIdentityCache();
return _cachedProjectName;
}
private static string ComputeProjectHash(string dataPath)
{
try
{
using SHA1 sha1 = SHA1.Create();
byte[] bytes = Encoding.UTF8.GetBytes(dataPath);
byte[] hashBytes = sha1.ComputeHash(bytes);
var sb = new StringBuilder();
foreach (byte b in hashBytes)
{
sb.Append(b.ToString("x2"));
}
return sb.ToString(0, Math.Min(16, sb.Length)).ToLowerInvariant();
}
catch
{
return "default";
}
}
private static string ComputeProjectName(string dataPath)
{
try
{
string projectPath = dataPath;
projectPath = projectPath.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
if (projectPath.EndsWith("Assets", StringComparison.OrdinalIgnoreCase))
{
projectPath = projectPath[..^6].TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
}
string name = Path.GetFileName(projectPath);
return string.IsNullOrEmpty(name) ? "Unknown" : name;
}
catch
{
return "Unknown";
}
}
/// <summary>
/// Persists a server-assigned session id.
/// Safe to call from background threads.
/// </summary>
public static void SetSessionId(string sessionId)
{
if (string.IsNullOrEmpty(sessionId))
{
return;
}
EditorApplication.delayCall += () =>
{
try
{
string projectHash = GetProjectHash();
string projectSpecificKey = $"{SessionPrefKey}_{projectHash}";
EditorPrefs.SetString(projectSpecificKey, sessionId);
}
catch (Exception ex)
{
McpLog.Warn($"Failed to persist session ID: {ex.Message}");
}
};
}
/// <summary>
/// Retrieves a persistent session id for the plugin, creating one if absent.
/// The session id is unique per project (scoped by project hash).
/// </summary>
public static string GetOrCreateSessionId()
{
try
{
// Make the session ID project-specific by including the project hash in the key
string projectHash = GetProjectHash();
string projectSpecificKey = $"{SessionPrefKey}_{projectHash}";
string sessionId = EditorPrefs.GetString(projectSpecificKey, string.Empty);
if (string.IsNullOrEmpty(sessionId))
{
sessionId = Guid.NewGuid().ToString();
EditorPrefs.SetString(projectSpecificKey, sessionId);
}
return sessionId;
}
catch
{
// If prefs are unavailable (e.g. during batch tests) fall back to runtime guid.
if (string.IsNullOrEmpty(_fallbackSessionId))
{
_fallbackSessionId = Guid.NewGuid().ToString();
}
return _fallbackSessionId;
}
}
/// <summary>
/// Clears the persisted session id (mainly for tests).
/// </summary>
public static void ResetSessionId()
{
try
{
// Clear the project-specific session ID
string projectHash = GetProjectHash();
string projectSpecificKey = $"{SessionPrefKey}_{projectHash}";
if (EditorPrefs.HasKey(projectSpecificKey))
{
EditorPrefs.DeleteKey(projectSpecificKey);
}
if (!_legacyKeyCleared && EditorPrefs.HasKey(SessionPrefKey))
{
EditorPrefs.DeleteKey(SessionPrefKey);
_legacyKeyCleared = true;
}
_fallbackSessionId = null;
}
catch
{
// Ignore
}
}
private static void EnsureIdentityCache()
{
// When Application.dataPath is unavailable (e.g., batch mode) we fall back to
// hashing the current working directory/Assets path so each project still
// derives a deterministic, per-project session id rather than sharing "default".
if (!string.IsNullOrEmpty(_cachedProjectHash) && _cachedProjectHash != "default")
{
return;
}
UpdateIdentityCache();
if (!string.IsNullOrEmpty(_cachedProjectHash) && _cachedProjectHash != "default")
{
return;
}
string fallback = TryComputeFallbackProjectHash();
if (!string.IsNullOrEmpty(fallback))
{
_cachedProjectHash = fallback;
}
}
private static string TryComputeFallbackProjectHash()
{
try
{
string workingDirectory = Directory.GetCurrentDirectory();
if (string.IsNullOrEmpty(workingDirectory))
{
return "default";
}
// Normalise trailing separators so hashes remain stable
workingDirectory = workingDirectory.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
return ComputeProjectHash(Path.Combine(workingDirectory, "Assets"));
}
catch
{
return "default";
}
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 936e878ce1275453bae5e0cf03bd9d30
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,93 @@
using System;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using MCPForUnity.Editor.Helpers;
using UnityEditor;
using UnityEngine;
namespace MCPForUnity.Editor.Helpers
{
/// <summary>
/// Unified property conversion from JSON to Unity types.
/// Uses UnityJsonSerializer for consistent type handling.
/// </summary>
public static class PropertyConversion
{
/// <summary>
/// Converts a JToken to the specified target type using Unity type converters.
/// </summary>
/// <param name="token">The JSON token to convert</param>
/// <param name="targetType">The target type to convert to</param>
/// <returns>The converted object, or null if conversion fails</returns>
public static object ConvertToType(JToken token, Type targetType)
{
if (token == null || token.Type == JTokenType.Null)
{
if (targetType.IsValueType && Nullable.GetUnderlyingType(targetType) == null)
{
McpLog.Warn($"[PropertyConversion] Cannot assign null to non-nullable value type {targetType.Name}. Returning default value.");
return Activator.CreateInstance(targetType);
}
return null;
}
try
{
// Use the shared Unity serializer with custom converters
return token.ToObject(targetType, UnityJsonSerializer.Instance);
}
catch (Exception ex)
{
McpLog.Error($"Error converting token to {targetType.FullName}: {ex.Message}\nToken: {token.ToString(Formatting.None)}");
throw;
}
}
/// <summary>
/// Tries to convert a JToken to the specified target type.
/// Returns null and logs warning on failure (does not throw).
/// </summary>
public static object TryConvertToType(JToken token, Type targetType)
{
try
{
return ConvertToType(token, targetType);
}
catch
{
return null;
}
}
/// <summary>
/// Generic version of ConvertToType.
/// </summary>
public static T ConvertTo<T>(JToken token)
{
return (T)ConvertToType(token, typeof(T));
}
/// <summary>
/// Converts a JToken to a Unity asset by loading from path.
/// </summary>
/// <param name="token">JToken containing asset path</param>
/// <param name="targetType">Expected asset type</param>
/// <returns>The loaded asset, or null if not found</returns>
public static UnityEngine.Object LoadAssetFromToken(JToken token, Type targetType)
{
if (token == null || token.Type != JTokenType.String)
return null;
string assetPath = AssetPathUtility.SanitizeAssetPath(token.ToString());
UnityEngine.Object loadedAsset = AssetDatabase.LoadAssetAtPath(assetPath, targetType);
if (loadedAsset == null)
{
McpLog.Warn($"[PropertyConversion] Could not load asset of type {targetType.Name} from path: {assetPath}");
}
return loadedAsset;
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 4b4187d5b338a453fbe0baceaeea6bcd
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,445 @@
using System;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Rendering;
using UnityEditor;
namespace MCPForUnity.Editor.Helpers
{
internal static class RenderPipelineUtility
{
internal enum PipelineKind
{
BuiltIn,
Universal,
HighDefinition,
Custom
}
internal enum VFXComponentType
{
ParticleSystem,
LineRenderer,
TrailRenderer
}
private static Dictionary<string, Material> s_DefaultVFXMaterials = new Dictionary<string, Material>();
private static Dictionary<string, Material> s_DefaultSceneMaterials = new Dictionary<string, Material>();
private static readonly string[] BuiltInLitShaders = { "Standard", "Legacy Shaders/Diffuse" };
private static readonly string[] BuiltInUnlitShaders = { "Unlit/Color", "Unlit/Texture" };
private static readonly string[] BuiltInParticleShaders = { "Particles/Standard Unlit", "Particles/Alpha Blended", "Particles/Additive" };
private static readonly string[] UrpLitShaders = { "Universal Render Pipeline/Lit", "Universal Render Pipeline/Simple Lit" };
private static readonly string[] UrpUnlitShaders = { "Universal Render Pipeline/Unlit" };
private static readonly string[] UrpParticleShaders = {
"Universal Render Pipeline/Particles/Unlit",
"Universal Render Pipeline/Particles/Simple Lit",
"Universal Render Pipeline/Particles/Lit",
};
private static readonly string[] HdrpLitShaders = { "HDRP/Lit", "High Definition Render Pipeline/Lit" };
private static readonly string[] HdrpUnlitShaders = { "HDRP/Unlit", "High Definition Render Pipeline/Unlit" };
internal static PipelineKind GetActivePipeline()
{
var asset = GraphicsSettings.currentRenderPipeline;
if (asset == null)
{
return PipelineKind.BuiltIn;
}
var typeName = asset.GetType().FullName ?? string.Empty;
if (typeName.IndexOf("HighDefinition", StringComparison.OrdinalIgnoreCase) >= 0 ||
typeName.IndexOf("HDRP", StringComparison.OrdinalIgnoreCase) >= 0)
{
return PipelineKind.HighDefinition;
}
if (typeName.IndexOf("Universal", StringComparison.OrdinalIgnoreCase) >= 0 ||
typeName.IndexOf("URP", StringComparison.OrdinalIgnoreCase) >= 0)
{
return PipelineKind.Universal;
}
return PipelineKind.Custom;
}
internal static Shader ResolveShader(string requestedNameOrAlias)
{
var pipeline = GetActivePipeline();
if (!string.IsNullOrWhiteSpace(requestedNameOrAlias))
{
var alias = requestedNameOrAlias.Trim();
var aliasMatch = ResolveAlias(alias, pipeline);
if (aliasMatch != null)
{
WarnIfPipelineMismatch(aliasMatch.name, pipeline);
return aliasMatch;
}
var direct = Shader.Find(alias);
if (direct != null)
{
WarnIfPipelineMismatch(direct.name, pipeline);
return direct;
}
McpLog.Warn($"Shader '{alias}' not found. Falling back to {pipeline} defaults.");
}
var fallback = ResolveDefaultLitShader(pipeline)
?? ResolveDefaultLitShader(PipelineKind.BuiltIn)
?? Shader.Find("Unlit/Color");
if (fallback != null)
{
WarnIfPipelineMismatch(fallback.name, pipeline);
}
return fallback;
}
internal static Shader ResolveDefaultLitShader(PipelineKind pipeline)
{
return pipeline switch
{
PipelineKind.HighDefinition => TryFindShader(HdrpLitShaders) ?? TryFindShader(UrpLitShaders),
PipelineKind.Universal => TryFindShader(UrpLitShaders) ?? TryFindShader(HdrpLitShaders),
PipelineKind.Custom => TryFindShader(BuiltInLitShaders) ?? TryFindShader(UrpLitShaders) ?? TryFindShader(HdrpLitShaders),
_ => TryFindShader(BuiltInLitShaders) ?? Shader.Find("Unlit/Color")
};
}
internal static Shader ResolveDefaultUnlitShader(PipelineKind pipeline)
{
return pipeline switch
{
PipelineKind.HighDefinition => TryFindShader(HdrpUnlitShaders) ?? TryFindShader(UrpUnlitShaders) ?? TryFindShader(BuiltInUnlitShaders),
PipelineKind.Universal => TryFindShader(UrpUnlitShaders) ?? TryFindShader(HdrpUnlitShaders) ?? TryFindShader(BuiltInUnlitShaders),
PipelineKind.Custom => TryFindShader(BuiltInUnlitShaders) ?? TryFindShader(UrpUnlitShaders) ?? TryFindShader(HdrpUnlitShaders),
_ => TryFindShader(BuiltInUnlitShaders)
};
}
private static Shader ResolveAlias(string alias, PipelineKind pipeline)
{
if (string.Equals(alias, "lit", StringComparison.OrdinalIgnoreCase) ||
string.Equals(alias, "default", StringComparison.OrdinalIgnoreCase) ||
string.Equals(alias, "default_lit", StringComparison.OrdinalIgnoreCase) ||
string.Equals(alias, "standard", StringComparison.OrdinalIgnoreCase))
{
return ResolveDefaultLitShader(pipeline);
}
if (string.Equals(alias, "unlit", StringComparison.OrdinalIgnoreCase))
{
return ResolveDefaultUnlitShader(pipeline);
}
if (string.Equals(alias, "urp_lit", StringComparison.OrdinalIgnoreCase))
{
return TryFindShader(UrpLitShaders);
}
if (string.Equals(alias, "hdrp_lit", StringComparison.OrdinalIgnoreCase))
{
return TryFindShader(HdrpLitShaders);
}
if (string.Equals(alias, "built_in_lit", StringComparison.OrdinalIgnoreCase))
{
return TryFindShader(BuiltInLitShaders);
}
return null;
}
private static Shader TryFindShader(params string[] shaderNames)
{
foreach (var shaderName in shaderNames)
{
var shader = Shader.Find(shaderName);
if (shader != null)
{
return shader;
}
}
return null;
}
private static void WarnIfPipelineMismatch(string shaderName, PipelineKind activePipeline)
{
if (string.IsNullOrEmpty(shaderName))
{
return;
}
var lowerName = shaderName.ToLowerInvariant();
bool shaderLooksUrp = lowerName.Contains("universal render pipeline") || lowerName.Contains("urp/");
bool shaderLooksHdrp = lowerName.Contains("high definition render pipeline") || lowerName.Contains("hdrp/");
bool shaderLooksSrp = shaderLooksUrp || shaderLooksHdrp;
bool shaderLooksBuiltin = LooksLikeBuiltInShader(lowerName, shaderLooksSrp);
switch (activePipeline)
{
case PipelineKind.HighDefinition:
if (shaderLooksUrp)
{
McpLog.Warn($"[RenderPipelineUtility] Active pipeline is HDRP but shader '{shaderName}' looks URP-based. Asset may appear incorrect.");
}
else if (shaderLooksBuiltin && !shaderLooksHdrp)
{
McpLog.Warn($"[RenderPipelineUtility] Active pipeline is HDRP but shader '{shaderName}' looks Built-in. Consider using an HDRP shader for correct results.");
}
break;
case PipelineKind.Universal:
if (shaderLooksHdrp)
{
McpLog.Warn($"[RenderPipelineUtility] Active pipeline is URP but shader '{shaderName}' looks HDRP-based. Asset may appear incorrect.");
}
else if (shaderLooksBuiltin && !shaderLooksUrp)
{
McpLog.Warn($"[RenderPipelineUtility] Active pipeline is URP but shader '{shaderName}' looks Built-in. Consider using a URP shader for correct results.");
}
break;
case PipelineKind.BuiltIn:
if (shaderLooksSrp)
{
McpLog.Warn($"[RenderPipelineUtility] Active pipeline is Built-in but shader '{shaderName}' targets URP/HDRP. Asset may not render as expected.");
}
break;
}
}
internal static bool IsMaterialInvalidForActivePipeline(Material material, out string reason)
{
reason = null;
if (material == null)
{
reason = "missing_material";
return true;
}
Shader shader = material.shader;
if (shader == null)
{
reason = "missing_shader";
return true;
}
if (IsErrorShader(shader))
{
reason = "error_shader";
return true;
}
var pipeline = GetActivePipeline();
if (IsPipelineMismatch(shader.name, pipeline))
{
reason = "pipeline_mismatch";
return true;
}
return false;
}
private static bool IsErrorShader(Shader shader)
{
if (shader == null)
{
return true;
}
if (shader == Shader.Find("Hidden/InternalErrorShader"))
{
return true;
}
string shaderName = shader.name ?? string.Empty;
return shaderName.IndexOf("InternalErrorShader", StringComparison.OrdinalIgnoreCase) >= 0;
}
private static bool IsPipelineMismatch(string shaderName, PipelineKind activePipeline)
{
if (string.IsNullOrEmpty(shaderName))
{
return true;
}
string lowerName = shaderName.ToLowerInvariant();
bool shaderLooksUrp = lowerName.Contains("universal render pipeline") || lowerName.Contains("urp/");
bool shaderLooksHdrp = lowerName.Contains("high definition render pipeline") || lowerName.Contains("hdrp/");
bool shaderLooksSrp = shaderLooksUrp || shaderLooksHdrp;
bool shaderLooksBuiltin = LooksLikeBuiltInShader(lowerName, shaderLooksSrp);
return activePipeline switch
{
PipelineKind.HighDefinition => shaderLooksUrp || (shaderLooksBuiltin && !shaderLooksHdrp),
PipelineKind.Universal => shaderLooksHdrp || (shaderLooksBuiltin && !shaderLooksUrp),
PipelineKind.BuiltIn => shaderLooksSrp,
_ => false,
};
}
internal static Material GetOrCreateDefaultVFXMaterial(VFXComponentType componentType)
{
var pipeline = GetActivePipeline();
string cacheKey = $"{pipeline}_{componentType}";
if (s_DefaultVFXMaterials.TryGetValue(cacheKey, out Material cachedMaterial) && cachedMaterial != null)
{
return cachedMaterial;
}
Material material = null;
if (pipeline == PipelineKind.BuiltIn)
{
string builtinPath = componentType == VFXComponentType.ParticleSystem
? "Default-Particle.mat"
: "Default-Line.mat";
material = AssetDatabase.GetBuiltinExtraResource<Material>(builtinPath);
}
if (material == null)
{
Shader shader = ResolveDefaultVFXShader(pipeline, componentType);
if (shader == null)
{
shader = Shader.Find("Unlit/Color");
}
if (shader != null)
{
material = new Material(shader);
material.name = $"Auto_Default_{componentType}_{pipeline}";
// Set default color (white is standard for VFX)
if (material.HasProperty("_Color"))
{
material.SetColor("_Color", Color.white);
}
if (material.HasProperty("_BaseColor"))
{
material.SetColor("_BaseColor", Color.white);
}
if (componentType == VFXComponentType.ParticleSystem)
{
material.renderQueue = 3000;
if (material.HasProperty("_Mode"))
{
material.SetFloat("_Mode", 2);
}
if (material.HasProperty("_SrcBlend"))
{
material.SetFloat("_SrcBlend", (float)UnityEngine.Rendering.BlendMode.SrcAlpha);
}
if (material.HasProperty("_DstBlend"))
{
material.SetFloat("_DstBlend", (float)UnityEngine.Rendering.BlendMode.OneMinusSrcAlpha);
}
if (material.HasProperty("_ZWrite"))
{
material.SetFloat("_ZWrite", 0);
}
}
McpLog.Info($"[RenderPipelineUtility] Created default VFX material for {componentType} using {shader.name}");
}
}
if (material != null)
{
s_DefaultVFXMaterials[cacheKey] = material;
}
return material;
}
private static Shader ResolveDefaultVFXShader(PipelineKind pipeline, VFXComponentType componentType)
{
if (componentType == VFXComponentType.ParticleSystem)
{
return pipeline switch
{
PipelineKind.Universal => TryFindShader(UrpParticleShaders) ?? ResolveDefaultUnlitShader(pipeline),
PipelineKind.HighDefinition => TryFindShader(HdrpUnlitShaders) ?? ResolveDefaultUnlitShader(pipeline),
PipelineKind.BuiltIn => TryFindShader(BuiltInParticleShaders) ?? ResolveDefaultUnlitShader(pipeline),
PipelineKind.Custom => TryFindShader(UrpParticleShaders)
?? TryFindShader(BuiltInParticleShaders)
?? TryFindShader(HdrpUnlitShaders)
?? ResolveDefaultUnlitShader(pipeline),
_ => ResolveDefaultUnlitShader(pipeline),
};
}
return ResolveDefaultUnlitShader(pipeline);
}
private static bool LooksLikeBuiltInShader(string lowerName, bool shaderLooksSrp)
{
if (string.IsNullOrEmpty(lowerName))
{
return false;
}
if (lowerName == "standard" ||
lowerName.StartsWith("legacy shaders/", StringComparison.Ordinal) ||
lowerName.StartsWith("mobile/", StringComparison.Ordinal))
{
return true;
}
// Built-in non-SRP shader families commonly seen on particles/old content.
if (!shaderLooksSrp &&
(lowerName.StartsWith("particles/", StringComparison.Ordinal) ||
lowerName.StartsWith("unlit/", StringComparison.Ordinal)))
{
return true;
}
return false;
}
internal static Material GetOrCreateDefaultSceneMaterial()
{
var pipeline = GetActivePipeline();
string cacheKey = $"{pipeline}_scene";
if (s_DefaultSceneMaterials.TryGetValue(cacheKey, out Material cached) && cached != null)
{
return cached;
}
Material material = null;
Shader shader = ResolveDefaultLitShader(pipeline) ?? ResolveDefaultUnlitShader(pipeline);
if (shader == null)
{
shader = Shader.Find("Unlit/Color");
}
if (shader != null)
{
material = new Material(shader);
material.name = $"Auto_Default_Scene_{pipeline}";
if (material.HasProperty("_Color"))
{
material.SetColor("_Color", Color.white);
}
if (material.HasProperty("_BaseColor"))
{
material.SetColor("_BaseColor", Color.white);
}
McpLog.Info($"[RenderPipelineUtility] Created default scene material using {shader.name}");
}
if (material != null)
{
s_DefaultSceneMaterials[cacheKey] = material;
}
return material;
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 5a0a1cfd55ab4bc99c74c52854f6bdf3
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,321 @@
using System;
using System.Collections.Generic;
using Newtonsoft.Json.Linq;
using UnityEngine;
using UnityEditor;
namespace MCPForUnity.Editor.Helpers
{
/// <summary>
/// Utility class for common Renderer property operations.
/// Used by ManageVFX for ParticleSystem, LineRenderer, and TrailRenderer components.
/// </summary>
public static class RendererHelpers
{
public readonly struct EnsureMaterialResult
{
public EnsureMaterialResult(bool materialReplaced, string replacementReason)
{
MaterialReplaced = materialReplaced;
ReplacementReason = replacementReason ?? string.Empty;
}
public bool MaterialReplaced { get; }
public string ReplacementReason { get; }
}
/// <summary>
/// Ensures a renderer has a material assigned. If not, auto-assigns a default material
/// based on the render pipeline and component type.
/// </summary>
/// <param name="renderer">The renderer to check</param>
public static EnsureMaterialResult EnsureMaterial(Renderer renderer)
{
if (renderer == null)
{
return new EnsureMaterialResult(false, "renderer_missing");
}
var existingMaterial = renderer.sharedMaterial;
string replacementReason = string.Empty;
bool pipelineInvalid = RenderPipelineUtility.IsMaterialInvalidForActivePipeline(existingMaterial, out string pipelineReason);
if (existingMaterial != null && !pipelineInvalid && IsUsableMaterial(existingMaterial))
{
return new EnsureMaterialResult(false, string.Empty);
}
if (existingMaterial != null)
{
var shaderName = existingMaterial.shader != null ? existingMaterial.shader.name : "(null)";
McpLog.Warn($"[RendererHelpers] Replacing invalid VFX material '{existingMaterial.name}' (shader: {shaderName}).");
replacementReason = !string.IsNullOrWhiteSpace(pipelineReason) ? pipelineReason : "invalid_material";
}
else
{
replacementReason = "missing_material";
}
Material replacement = ResolveReplacementMaterial(renderer);
if (replacement != null)
{
Undo.RecordObject(renderer, "Assign default renderer material");
renderer.sharedMaterial = replacement;
EditorUtility.SetDirty(renderer);
return new EnsureMaterialResult(true, replacementReason);
}
return new EnsureMaterialResult(false, replacementReason);
}
private static bool IsUsableMaterial(Material material)
{
if (material == null)
{
return false;
}
var shader = material.shader;
if (shader == null)
{
return false;
}
var shaderName = shader.name ?? string.Empty;
if (shaderName.IndexOf("InternalErrorShader", StringComparison.OrdinalIgnoreCase) >= 0)
{
return false;
}
return shader.isSupported;
}
private static Material ResolveReplacementMaterial(Renderer renderer)
{
if (renderer is ParticleSystemRenderer)
{
return RenderPipelineUtility.GetOrCreateDefaultVFXMaterial(RenderPipelineUtility.VFXComponentType.ParticleSystem);
}
if (renderer is LineRenderer)
{
return RenderPipelineUtility.GetOrCreateDefaultVFXMaterial(RenderPipelineUtility.VFXComponentType.LineRenderer);
}
if (renderer is TrailRenderer)
{
return RenderPipelineUtility.GetOrCreateDefaultVFXMaterial(RenderPipelineUtility.VFXComponentType.TrailRenderer);
}
return RenderPipelineUtility.GetOrCreateDefaultSceneMaterial();
}
/// <summary>
/// Applies common Renderer properties (shadows, lighting, probes, sorting, rendering layer).
/// Used by ParticleSetRenderer, LineSetProperties, TrailSetProperties.
/// </summary>
public static void ApplyCommonRendererProperties(Renderer renderer, JObject @params, List<string> changes)
{
// Shadows
if (@params["shadowCastingMode"] != null && Enum.TryParse<UnityEngine.Rendering.ShadowCastingMode>(@params["shadowCastingMode"].ToString(), true, out var shadowMode))
{ renderer.shadowCastingMode = shadowMode; changes.Add("shadowCastingMode"); }
if (@params["receiveShadows"] != null) { renderer.receiveShadows = @params["receiveShadows"].ToObject<bool>(); changes.Add("receiveShadows"); }
// Note: shadowBias is only available on specific renderer types (e.g., ParticleSystemRenderer), not base Renderer
// Lighting and probes
if (@params["lightProbeUsage"] != null && Enum.TryParse<UnityEngine.Rendering.LightProbeUsage>(@params["lightProbeUsage"].ToString(), true, out var probeUsage))
{ renderer.lightProbeUsage = probeUsage; changes.Add("lightProbeUsage"); }
if (@params["reflectionProbeUsage"] != null && Enum.TryParse<UnityEngine.Rendering.ReflectionProbeUsage>(@params["reflectionProbeUsage"].ToString(), true, out var reflectionUsage))
{ renderer.reflectionProbeUsage = reflectionUsage; changes.Add("reflectionProbeUsage"); }
// Motion vectors
if (@params["motionVectorGenerationMode"] != null && Enum.TryParse<MotionVectorGenerationMode>(@params["motionVectorGenerationMode"].ToString(), true, out var motionMode))
{ renderer.motionVectorGenerationMode = motionMode; changes.Add("motionVectorGenerationMode"); }
// Sorting
if (@params["sortingOrder"] != null) { renderer.sortingOrder = @params["sortingOrder"].ToObject<int>(); changes.Add("sortingOrder"); }
if (@params["sortingLayerName"] != null) { renderer.sortingLayerName = @params["sortingLayerName"].ToString(); changes.Add("sortingLayerName"); }
if (@params["sortingLayerID"] != null) { renderer.sortingLayerID = @params["sortingLayerID"].ToObject<int>(); changes.Add("sortingLayerID"); }
// Rendering layer mask (for SRP)
if (@params["renderingLayerMask"] != null) { renderer.renderingLayerMask = @params["renderingLayerMask"].ToObject<uint>(); changes.Add("renderingLayerMask"); }
}
/// <summary>
/// Gets common Renderer properties for GetInfo methods.
/// </summary>
public static object GetCommonRendererInfo(Renderer renderer)
{
return new
{
shadowCastingMode = renderer.shadowCastingMode.ToString(),
receiveShadows = renderer.receiveShadows,
lightProbeUsage = renderer.lightProbeUsage.ToString(),
reflectionProbeUsage = renderer.reflectionProbeUsage.ToString(),
sortingOrder = renderer.sortingOrder,
sortingLayerName = renderer.sortingLayerName,
renderingLayerMask = renderer.renderingLayerMask
};
}
/// <summary>
/// Sets width properties for LineRenderer or TrailRenderer.
/// </summary>
/// <param name="params">JSON parameters containing width, startWidth, endWidth, widthCurve, widthMultiplier</param>
/// <param name="changes">List to track changed properties</param>
/// <param name="setStartWidth">Action to set start width</param>
/// <param name="setEndWidth">Action to set end width</param>
/// <param name="setWidthCurve">Action to set width curve</param>
/// <param name="setWidthMultiplier">Action to set width multiplier</param>
/// <param name="parseAnimationCurve">Function to parse animation curve from JToken</param>
public static void ApplyWidthProperties(JObject @params, List<string> changes,
Action<float> setStartWidth, Action<float> setEndWidth,
Action<AnimationCurve> setWidthCurve, Action<float> setWidthMultiplier,
Func<JToken, float, AnimationCurve> parseAnimationCurve)
{
if (@params["width"] != null)
{
float w = @params["width"].ToObject<float>();
setStartWidth(w);
setEndWidth(w);
changes.Add("width");
}
if (@params["startWidth"] != null) { setStartWidth(@params["startWidth"].ToObject<float>()); changes.Add("startWidth"); }
if (@params["endWidth"] != null) { setEndWidth(@params["endWidth"].ToObject<float>()); changes.Add("endWidth"); }
if (@params["widthCurve"] != null) { setWidthCurve(parseAnimationCurve(@params["widthCurve"], 1f)); changes.Add("widthCurve"); }
if (@params["widthMultiplier"] != null) { setWidthMultiplier(@params["widthMultiplier"].ToObject<float>()); changes.Add("widthMultiplier"); }
}
/// <summary>
/// Sets color properties for LineRenderer or TrailRenderer.
/// </summary>
/// <param name="params">JSON parameters containing color, startColor, endColor, gradient</param>
/// <param name="changes">List to track changed properties</param>
/// <param name="setStartColor">Action to set start color</param>
/// <param name="setEndColor">Action to set end color</param>
/// <param name="setGradient">Action to set gradient</param>
/// <param name="parseColor">Function to parse color from JToken</param>
/// <param name="parseGradient">Function to parse gradient from JToken</param>
/// <param name="fadeEndAlpha">If true, sets end color alpha to 0 when using single color</param>
public static void ApplyColorProperties(JObject @params, List<string> changes,
Action<Color> setStartColor, Action<Color> setEndColor,
Action<Gradient> setGradient,
Func<JToken, Color> parseColor, Func<JToken, Gradient> parseGradient,
bool fadeEndAlpha = false)
{
if (@params["color"] != null)
{
Color c = parseColor(@params["color"]);
setStartColor(c);
setEndColor(fadeEndAlpha ? new Color(c.r, c.g, c.b, 0f) : c);
changes.Add("color");
}
if (@params["startColor"] != null) { setStartColor(parseColor(@params["startColor"])); changes.Add("startColor"); }
if (@params["endColor"] != null) { setEndColor(parseColor(@params["endColor"])); changes.Add("endColor"); }
if (@params["gradient"] != null) { setGradient(parseGradient(@params["gradient"])); changes.Add("gradient"); }
}
/// <summary>
/// Sets material for a Renderer.
/// </summary>
/// <param name="renderer">The renderer to set material on</param>
/// <param name="params">JSON parameters containing materialPath</param>
/// <param name="undoName">Name for the undo operation</param>
/// <param name="findMaterial">Function to find material by path</param>
/// <param name="autoAssignDefault">If true, auto-assigns default material when materialPath is not provided</param>
public static object SetRendererMaterial(Renderer renderer, JObject @params, string undoName, Func<string, Material> findMaterial, bool autoAssignDefault = true)
{
if (renderer == null) return new { success = false, message = "Renderer not found" };
string path = @params["materialPath"]?.ToString();
if (string.IsNullOrEmpty(path))
{
if (!autoAssignDefault)
{
return new { success = false, message = "materialPath required" };
}
RenderPipelineUtility.VFXComponentType? componentType = null;
if (renderer is ParticleSystemRenderer)
{
componentType = RenderPipelineUtility.VFXComponentType.ParticleSystem;
}
else if (renderer is LineRenderer)
{
componentType = RenderPipelineUtility.VFXComponentType.LineRenderer;
}
else if (renderer is TrailRenderer)
{
componentType = RenderPipelineUtility.VFXComponentType.TrailRenderer;
}
if (componentType.HasValue)
{
Material defaultMat = RenderPipelineUtility.GetOrCreateDefaultVFXMaterial(componentType.Value);
if (defaultMat != null)
{
Undo.RecordObject(renderer, undoName);
renderer.sharedMaterial = defaultMat;
EditorUtility.SetDirty(renderer);
return new { success = true, message = $"Auto-assigned default material: {defaultMat.name}" };
}
}
return new { success = false, message = "materialPath required" };
}
Material mat = findMaterial(path);
if (mat == null) return new { success = false, message = $"Material not found: {path}" };
Undo.RecordObject(renderer, undoName);
renderer.sharedMaterial = mat;
EditorUtility.SetDirty(renderer);
return new { success = true, message = $"Set material to {mat.name}" };
}
/// <summary>
/// Applies Line/Trail specific properties (loop, alignment, textureMode, etc.).
/// </summary>
public static void ApplyLineTrailProperties(JObject @params, List<string> changes,
Action<bool> setLoop, Action<bool> setUseWorldSpace,
Action<int> setNumCornerVertices, Action<int> setNumCapVertices,
Action<LineAlignment> setAlignment, Action<LineTextureMode> setTextureMode,
Action<bool> setGenerateLightingData)
{
if (@params["loop"] != null && setLoop != null) { setLoop(@params["loop"].ToObject<bool>()); changes.Add("loop"); }
if (@params["useWorldSpace"] != null && setUseWorldSpace != null) { setUseWorldSpace(@params["useWorldSpace"].ToObject<bool>()); changes.Add("useWorldSpace"); }
if (@params["numCornerVertices"] != null && setNumCornerVertices != null) { setNumCornerVertices(@params["numCornerVertices"].ToObject<int>()); changes.Add("numCornerVertices"); }
if (@params["numCapVertices"] != null && setNumCapVertices != null) { setNumCapVertices(@params["numCapVertices"].ToObject<int>()); changes.Add("numCapVertices"); }
if (@params["alignment"] != null && setAlignment != null && Enum.TryParse<LineAlignment>(@params["alignment"].ToString(), true, out var align)) { setAlignment(align); changes.Add("alignment"); }
if (@params["textureMode"] != null && setTextureMode != null && Enum.TryParse<LineTextureMode>(@params["textureMode"].ToString(), true, out var texMode)) { setTextureMode(texMode); changes.Add("textureMode"); }
if (@params["generateLightingData"] != null && setGenerateLightingData != null) { setGenerateLightingData(@params["generateLightingData"].ToObject<bool>()); changes.Add("generateLightingData"); }
}
/// <summary>
/// Applies sensible default values to a newly-created ParticleSystem.
/// Unity's raw defaults (startSize=1, startSpeed=5, startLifetime=5, maxParticles=1000)
/// produce oversized particle clouds in most scenes. These gentler defaults are a better
/// starting point that callers can override with particle_set_main or set_property.
/// </summary>
public static void SetSensibleParticleDefaults(ParticleSystem ps)
{
if (ps == null) return;
var main = ps.main;
main.startSize = new ParticleSystem.MinMaxCurve(0.1f);
main.startSpeed = new ParticleSystem.MinMaxCurve(1f);
main.startLifetime = new ParticleSystem.MinMaxCurve(2f);
main.maxParticles = 100;
main.playOnAwake = false;
main.simulationSpace = ParticleSystemSimulationSpace.World;
var emission = ps.emission;
emission.rateOverTime = new ParticleSystem.MinMaxCurve(20f);
var shape = ps.shape;
shape.radius = 0.25f;
}
}
}
@@ -0,0 +1,12 @@
fileFormatVersion: 2
guid: 8f3a7e2d5c1b4a9e6d0f8c3b2a1e5d7c
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
+108
View File
@@ -0,0 +1,108 @@
using Newtonsoft.Json;
namespace MCPForUnity.Editor.Helpers
{
public interface IMcpResponse
{
[JsonProperty("success")]
bool Success { get; }
}
public sealed class SuccessResponse : IMcpResponse
{
[JsonProperty("success")]
public bool Success => true;
[JsonIgnore]
public bool success => Success; // Backward-compatible casing for reflection-based tests
[JsonProperty("message")]
public string Message { get; }
[JsonProperty("data", NullValueHandling = NullValueHandling.Ignore)]
public object Data { get; }
[JsonIgnore]
public object data => Data;
public SuccessResponse(string message, object data = null)
{
Message = message;
Data = data;
}
}
public sealed class ErrorResponse : IMcpResponse
{
[JsonProperty("success")]
public bool Success => false;
[JsonIgnore]
public bool success => Success; // Backward-compatible casing for reflection-based tests
[JsonProperty("code", NullValueHandling = NullValueHandling.Ignore)]
public string Code { get; }
[JsonIgnore]
public string code => Code;
[JsonProperty("error")]
public string Error { get; }
[JsonIgnore]
public string error => Error;
[JsonProperty("data", NullValueHandling = NullValueHandling.Ignore)]
public object Data { get; }
[JsonIgnore]
public object data => Data;
public ErrorResponse(string messageOrCode, object data = null)
{
Code = messageOrCode;
Error = messageOrCode;
Data = data;
}
}
public sealed class PendingResponse : IMcpResponse
{
[JsonProperty("success")]
public bool Success => true;
[JsonIgnore]
public bool success => Success; // Backward-compatible casing for reflection-based tests
[JsonProperty("_mcp_status")]
public string Status => "pending";
[JsonIgnore]
public string _mcp_status => Status;
[JsonProperty("_mcp_poll_interval")]
public double PollIntervalSeconds { get; }
[JsonIgnore]
public double _mcp_poll_interval => PollIntervalSeconds;
[JsonProperty("message", NullValueHandling = NullValueHandling.Ignore)]
public string Message { get; }
[JsonIgnore]
public string message => Message;
[JsonProperty("data", NullValueHandling = NullValueHandling.Ignore)]
public object Data { get; }
[JsonIgnore]
public object data => Data;
public PendingResponse(string message = "", double pollIntervalSeconds = 1.0, object data = null)
{
Message = string.IsNullOrEmpty(message) ? null : message;
PollIntervalSeconds = pollIntervalSeconds;
Data = data;
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: e8a93c1537f546c6b3b24b903d5f9395
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,49 @@
using MCPForUnity.Runtime.Helpers;
using UnityEditor;
namespace MCPForUnity.Editor.Helpers
{
/// <summary>
/// Per-user EditorPrefs override for the default screenshot output folder.
/// Resolution priority used by callers:
/// 1. Per-call <c>output_folder</c> tool parameter
/// 2. <see cref="DefaultFolder"/> (this preference)
/// 3. <see cref="ScreenshotUtility.DefaultFolder"/> built-in fallback
/// </summary>
public static class ScreenshotPreferences
{
public const string EditorPrefsKey = "MCPForUnity_ScreenshotsFolder";
/// <summary>
/// User-configured default folder, or empty string when unset.
/// Stored as a project-relative path (e.g. "Assets/Screenshots", "Captures").
/// </summary>
public static string DefaultFolder
{
get => EditorPrefs.GetString(EditorPrefsKey, string.Empty);
set
{
if (string.IsNullOrWhiteSpace(value))
{
EditorPrefs.DeleteKey(EditorPrefsKey);
}
else
{
EditorPrefs.SetString(EditorPrefsKey, value.Trim());
}
}
}
/// <summary>
/// Resolves the effective folder: caller override → user pref → built-in default.
/// Returns a project-relative path string suitable for <see cref="ScreenshotUtility.ResolveFolderAbsolute"/>.
/// </summary>
public static string Resolve(string callerOverride)
{
if (!string.IsNullOrWhiteSpace(callerOverride)) return callerOverride.Trim();
string pref = DefaultFolder;
if (!string.IsNullOrWhiteSpace(pref)) return pref;
return ScreenshotUtility.DefaultFolder;
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 7c4f1a8e9b3d4d2eaf5c1d7b9e2f4a6c
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,73 @@
using System;
using System.Linq;
using System.Text.RegularExpressions;
namespace MCPForUnity.Editor.Helpers
{
/// <summary>
/// Utility class for converting between naming conventions (snake_case, camelCase).
/// Consolidates previously duplicated implementations from ToolParams, ManageVFX,
/// BatchExecute, CommandRegistry, and ToolDiscoveryService.
/// </summary>
public static class StringCaseUtility
{
/// <summary>
/// Checks whether a type belongs to the built-in MCP for Unity package.
/// Returns true when the type's namespace starts with
/// <paramref name="builtInNamespacePrefix"/> or its assembly is MCPForUnity.Editor.
/// </summary>
public static bool IsBuiltInMcpType(Type type, string assemblyName, string builtInNamespacePrefix)
{
if (type != null && !string.IsNullOrEmpty(type.Namespace)
&& type.Namespace.StartsWith(builtInNamespacePrefix, StringComparison.Ordinal))
{
return true;
}
if (!string.IsNullOrEmpty(assemblyName)
&& assemblyName.Equals("MCPForUnity.Editor", StringComparison.Ordinal))
{
return true;
}
return false;
}
/// <summary>
/// Converts a camelCase string to snake_case.
/// Example: "searchMethod" -> "search_method", "param1Value" -> "param1_value"
/// </summary>
/// <param name="str">The camelCase string to convert</param>
/// <returns>The snake_case equivalent, or original string if null/empty</returns>
public static string ToSnakeCase(string str)
{
if (string.IsNullOrEmpty(str))
return str;
return Regex.Replace(str, "([a-z0-9])([A-Z])", "$1_$2").ToLowerInvariant();
}
/// <summary>
/// Converts a snake_case string to camelCase.
/// Example: "search_method" -> "searchMethod"
/// </summary>
/// <param name="str">The snake_case string to convert</param>
/// <returns>The camelCase equivalent, or original string if null/empty or no underscores</returns>
public static string ToCamelCase(string str)
{
if (string.IsNullOrEmpty(str) || !str.Contains("_"))
return str;
var parts = str.Split('_');
if (parts.Length == 0)
return str;
// First part stays lowercase, rest get capitalized
var first = parts[0];
var rest = string.Concat(parts.Skip(1).Select(part =>
string.IsNullOrEmpty(part) ? "" : char.ToUpperInvariant(part[0]) + part.Substring(1)));
return first + rest;
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: f22b312318ade42c4bb6b5dfddacecfa
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,226 @@
using System;
using System.Collections.Generic;
using System.Threading;
using MCPForUnity.Editor.Constants;
using MCPForUnity.Editor.Services.Transport.Transports;
using UnityEngine;
namespace MCPForUnity.Editor.Helpers
{
/// <summary>
/// Unity Bridge telemetry helper for collecting usage analytics
/// Following privacy-first approach with easy opt-out mechanisms
/// </summary>
public static class TelemetryHelper
{
private const string TELEMETRY_DISABLED_KEY = EditorPrefKeys.TelemetryDisabled;
private const string CUSTOMER_UUID_KEY = EditorPrefKeys.CustomerUuid;
private static Action<Dictionary<string, object>> s_sender;
/// <summary>
/// Check if telemetry is enabled (can be disabled via Environment Variable or EditorPrefs)
/// </summary>
public static bool IsEnabled
{
get
{
// Check environment variables first
var envDisable = Environment.GetEnvironmentVariable("DISABLE_TELEMETRY");
if (!string.IsNullOrEmpty(envDisable) &&
(envDisable.ToLower() == "true" || envDisable == "1"))
{
return false;
}
var unityMcpDisable = Environment.GetEnvironmentVariable("UNITY_MCP_DISABLE_TELEMETRY");
if (!string.IsNullOrEmpty(unityMcpDisable) &&
(unityMcpDisable.ToLower() == "true" || unityMcpDisable == "1"))
{
return false;
}
// Honor protocol-wide opt-out as well
var mcpDisable = Environment.GetEnvironmentVariable("MCP_DISABLE_TELEMETRY");
if (!string.IsNullOrEmpty(mcpDisable) &&
(mcpDisable.Equals("true", StringComparison.OrdinalIgnoreCase) || mcpDisable == "1"))
{
return false;
}
// Check EditorPrefs
return !UnityEditor.EditorPrefs.GetBool(TELEMETRY_DISABLED_KEY, false);
}
}
/// <summary>
/// Get or generate customer UUID for anonymous tracking
/// </summary>
public static string GetCustomerUUID()
{
var uuid = UnityEditor.EditorPrefs.GetString(CUSTOMER_UUID_KEY, "");
if (string.IsNullOrEmpty(uuid))
{
uuid = System.Guid.NewGuid().ToString();
UnityEditor.EditorPrefs.SetString(CUSTOMER_UUID_KEY, uuid);
}
return uuid;
}
/// <summary>
/// Disable telemetry (stored in EditorPrefs)
/// </summary>
public static void DisableTelemetry()
{
UnityEditor.EditorPrefs.SetBool(TELEMETRY_DISABLED_KEY, true);
}
/// <summary>
/// Enable telemetry (stored in EditorPrefs)
/// </summary>
public static void EnableTelemetry()
{
UnityEditor.EditorPrefs.SetBool(TELEMETRY_DISABLED_KEY, false);
}
/// <summary>
/// Send telemetry data to MCP server for processing
/// This is a lightweight bridge - the actual telemetry logic is in the MCP server
/// </summary>
public static void RecordEvent(string eventType, Dictionary<string, object> data = null)
{
if (!IsEnabled)
return;
try
{
var telemetryData = new Dictionary<string, object>
{
["event_type"] = eventType,
["timestamp"] = DateTimeOffset.UtcNow.ToUnixTimeSeconds(),
["customer_uuid"] = GetCustomerUUID(),
["unity_version"] = Application.unityVersion,
["platform"] = Application.platform.ToString(),
["source"] = "unity_bridge"
};
if (data != null)
{
telemetryData["data"] = data;
}
// Send to MCP server via existing bridge communication
// The MCP server will handle actual telemetry transmission
SendTelemetryToMcpServer(telemetryData);
}
catch (Exception e)
{
// Never let telemetry errors interfere with functionality
if (IsDebugEnabled())
{
McpLog.Warn($"Telemetry error (non-blocking): {e.Message}");
}
}
}
/// <summary>
/// Allows the bridge to register a concrete sender for telemetry payloads.
/// </summary>
public static void RegisterTelemetrySender(Action<Dictionary<string, object>> sender)
{
Interlocked.Exchange(ref s_sender, sender);
}
public static void UnregisterTelemetrySender()
{
Interlocked.Exchange(ref s_sender, null);
}
/// <summary>
/// Record bridge startup event
/// </summary>
public static void RecordBridgeStartup()
{
RecordEvent("bridge_startup", new Dictionary<string, object>
{
["bridge_version"] = AssetPathUtility.GetPackageVersion(),
["auto_connect"] = StdioBridgeHost.IsAutoConnectMode()
});
}
/// <summary>
/// Record bridge connection event
/// </summary>
public static void RecordBridgeConnection(bool success, string error = null)
{
var data = new Dictionary<string, object>
{
["success"] = success
};
if (!string.IsNullOrEmpty(error))
{
data["error"] = error.Substring(0, Math.Min(200, error.Length));
}
RecordEvent("bridge_connection", data);
}
/// <summary>
/// Record tool execution from Unity side
/// </summary>
public static void RecordToolExecution(string toolName, bool success, float durationMs, string error = null)
{
var data = new Dictionary<string, object>
{
["tool_name"] = toolName,
["success"] = success,
["duration_ms"] = Math.Round(durationMs, 2)
};
if (!string.IsNullOrEmpty(error))
{
data["error"] = error.Substring(0, Math.Min(200, error.Length));
}
RecordEvent("tool_execution_unity", data);
}
private static void SendTelemetryToMcpServer(Dictionary<string, object> telemetryData)
{
var sender = Volatile.Read(ref s_sender);
if (sender != null)
{
try
{
sender(telemetryData);
return;
}
catch (Exception e)
{
if (IsDebugEnabled())
{
McpLog.Warn($"Telemetry sender error (non-blocking): {e.Message}");
}
}
}
// Fallback: log when debug is enabled
if (IsDebugEnabled())
{
McpLog.Info($"Telemetry: {telemetryData["event_type"]}");
}
}
private static bool IsDebugEnabled()
{
try
{
return UnityEditor.EditorPrefs.GetBool(EditorPrefKeys.DebugLogs, false);
}
catch
{
return false;
}
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: b8f3c2d1e7a94f6c8a9b5e3d2c1a0f9e
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
+162
View File
@@ -0,0 +1,162 @@
using System;
using System.Collections.Generic;
using System.IO;
using Newtonsoft.Json.Linq;
using UnityEngine;
namespace MCPForUnity.Editor.Helpers
{
public static class TextureOps
{
public static byte[] EncodeTexture(Texture2D texture, string assetPath)
{
if (texture == null)
return null;
string extension = Path.GetExtension(assetPath);
if (string.IsNullOrEmpty(extension))
{
McpLog.Warn($"[TextureOps] No file extension for '{assetPath}', defaulting to PNG.");
return texture.EncodeToPNG();
}
switch (extension.ToLowerInvariant())
{
case ".png":
return texture.EncodeToPNG();
case ".jpg":
case ".jpeg":
return texture.EncodeToJPG();
default:
McpLog.Warn($"[TextureOps] Unsupported extension '{extension}' for '{assetPath}', defaulting to PNG.");
return texture.EncodeToPNG();
}
}
public static void FillTexture(Texture2D texture, Color32 color)
{
if (texture == null)
return;
Color32[] pixels = new Color32[texture.width * texture.height];
for (int i = 0; i < pixels.Length; i++)
{
pixels[i] = color;
}
texture.SetPixels32(pixels);
}
public static Color32 ParseColor32(JArray colorArray)
{
if (colorArray == null || colorArray.Count < 3)
return new Color32(255, 255, 255, 255);
byte r = (byte)Mathf.Clamp(colorArray[0].ToObject<int>(), 0, 255);
byte g = (byte)Mathf.Clamp(colorArray[1].ToObject<int>(), 0, 255);
byte b = (byte)Mathf.Clamp(colorArray[2].ToObject<int>(), 0, 255);
byte a = colorArray.Count > 3 ? (byte)Mathf.Clamp(colorArray[3].ToObject<int>(), 0, 255) : (byte)255;
return new Color32(r, g, b, a);
}
public static List<Color32> ParsePalette(JArray paletteArray)
{
if (paletteArray == null)
return null;
List<Color32> palette = new List<Color32>();
foreach (var item in paletteArray)
{
if (item is JArray colorArray)
{
palette.Add(ParseColor32(colorArray));
}
}
return palette.Count > 0 ? palette : null;
}
public static void ApplyPixelData(Texture2D texture, JToken pixelsToken, int width, int height)
{
ApplyPixelDataToRegion(texture, pixelsToken, 0, 0, width, height);
}
public static void ApplyPixelDataToRegion(Texture2D texture, JToken pixelsToken, int offsetX, int offsetY, int regionWidth, int regionHeight)
{
if (texture == null || pixelsToken == null)
return;
int textureWidth = texture.width;
int textureHeight = texture.height;
if (pixelsToken is JArray pixelArray)
{
int index = 0;
for (int y = 0; y < regionHeight && index < pixelArray.Count; y++)
{
for (int x = 0; x < regionWidth && index < pixelArray.Count; x++)
{
var pixelColor = pixelArray[index] as JArray;
if (pixelColor != null)
{
int px = offsetX + x;
int py = offsetY + y;
if (px >= 0 && px < textureWidth && py >= 0 && py < textureHeight)
{
texture.SetPixel(px, py, ParseColor32(pixelColor));
}
}
index++;
}
}
int expectedCount = regionWidth * regionHeight;
if (pixelArray.Count != expectedCount)
{
McpLog.Warn($"[TextureOps] Pixel array size mismatch: expected {expectedCount} entries, got {pixelArray.Count}");
}
}
else if (pixelsToken.Type == JTokenType.String)
{
string pixelString = pixelsToken.ToString();
string base64 = pixelString.StartsWith("base64:") ? pixelString.Substring(7) : pixelString;
if (!pixelString.StartsWith("base64:"))
{
McpLog.Warn("[TextureOps] Base64 pixel data missing 'base64:' prefix; attempting to decode.");
}
byte[] rawData = Convert.FromBase64String(base64);
// Assume RGBA32 format: 4 bytes per pixel
int expectedBytes = regionWidth * regionHeight * 4;
if (rawData.Length == expectedBytes)
{
int pixelIndex = 0;
for (int y = 0; y < regionHeight; y++)
{
for (int x = 0; x < regionWidth; x++)
{
int px = offsetX + x;
int py = offsetY + y;
if (px >= 0 && px < textureWidth && py >= 0 && py < textureHeight)
{
int byteIndex = pixelIndex * 4;
Color32 color = new Color32(
rawData[byteIndex],
rawData[byteIndex + 1],
rawData[byteIndex + 2],
rawData[byteIndex + 3]
);
texture.SetPixel(px, py, color);
}
pixelIndex++;
}
}
}
else
{
McpLog.Warn($"[TextureOps] Base64 data size mismatch: expected {expectedBytes} bytes, got {rawData.Length}");
}
}
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 864ea682d797466a84b6b951f6c4e4ba
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
+255
View File
@@ -0,0 +1,255 @@
using System.Linq;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using System;
namespace MCPForUnity.Editor.Helpers
{
/// <summary>
/// Unified parameter validation and extraction wrapper for MCP tools.
/// Eliminates repetitive IsNullOrEmpty checks and provides consistent error messages.
/// </summary>
public class ToolParams
{
private readonly JObject _params;
public ToolParams(JObject @params)
{
_params = @params ?? throw new ArgumentNullException(nameof(@params));
}
/// <summary>
/// Get required string parameter. Returns ErrorResponse if missing or empty.
/// </summary>
public Result<string> GetRequired(string key, string errorMessage = null)
{
var value = GetString(key);
if (string.IsNullOrEmpty(value))
{
return Result<string>.Error(
errorMessage ?? $"'{key}' parameter is required."
);
}
return Result<string>.Success(value);
}
/// <summary>
/// Get optional string parameter with default value.
/// Supports both snake_case and camelCase automatically.
/// </summary>
public string Get(string key, string defaultValue = null)
{
return GetString(key) ?? defaultValue;
}
/// <summary>
/// Get optional int parameter.
/// </summary>
public int? GetInt(string key, int? defaultValue = null)
{
var str = GetString(key);
if (string.IsNullOrEmpty(str)) return defaultValue;
return int.TryParse(str, out var result) ? result : defaultValue;
}
/// <summary>
/// Get optional bool parameter.
/// Supports both snake_case and camelCase automatically.
/// </summary>
public bool GetBool(string key, bool defaultValue = false)
{
return ParamCoercion.CoerceBool(GetToken(key), defaultValue);
}
/// <summary>
/// Get optional float parameter.
/// </summary>
public float? GetFloat(string key, float? defaultValue = null)
{
var str = GetString(key);
if (string.IsNullOrEmpty(str)) return defaultValue;
return float.TryParse(str, out var result) ? result : defaultValue;
}
/// <summary>
/// Check if parameter exists (even if null).
/// Supports both snake_case and camelCase automatically.
/// </summary>
public bool Has(string key)
{
return GetToken(key) != null;
}
/// <summary>
/// Get raw JToken for complex types.
/// Supports both snake_case and camelCase automatically.
/// </summary>
public JToken GetRaw(string key)
{
return GetToken(key);
}
/// <summary>
/// Get optional string array parameter, handling various MCP serialization formats:
/// plain strings, JSON arrays, stringified JSON arrays, and double-serialized arrays.
/// Supports both snake_case and camelCase automatically.
/// </summary>
public string[] GetStringArray(string key)
{
return CoerceStringArray(GetToken(key));
}
/// <summary>
/// Coerces a JToken to a string array, handling various MCP serialization formats:
/// plain strings, JSON arrays, stringified JSON arrays, and double-serialized arrays.
/// </summary>
internal static string[] CoerceStringArray(JToken token)
{
if (token == null || token.Type == JTokenType.Null) return null;
if (token.Type == JTokenType.String)
{
var value = token.ToString();
if (string.IsNullOrWhiteSpace(value)) return null;
// Handle stringified JSON arrays (e.g. "[\"name1\", \"name2\"]")
var trimmed = value.Trim();
if (trimmed.StartsWith("[") && trimmed.EndsWith("]"))
{
try
{
var parsed = JArray.Parse(trimmed);
var values = parsed.Values<string>()
.Where(s => !string.IsNullOrWhiteSpace(s))
.ToArray();
return values.Length > 0 ? values : null;
}
catch (JsonException) { /* not a valid JSON array, treat as plain string */ }
}
return new[] { value };
}
if (token.Type == JTokenType.Array)
{
var array = token as JArray;
if (array == null || array.Count == 0) return null;
// Handle double-serialized arrays: MCP bridge may send ["[\"name1\"]"]
// where the inner string is a stringified JSON array
if (array.Count == 1 && array[0].Type == JTokenType.String)
{
var inner = array[0].ToString().Trim();
if (inner.StartsWith("[") && inner.EndsWith("]"))
{
try
{
array = JArray.Parse(inner);
}
catch (JsonException) { /* use original array */ }
}
}
// Handle single-level nested arrays: [[name1, name2]]
// Multi-element outer arrays (e.g. [["a"], ["b"]]) are not unwrapped
// as that format is not produced by known MCP clients.
else if (array.Count == 1 && array[0].Type == JTokenType.Array)
{
array = array[0] as JArray ?? array;
}
var values = array
.Values<string>()
.Where(s => !string.IsNullOrWhiteSpace(s))
.ToArray();
return values.Length > 0 ? values : null;
}
return null;
}
/// <summary>
/// Get raw JToken with snake_case/camelCase fallback.
/// </summary>
private JToken GetToken(string key)
{
// Try exact match first
var token = _params[key];
if (token != null) return token;
// Try snake_case if camelCase was provided
var snakeKey = ToSnakeCase(key);
if (snakeKey != key)
{
token = _params[snakeKey];
if (token != null) return token;
}
// Try camelCase if snake_case was provided
var camelKey = ToCamelCase(key);
if (camelKey != key)
{
token = _params[camelKey];
}
return token;
}
private string GetString(string key)
{
// Try exact match first
var value = _params[key]?.ToString();
if (value != null) return value;
// Try snake_case if camelCase was provided
var snakeKey = ToSnakeCase(key);
if (snakeKey != key)
{
value = _params[snakeKey]?.ToString();
if (value != null) return value;
}
// Try camelCase if snake_case was provided
var camelKey = ToCamelCase(key);
if (camelKey != key)
{
value = _params[camelKey]?.ToString();
}
return value;
}
private static string ToSnakeCase(string str) => StringCaseUtility.ToSnakeCase(str);
private static string ToCamelCase(string str) => StringCaseUtility.ToCamelCase(str);
}
/// <summary>
/// Result type for operations that can fail with an error message.
/// </summary>
public class Result<T>
{
public bool IsSuccess { get; }
public T Value { get; }
public string ErrorMessage { get; }
private Result(bool isSuccess, T value, string errorMessage)
{
IsSuccess = isSuccess;
Value = value;
ErrorMessage = errorMessage;
}
public static Result<T> Success(T value) => new Result<T>(true, value, null);
public static Result<T> Error(string errorMessage) => new Result<T>(false, default, errorMessage);
/// <summary>
/// Get value or return ErrorResponse.
/// </summary>
public object GetOrError(out T value)
{
if (IsSuccess)
{
value = Value;
return null;
}
value = default;
return new ErrorResponse(ErrorMessage);
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 404b09ea3e2714e1babd16f5705ac788
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,33 @@
using System.Collections.Generic;
using Newtonsoft.Json;
using MCPForUnity.Runtime.Serialization;
namespace MCPForUnity.Editor.Helpers
{
/// <summary>
/// Shared JsonSerializer with Unity type converters.
/// Extracted from ManageGameObject to eliminate cross-tool dependencies.
/// </summary>
public static class UnityJsonSerializer
{
/// <summary>
/// Shared JsonSerializer instance with converters for Unity types.
/// Use this for all JToken-to-Unity-type conversions.
/// </summary>
public static readonly JsonSerializer Instance = JsonSerializer.Create(new JsonSerializerSettings
{
Converters = new List<JsonConverter>
{
new Vector2Converter(),
new Vector3Converter(),
new Vector4Converter(),
new QuaternionConverter(),
new ColorConverter(),
new RectConverter(),
new BoundsConverter(),
new UnityEngineObjectConverter()
}
});
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 24d94c9c030bd4ff1ab208c748f26b01
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,263 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using MCPForUnity.Runtime.Helpers;
using UnityEngine;
#if UNITY_EDITOR
using UnityEditor;
using UnityEditor.Compilation;
#endif
namespace MCPForUnity.Editor.Helpers
{
/// <summary>
/// Unified type resolution for Unity types (Components, ScriptableObjects, etc.).
/// Extracted from ComponentResolver in ManageGameObject and ResolveType in ManageScriptableObject.
/// Features: caching, prioritizes Player assemblies over Editor assemblies, uses TypeCache.
/// </summary>
public static class UnityTypeResolver
{
private static readonly Dictionary<string, Type> CacheByFqn = new(StringComparer.Ordinal);
private static readonly Dictionary<string, Type> CacheByName = new(StringComparer.Ordinal);
/// <summary>
/// Resolves a type by name, with optional base type constraint.
/// Caches results for performance. Prefers runtime assemblies over Editor assemblies.
/// </summary>
/// <param name="typeName">The short name or fully-qualified name of the type</param>
/// <param name="type">The resolved type, or null if not found</param>
/// <param name="error">Error message if resolution failed</param>
/// <param name="requiredBaseType">Optional base type constraint (e.g., typeof(Component))</param>
/// <returns>True if type was resolved successfully</returns>
public static bool TryResolve(string typeName, out Type type, out string error, Type requiredBaseType = null)
{
error = string.Empty;
type = null;
if (string.IsNullOrWhiteSpace(typeName))
{
error = "Type name cannot be null or empty";
return false;
}
// Check caches
if (CacheByFqn.TryGetValue(typeName, out type) && PassesConstraint(type, requiredBaseType))
return true;
if (!typeName.Contains(".") && CacheByName.TryGetValue(typeName, out type) && PassesConstraint(type, requiredBaseType))
return true;
// Try direct Type.GetType
type = Type.GetType(typeName, throwOnError: false);
if (type != null && PassesConstraint(type, requiredBaseType))
{
Cache(type);
return true;
}
// Search loaded assemblies (prefer Player assemblies)
var candidates = FindCandidates(typeName, requiredBaseType);
if (candidates.Count == 1)
{
type = candidates[0];
Cache(type);
return true;
}
if (candidates.Count > 1)
{
error = FormatAmbiguityError(typeName, candidates);
type = null;
return false;
}
#if UNITY_EDITOR
// Last resort: TypeCache (fast index)
if (requiredBaseType != null)
{
var tc = TypeCache.GetTypesDerivedFrom(requiredBaseType)
.Where(t => NamesMatch(t, typeName));
candidates = DisambiguateByIdentity(PreferPlayer(tc).ToList());
if (candidates.Count == 1)
{
type = candidates[0];
Cache(type);
return true;
}
if (candidates.Count > 1)
{
error = FormatAmbiguityError(typeName, candidates);
type = null;
return false;
}
}
#endif
error = $"Type '{typeName}' not found in loaded runtime assemblies. " +
"Use a fully-qualified name (Namespace.TypeName) and ensure the script compiled.";
type = null;
return false;
}
/// <summary>
/// Convenience method to resolve a Component type.
/// </summary>
public static Type ResolveComponent(string typeName)
{
if (TryResolve(typeName, out Type type, out _, typeof(Component)))
return type;
return null;
}
/// <summary>
/// Convenience method to resolve a ScriptableObject type.
/// </summary>
public static Type ResolveScriptableObject(string typeName)
{
if (TryResolve(typeName, out Type type, out _, typeof(ScriptableObject)))
return type;
return null;
}
/// <summary>
/// Convenience method to resolve any type without constraints.
/// </summary>
public static Type ResolveAny(string typeName)
{
if (TryResolve(typeName, out Type type, out _, null))
return type;
return null;
}
// --- Private Helpers ---
private static bool PassesConstraint(Type type, Type requiredBaseType)
{
if (type == null) return false;
if (requiredBaseType == null) return true;
return requiredBaseType.IsAssignableFrom(type);
}
private static bool NamesMatch(Type t, string query) =>
t.Name.Equals(query, StringComparison.Ordinal) ||
(t.FullName?.Equals(query, StringComparison.Ordinal) ?? false);
private static void Cache(Type t)
{
if (t == null) return;
if (t.FullName != null) CacheByFqn[t.FullName] = t;
CacheByName[t.Name] = t;
}
private static List<Type> FindCandidates(string query, Type requiredBaseType)
{
bool isShort = !query.Contains('.');
var loaded = UnityAssembliesCompat.GetLoadedAssemblies();
#if UNITY_EDITOR
// Names of Player (runtime) script assemblies
var playerAsmNames = new HashSet<string>(
CompilationPipeline.GetAssemblies(AssembliesType.Player).Select(a => a.name),
StringComparer.Ordinal);
var playerAsms = loaded.Where(a => playerAsmNames.Contains(a.GetName().Name));
var editorAsms = loaded.Except(playerAsms);
#else
var playerAsms = loaded;
var editorAsms = Array.Empty<System.Reflection.Assembly>();
#endif
Func<Type, bool> match = isShort
? (t => t.Name.Equals(query, StringComparison.Ordinal))
: (t => t.FullName?.Equals(query, StringComparison.Ordinal) ?? false);
var fromPlayer = playerAsms.SelectMany(SafeGetTypes)
.Where(t => PassesConstraint(t, requiredBaseType))
.Where(match);
var fromEditor = editorAsms.SelectMany(SafeGetTypes)
.Where(t => PassesConstraint(t, requiredBaseType))
.Where(match);
// Prefer Player over Editor
var candidates = fromPlayer.ToList();
if (candidates.Count == 0)
candidates = fromEditor.ToList();
return DisambiguateByIdentity(candidates);
}
/// <summary>
/// Collapses spurious matches that are not a genuine user-facing ambiguity:
/// 1. De-dupe by FullName (same type surfaced via multiple assemblies / type-forwards).
/// 2. If still >1, prefer a single PUBLIC type (e.g. public BCL List`1 over an
/// internal type that happens to share the short name).
/// 3. If still >1, prefer a single core/BCL type (mscorlib / System.* / netstandard /
/// System.Private.CoreLib) so unqualified BCL generics resolve deterministically.
/// Each step only narrows when it yields exactly one survivor, so genuine clashes
/// between two public, non-BCL types (e.g. UnityEngine.UI.Button vs
/// UnityEngine.UIElements.Button) are still reported as ambiguous.
/// </summary>
private static List<Type> DisambiguateByIdentity(List<Type> candidates)
{
if (candidates.Count <= 1) return candidates;
// 1. De-dupe by FullName (keep first occurrence; Player-preferred ordering preserved).
var deduped = candidates
.GroupBy(t => t.FullName ?? t.Name, StringComparer.Ordinal)
.Select(g => g.First())
.ToList();
if (deduped.Count <= 1) return deduped;
// 2. Prefer a single public type.
var publicTypes = deduped.Where(t => t.IsPublic || t.IsNestedPublic).ToList();
if (publicTypes.Count == 1) return publicTypes;
var pool = publicTypes.Count > 1 ? publicTypes : deduped;
// 3. Prefer a single core/BCL type.
var coreTypes = pool.Where(IsCoreBclType).ToList();
if (coreTypes.Count == 1) return coreTypes;
return pool;
}
private static bool IsCoreBclType(Type t)
{
string asmName = t.Assembly.GetName().Name;
if (string.IsNullOrEmpty(asmName)) return false;
return asmName.Equals("mscorlib", StringComparison.Ordinal)
|| asmName.Equals("netstandard", StringComparison.Ordinal)
|| asmName.Equals("System.Private.CoreLib", StringComparison.Ordinal)
|| asmName.Equals("System", StringComparison.Ordinal)
|| asmName.StartsWith("System.", StringComparison.Ordinal);
}
private static IEnumerable<Type> SafeGetTypes(System.Reflection.Assembly assembly)
{
try { return assembly.GetTypes(); }
catch (ReflectionTypeLoadException rtle) { return rtle.Types.Where(t => t != null); }
catch { return Enumerable.Empty<Type>(); }
}
private static IEnumerable<Type> PreferPlayer(IEnumerable<Type> types)
{
#if UNITY_EDITOR
var playerAsmNames = new HashSet<string>(
CompilationPipeline.GetAssemblies(AssembliesType.Player).Select(a => a.name),
StringComparer.Ordinal);
var list = types.ToList();
var fromPlayer = list.Where(t => playerAsmNames.Contains(t.Assembly.GetName().Name)).ToList();
return fromPlayer.Count > 0 ? fromPlayer : list;
#else
return types;
#endif
}
private static string FormatAmbiguityError(string query, List<Type> candidates)
{
var names = string.Join(", ", candidates.Take(5).Select(t => t.FullName));
if (candidates.Count > 5) names += $" ... ({candidates.Count - 5} more)";
return $"Ambiguous type reference '{query}'. Found {candidates.Count} matches: [{names}]. Use a fully-qualified name.";
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 2cdf06f869b124741af31f27b25742db
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
+731
View File
@@ -0,0 +1,731 @@
using System;
using System.Collections.Generic;
using Newtonsoft.Json.Linq;
using UnityEngine;
namespace MCPForUnity.Editor.Helpers
{
/// <summary>
/// Utility class for parsing JSON tokens into Unity vector, math, and animation types.
/// Supports both array format [x, y, z] and object format {x: 1, y: 2, z: 3}.
/// </summary>
public static class VectorParsing
{
/// <summary>
/// Parses a JToken (array or object) into a Vector3.
/// </summary>
/// <param name="token">The JSON token to parse</param>
/// <returns>The parsed Vector3 or null if parsing fails</returns>
public static Vector3? ParseVector3(JToken token)
{
if (token == null || token.Type == JTokenType.Null)
return null;
try
{
// Array format: [x, y, z]
if (token is JArray array && array.Count >= 3)
{
return new Vector3(
array[0].ToObject<float>(),
array[1].ToObject<float>(),
array[2].ToObject<float>()
);
}
// Object format: {x: 1, y: 2, z: 3}
if (token is JObject obj && obj.ContainsKey("x") && obj.ContainsKey("y") && obj.ContainsKey("z"))
{
return new Vector3(
obj["x"].ToObject<float>(),
obj["y"].ToObject<float>(),
obj["z"].ToObject<float>()
);
}
}
catch (Exception ex)
{
McpLog.Warn($"[VectorParsing] Failed to parse Vector3 from '{token}': {ex.Message}");
}
return null;
}
/// <summary>
/// Parses a JToken into a Vector3, returning a default value if parsing fails.
/// </summary>
public static Vector3 ParseVector3OrDefault(JToken token, Vector3 defaultValue = default)
{
return ParseVector3(token) ?? defaultValue;
}
/// <summary>
/// Parses a JToken (array or object) into a Vector2.
/// </summary>
/// <param name="token">The JSON token to parse</param>
/// <returns>The parsed Vector2 or null if parsing fails</returns>
public static Vector2? ParseVector2(JToken token)
{
if (token == null || token.Type == JTokenType.Null)
return null;
try
{
// Array format: [x, y]
if (token is JArray array && array.Count >= 2)
{
return new Vector2(
array[0].ToObject<float>(),
array[1].ToObject<float>()
);
}
// Object format: {x: 1, y: 2}
if (token is JObject obj && obj.ContainsKey("x") && obj.ContainsKey("y"))
{
return new Vector2(
obj["x"].ToObject<float>(),
obj["y"].ToObject<float>()
);
}
}
catch (Exception ex)
{
McpLog.Warn($"[VectorParsing] Failed to parse Vector2 from '{token}': {ex.Message}");
}
return null;
}
/// <summary>
/// Parses a JToken (array or object) into a Vector4.
/// </summary>
/// <param name="token">The JSON token to parse</param>
/// <returns>The parsed Vector4 or null if parsing fails</returns>
public static Vector4? ParseVector4(JToken token)
{
if (token == null || token.Type == JTokenType.Null)
return null;
try
{
// Array format: [x, y, z, w]
if (token is JArray array && array.Count >= 4)
{
return new Vector4(
array[0].ToObject<float>(),
array[1].ToObject<float>(),
array[2].ToObject<float>(),
array[3].ToObject<float>()
);
}
// Object format: {x: 1, y: 2, z: 3, w: 4}
if (token is JObject obj && obj.ContainsKey("x") && obj.ContainsKey("y") &&
obj.ContainsKey("z") && obj.ContainsKey("w"))
{
return new Vector4(
obj["x"].ToObject<float>(),
obj["y"].ToObject<float>(),
obj["z"].ToObject<float>(),
obj["w"].ToObject<float>()
);
}
}
catch (Exception ex)
{
Debug.LogWarning($"[VectorParsing] Failed to parse Vector4 from '{token}': {ex.Message}");
}
return null;
}
/// <summary>
/// Parses a JToken (array or object) into a Quaternion.
/// Supports both euler angles [x, y, z] and quaternion components [x, y, z, w].
/// Note: Raw quaternion components are NOT normalized. Callers should normalize if needed
/// for operations like interpolation where non-unit quaternions cause issues.
/// </summary>
/// <param name="token">The JSON token to parse</param>
/// <param name="asEulerAngles">If true, treats 3-element arrays as euler angles</param>
/// <returns>The parsed Quaternion or null if parsing fails</returns>
public static Quaternion? ParseQuaternion(JToken token, bool asEulerAngles = true)
{
if (token == null || token.Type == JTokenType.Null)
return null;
try
{
if (token is JArray array)
{
// Quaternion components: [x, y, z, w]
if (array.Count >= 4)
{
return new Quaternion(
array[0].ToObject<float>(),
array[1].ToObject<float>(),
array[2].ToObject<float>(),
array[3].ToObject<float>()
);
}
// Euler angles: [x, y, z]
if (array.Count >= 3 && asEulerAngles)
{
return Quaternion.Euler(
array[0].ToObject<float>(),
array[1].ToObject<float>(),
array[2].ToObject<float>()
);
}
}
// Object format: {x: 0, y: 0, z: 0, w: 1}
if (token is JObject obj)
{
if (obj.ContainsKey("x") && obj.ContainsKey("y") && obj.ContainsKey("z") && obj.ContainsKey("w"))
{
return new Quaternion(
obj["x"].ToObject<float>(),
obj["y"].ToObject<float>(),
obj["z"].ToObject<float>(),
obj["w"].ToObject<float>()
);
}
// Euler format in object: {x: 45, y: 90, z: 0} (as euler angles)
if (obj.ContainsKey("x") && obj.ContainsKey("y") && obj.ContainsKey("z") && asEulerAngles)
{
return Quaternion.Euler(
obj["x"].ToObject<float>(),
obj["y"].ToObject<float>(),
obj["z"].ToObject<float>()
);
}
}
}
catch (Exception ex)
{
McpLog.Warn($"[VectorParsing] Failed to parse Quaternion from '{token}': {ex.Message}");
}
return null;
}
/// <summary>
/// Parses a JToken (array or object) into a Color.
/// Supports both [r, g, b, a] and {r: 1, g: 1, b: 1, a: 1} formats.
/// </summary>
/// <param name="token">The JSON token to parse</param>
/// <returns>The parsed Color or null if parsing fails</returns>
public static Color? ParseColor(JToken token)
{
if (token == null || token.Type == JTokenType.Null)
return null;
try
{
// Array format: [r, g, b, a] or [r, g, b]
if (token is JArray array)
{
if (array.Count >= 4)
{
return new Color(
array[0].ToObject<float>(),
array[1].ToObject<float>(),
array[2].ToObject<float>(),
array[3].ToObject<float>()
);
}
if (array.Count >= 3)
{
return new Color(
array[0].ToObject<float>(),
array[1].ToObject<float>(),
array[2].ToObject<float>(),
1f // Default alpha
);
}
}
// Object format: {r: 1, g: 1, b: 1, a: 1}
if (token is JObject obj && obj.ContainsKey("r") && obj.ContainsKey("g") && obj.ContainsKey("b"))
{
float a = obj.ContainsKey("a") ? obj["a"].ToObject<float>() : 1f;
return new Color(
obj["r"].ToObject<float>(),
obj["g"].ToObject<float>(),
obj["b"].ToObject<float>(),
a
);
}
}
catch (Exception ex)
{
McpLog.Warn($"[VectorParsing] Failed to parse Color from '{token}': {ex.Message}");
}
return null;
}
/// <summary>
/// Parses a JToken into a Color, returning Color.white if parsing fails and no default is specified.
/// </summary>
public static Color ParseColorOrDefault(JToken token) => ParseColor(token) ?? Color.white;
/// <summary>
/// Parses a JToken into a Color, returning the specified default if parsing fails.
/// </summary>
public static Color ParseColorOrDefault(JToken token, Color defaultValue) => ParseColor(token) ?? defaultValue;
/// <summary>
/// Parses a JToken into a Vector4, returning a default value if parsing fails.
/// Added for ManageVFX refactoring.
/// </summary>
public static Vector4 ParseVector4OrDefault(JToken token, Vector4 defaultValue = default)
{
return ParseVector4(token) ?? defaultValue;
}
/// <summary>
/// Parses a JToken into a Gradient.
/// Supports formats:
/// - Simple: {startColor: [r,g,b,a], endColor: [r,g,b,a]}
/// - Full: {colorKeys: [{color: [r,g,b,a], time: 0.0}, ...], alphaKeys: [{alpha: 1.0, time: 0.0}, ...]}
/// Added for ManageVFX refactoring.
/// </summary>
/// <param name="token">The JSON token to parse</param>
/// <returns>The parsed Gradient or null if parsing fails</returns>
public static Gradient ParseGradient(JToken token)
{
if (token == null || token.Type == JTokenType.Null)
return null;
try
{
Gradient gradient = new Gradient();
if (token is JObject obj)
{
// Simple format: {startColor: ..., endColor: ...}
if (obj.ContainsKey("startColor"))
{
Color startColor = ParseColorOrDefault(obj["startColor"]);
Color endColor = ParseColorOrDefault(obj["endColor"] ?? obj["startColor"]);
float startAlpha = obj["startAlpha"]?.ToObject<float>() ?? startColor.a;
float endAlpha = obj["endAlpha"]?.ToObject<float>() ?? endColor.a;
gradient.SetKeys(
new GradientColorKey[] { new GradientColorKey(startColor, 0f), new GradientColorKey(endColor, 1f) },
new GradientAlphaKey[] { new GradientAlphaKey(startAlpha, 0f), new GradientAlphaKey(endAlpha, 1f) }
);
return gradient;
}
// Full format: {colorKeys: [...], alphaKeys: [...]}
var colorKeys = new List<GradientColorKey>();
var alphaKeys = new List<GradientAlphaKey>();
if (obj["colorKeys"] is JArray colorKeysArr)
{
foreach (var key in colorKeysArr)
{
Color color = ParseColorOrDefault(key["color"]);
float time = key["time"]?.ToObject<float>() ?? 0f;
colorKeys.Add(new GradientColorKey(color, time));
}
}
if (obj["alphaKeys"] is JArray alphaKeysArr)
{
foreach (var key in alphaKeysArr)
{
float alpha = key["alpha"]?.ToObject<float>() ?? 1f;
float time = key["time"]?.ToObject<float>() ?? 0f;
alphaKeys.Add(new GradientAlphaKey(alpha, time));
}
}
// Ensure at least 2 keys
if (colorKeys.Count == 0)
{
colorKeys.Add(new GradientColorKey(Color.white, 0f));
colorKeys.Add(new GradientColorKey(Color.white, 1f));
}
if (alphaKeys.Count == 0)
{
alphaKeys.Add(new GradientAlphaKey(1f, 0f));
alphaKeys.Add(new GradientAlphaKey(1f, 1f));
}
gradient.SetKeys(colorKeys.ToArray(), alphaKeys.ToArray());
return gradient;
}
}
catch (Exception ex)
{
McpLog.Warn($"[VectorParsing] Failed to parse Gradient from '{token}': {ex.Message}");
}
return null;
}
/// <summary>
/// Parses a JToken into a Gradient, returning a default gradient if parsing fails.
/// Added for ManageVFX refactoring.
/// </summary>
public static Gradient ParseGradientOrDefault(JToken token)
{
var result = ParseGradient(token);
if (result != null) return result;
// Return default white gradient
var gradient = new Gradient();
gradient.SetKeys(
new GradientColorKey[] { new GradientColorKey(Color.white, 0f), new GradientColorKey(Color.white, 1f) },
new GradientAlphaKey[] { new GradientAlphaKey(1f, 0f), new GradientAlphaKey(1f, 1f) }
);
return gradient;
}
/// <summary>
/// Parses a JToken into an AnimationCurve.
///
/// <para><b>Supported formats:</b></para>
/// <list type="bullet">
/// <item>Constant: <c>1.0</c> (number) - Creates constant curve at that value</item>
/// <item>Simple: <c>{start: 0.0, end: 1.0}</c> or <c>{startValue: 0.0, endValue: 1.0}</c></item>
/// <item>Full: <c>{keys: [{time: 0, value: 1, inTangent: 0, outTangent: 0}, ...]}</c></item>
/// </list>
///
/// <para><b>Keyframe field defaults (for Full format):</b></para>
/// <list type="bullet">
/// <item><c>time</c> (float): <b>Default: 0</b></item>
/// <item><c>value</c> (float): <b>Default: 1</b> (note: differs from ManageScriptableObject which uses 0)</item>
/// <item><c>inTangent</c> (float): <b>Default: 0</b></item>
/// <item><c>outTangent</c> (float): <b>Default: 0</b></item>
/// </list>
///
/// <para><b>Note:</b> This method is used by ManageVFX. For ScriptableObject patching,
/// see <see cref="MCPForUnity.Editor.Tools.ManageScriptableObject"/> which has slightly different defaults.</para>
/// </summary>
/// <param name="token">The JSON token to parse</param>
/// <returns>The parsed AnimationCurve or null if parsing fails</returns>
public static AnimationCurve ParseAnimationCurve(JToken token)
{
if (token == null || token.Type == JTokenType.Null)
return null;
try
{
// Constant value: just a number
if (token.Type == JTokenType.Float || token.Type == JTokenType.Integer)
{
return AnimationCurve.Constant(0f, 1f, token.ToObject<float>());
}
if (token is JObject obj)
{
// Full format: {keys: [...]}
if (obj["keys"] is JArray keys)
{
AnimationCurve curve = new AnimationCurve();
foreach (var key in keys)
{
float time = key["time"]?.ToObject<float>() ?? 0f;
float value = key["value"]?.ToObject<float>() ?? 1f;
float inTangent = key["inTangent"]?.ToObject<float>() ?? 0f;
float outTangent = key["outTangent"]?.ToObject<float>() ?? 0f;
curve.AddKey(new Keyframe(time, value, inTangent, outTangent));
}
return curve;
}
// Simple format: {start: 0.0, end: 1.0} or {startValue: 0.0, endValue: 1.0}
if (obj.ContainsKey("start") || obj.ContainsKey("startValue") || obj.ContainsKey("end") || obj.ContainsKey("endValue"))
{
float startValue = obj["start"]?.ToObject<float>() ?? obj["startValue"]?.ToObject<float>() ?? 1f;
float endValue = obj["end"]?.ToObject<float>() ?? obj["endValue"]?.ToObject<float>() ?? 1f;
AnimationCurve curve = new AnimationCurve();
curve.AddKey(0f, startValue);
curve.AddKey(1f, endValue);
return curve;
}
}
}
catch (Exception ex)
{
McpLog.Warn($"[VectorParsing] Failed to parse AnimationCurve from '{token}': {ex.Message}");
}
return null;
}
/// <summary>
/// Parses a JToken into an AnimationCurve, returning a constant curve if parsing fails.
/// Added for ManageVFX refactoring.
/// </summary>
/// <param name="token">The JSON token to parse</param>
/// <param name="defaultValue">The constant value for the default curve</param>
public static AnimationCurve ParseAnimationCurveOrDefault(JToken token, float defaultValue = 1f)
{
return ParseAnimationCurve(token) ?? AnimationCurve.Constant(0f, 1f, defaultValue);
}
/// <summary>
/// Validates AnimationCurve JSON format without parsing it.
/// Used by dry-run validation to provide early feedback on format errors.
///
/// <para><b>Validated formats:</b></para>
/// <list type="bullet">
/// <item>Wrapped: <c>{ "keys": [ { "time": 0, "value": 1.0 }, ... ] }</c></item>
/// <item>Direct array: <c>[ { "time": 0, "value": 1.0 }, ... ]</c></item>
/// <item>Null/empty: Valid (will set empty curve)</item>
/// </list>
/// </summary>
/// <param name="valueToken">The JSON value to validate</param>
/// <param name="message">Output message describing validation result or error</param>
/// <returns>True if format is valid, false otherwise</returns>
public static bool ValidateAnimationCurveFormat(JToken valueToken, out string message)
{
message = null;
if (valueToken == null || valueToken.Type == JTokenType.Null)
{
message = "Value format valid (will set empty curve).";
return true;
}
JArray keysArray = null;
if (valueToken is JObject curveObj)
{
keysArray = curveObj["keys"] as JArray;
if (keysArray == null)
{
message = "AnimationCurve object requires 'keys' array. Expected: { \"keys\": [ { \"time\": 0, \"value\": 0 }, ... ] }";
return false;
}
}
else if (valueToken is JArray directArray)
{
keysArray = directArray;
}
else
{
message = "AnimationCurve requires object with 'keys' or array of keyframes. " +
"Expected: { \"keys\": [ { \"time\": 0, \"value\": 0, \"inSlope\": 0, \"outSlope\": 0 }, ... ] }";
return false;
}
// Validate each keyframe
for (int i = 0; i < keysArray.Count; i++)
{
var keyToken = keysArray[i];
if (keyToken is not JObject keyObj)
{
message = $"Keyframe at index {i} must be an object with 'time' and 'value'.";
return false;
}
// Validate numeric fields if present
string[] numericFields = { "time", "value", "inSlope", "outSlope", "inTangent", "outTangent", "inWeight", "outWeight" };
foreach (var field in numericFields)
{
if (!ParamCoercion.ValidateNumericField(keyObj, field, out var fieldError))
{
message = $"Keyframe[{i}].{field}: {fieldError}";
return false;
}
}
if (!ParamCoercion.ValidateIntegerField(keyObj, "weightedMode", out var weightedModeError))
{
message = $"Keyframe[{i}].weightedMode: {weightedModeError}";
return false;
}
}
message = $"Value format valid (AnimationCurve with {keysArray.Count} keyframes). " +
"Note: Missing keyframe fields default to 0 (time, value, inSlope, outSlope, inWeight, outWeight).";
return true;
}
/// <summary>
/// Validates Quaternion JSON format without parsing it.
/// Used by dry-run validation to provide early feedback on format errors.
///
/// <para><b>Validated formats:</b></para>
/// <list type="bullet">
/// <item>Euler array: <c>[x, y, z]</c> - 3 numeric elements</item>
/// <item>Raw quaternion: <c>[x, y, z, w]</c> - 4 numeric elements</item>
/// <item>Object: <c>{ "x": 0, "y": 0, "z": 0, "w": 1 }</c></item>
/// <item>Explicit euler: <c>{ "euler": [x, y, z] }</c></item>
/// <item>Null/empty: Valid (will set identity)</item>
/// </list>
/// </summary>
/// <param name="valueToken">The JSON value to validate</param>
/// <param name="message">Output message describing validation result or error</param>
/// <returns>True if format is valid, false otherwise</returns>
public static bool ValidateQuaternionFormat(JToken valueToken, out string message)
{
message = null;
if (valueToken == null || valueToken.Type == JTokenType.Null)
{
message = "Value format valid (will set identity quaternion).";
return true;
}
if (valueToken is JArray arr)
{
if (arr.Count == 3)
{
// Validate Euler angles [x, y, z]
for (int i = 0; i < 3; i++)
{
if (!ParamCoercion.IsNumericToken(arr[i]))
{
message = $"Euler angle at index {i} must be a number.";
return false;
}
}
message = "Value format valid (Quaternion from Euler angles [x, y, z]).";
return true;
}
else if (arr.Count == 4)
{
// Validate raw quaternion [x, y, z, w]
for (int i = 0; i < 4; i++)
{
if (!ParamCoercion.IsNumericToken(arr[i]))
{
message = $"Quaternion component at index {i} must be a number.";
return false;
}
}
message = "Value format valid (Quaternion from [x, y, z, w]).";
return true;
}
else
{
message = "Quaternion array must have 3 elements (Euler angles) or 4 elements (x, y, z, w).";
return false;
}
}
else if (valueToken is JObject obj)
{
// Check for explicit euler property
if (obj["euler"] is JArray eulerArr)
{
if (eulerArr.Count != 3)
{
message = "Quaternion euler array must have exactly 3 elements [x, y, z].";
return false;
}
for (int i = 0; i < 3; i++)
{
if (!ParamCoercion.IsNumericToken(eulerArr[i]))
{
message = $"Euler angle at index {i} must be a number.";
return false;
}
}
message = "Value format valid (Quaternion from { euler: [x, y, z] }).";
return true;
}
// Object format { x, y, z, w }
if (obj["x"] != null && obj["y"] != null && obj["z"] != null && obj["w"] != null)
{
if (!ParamCoercion.IsNumericToken(obj["x"]) || !ParamCoercion.IsNumericToken(obj["y"]) ||
!ParamCoercion.IsNumericToken(obj["z"]) || !ParamCoercion.IsNumericToken(obj["w"]))
{
message = "Quaternion { x, y, z, w } fields must all be numbers.";
return false;
}
message = "Value format valid (Quaternion from { x, y, z, w }).";
return true;
}
message = "Quaternion object must have { x, y, z, w } or { euler: [x, y, z] }.";
return false;
}
else
{
message = "Quaternion requires array [x,y,z] (Euler), [x,y,z,w] (raw), or object { x, y, z, w }.";
return false;
}
}
/// <summary>
/// Parses a JToken into a Rect.
/// Supports {x, y, width, height} format.
/// </summary>
public static Rect? ParseRect(JToken token)
{
if (token == null || token.Type == JTokenType.Null)
return null;
try
{
if (token is JObject obj &&
obj.ContainsKey("x") && obj.ContainsKey("y") &&
obj.ContainsKey("width") && obj.ContainsKey("height"))
{
return new Rect(
obj["x"].ToObject<float>(),
obj["y"].ToObject<float>(),
obj["width"].ToObject<float>(),
obj["height"].ToObject<float>()
);
}
// Array format: [x, y, width, height]
if (token is JArray array && array.Count >= 4)
{
return new Rect(
array[0].ToObject<float>(),
array[1].ToObject<float>(),
array[2].ToObject<float>(),
array[3].ToObject<float>()
);
}
}
catch (Exception ex)
{
McpLog.Warn($"[VectorParsing] Failed to parse Rect from '{token}': {ex.Message}");
}
return null;
}
/// <summary>
/// Parses a JToken into a Bounds.
/// Supports {center: {x,y,z}, size: {x,y,z}} format.
/// </summary>
public static Bounds? ParseBounds(JToken token)
{
if (token == null || token.Type == JTokenType.Null)
return null;
try
{
if (token is JObject obj && obj.ContainsKey("center") && obj.ContainsKey("size"))
{
var center = ParseVector3(obj["center"]) ?? Vector3.zero;
var size = ParseVector3(obj["size"]) ?? Vector3.zero;
return new Bounds(center, size);
}
}
catch (Exception ex)
{
McpLog.Warn($"[VectorParsing] Failed to parse Bounds from '{token}': {ex.Message}");
}
return null;
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: ca2205caede3744aebda9f6da2fa2c22
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant: