Shutong Wu 2025-10-19 23:12:11 -04:00
commit be7ade8020
45 changed files with 2011 additions and 206 deletions

View File

@ -1,7 +1,6 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Runtime.InteropServices;
using MCPForUnity.Editor.Models;
namespace MCPForUnity.Editor.Data

View File

@ -0,0 +1,107 @@
using System;
using System.Collections.Generic;
using System.Linq;
using UnityEngine;
namespace MCPForUnity.Editor.Data
{
/// <summary>
/// Registry of Python tool files to sync to the MCP server.
/// Add your Python files here - they can be stored anywhere in your project.
/// </summary>
[CreateAssetMenu(fileName = "PythonTools", menuName = "MCP For Unity/Python Tools")]
public class PythonToolsAsset : ScriptableObject
{
[Tooltip("Add Python files (.py) to sync to the MCP server. Files can be located anywhere in your project.")]
public List<TextAsset> pythonFiles = new List<TextAsset>();
[Header("Sync Options")]
[Tooltip("Use content hashing to detect changes (recommended). If false, always copies on startup.")]
public bool useContentHashing = true;
[Header("Sync State (Read-only)")]
[Tooltip("Internal tracking - do not modify")]
public List<PythonFileState> fileStates = new List<PythonFileState>();
/// <summary>
/// Gets all valid Python files (filters out null/missing references)
/// </summary>
public IEnumerable<TextAsset> GetValidFiles()
{
return pythonFiles.Where(f => f != null);
}
/// <summary>
/// Checks if a file needs syncing
/// </summary>
public bool NeedsSync(TextAsset file, string currentHash)
{
if (!useContentHashing) return true; // Always sync if hashing disabled
var state = fileStates.FirstOrDefault(s => s.assetGuid == GetAssetGuid(file));
return state == null || state.contentHash != currentHash;
}
/// <summary>
/// Records that a file was synced
/// </summary>
public void RecordSync(TextAsset file, string hash)
{
string guid = GetAssetGuid(file);
var state = fileStates.FirstOrDefault(s => s.assetGuid == guid);
if (state == null)
{
state = new PythonFileState { assetGuid = guid };
fileStates.Add(state);
}
state.contentHash = hash;
state.lastSyncTime = DateTime.UtcNow;
state.fileName = file.name;
}
/// <summary>
/// Removes state entries for files no longer in the list
/// </summary>
public void CleanupStaleStates()
{
var validGuids = new HashSet<string>(GetValidFiles().Select(GetAssetGuid));
fileStates.RemoveAll(s => !validGuids.Contains(s.assetGuid));
}
private string GetAssetGuid(TextAsset asset)
{
return UnityEditor.AssetDatabase.AssetPathToGUID(UnityEditor.AssetDatabase.GetAssetPath(asset));
}
/// <summary>
/// Called when the asset is modified in the Inspector
/// Triggers sync to handle file additions/removals
/// </summary>
private void OnValidate()
{
// Cleanup stale states immediately
CleanupStaleStates();
// Trigger sync after a delay to handle file removals
// Delay ensures the asset is saved before sync runs
UnityEditor.EditorApplication.delayCall += () =>
{
if (this != null) // Check if asset still exists
{
MCPForUnity.Editor.Helpers.PythonToolSyncProcessor.SyncAllTools();
}
};
}
}
[Serializable]
public class PythonFileState
{
public string assetGuid;
public string fileName;
public string contentHash;
public DateTime lastSyncTime;
}
}

View File

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

View File

@ -2,10 +2,7 @@ using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using System.Text.RegularExpressions;
using MCPForUnity.External.Tommy;
using Newtonsoft.Json;
namespace MCPForUnity.Editor.Helpers
{
@ -42,76 +39,44 @@ namespace MCPForUnity.Editor.Helpers
public static string BuildCodexServerBlock(string uvPath, string serverSrc)
{
string argsArray = FormatTomlStringArray(new[] { "run", "--directory", serverSrc, "server.py" });
return $"[mcp_servers.unityMCP]{Environment.NewLine}" +
$"command = \"{EscapeTomlString(uvPath)}\"{Environment.NewLine}" +
$"args = {argsArray}";
var table = new TomlTable();
var mcpServers = new TomlTable();
mcpServers["unityMCP"] = CreateUnityMcpTable(uvPath, serverSrc);
table["mcp_servers"] = mcpServers;
using var writer = new StringWriter();
table.WriteTo(writer);
return writer.ToString();
}
public static string UpsertCodexServerBlock(string existingToml, string newBlock)
public static string UpsertCodexServerBlock(string existingToml, string uvPath, string serverSrc)
{
if (string.IsNullOrWhiteSpace(existingToml))
{
return newBlock.TrimEnd() + Environment.NewLine;
}
// Parse existing TOML or create new root table
var root = TryParseToml(existingToml) ?? new TomlTable();
StringBuilder sb = new StringBuilder();
using StringReader reader = new StringReader(existingToml);
string line;
bool inTarget = false;
bool replaced = false;
while ((line = reader.ReadLine()) != null)
// Ensure mcp_servers table exists
if (!root.TryGetNode("mcp_servers", out var mcpServersNode) || !(mcpServersNode is TomlTable))
{
string trimmed = line.Trim();
bool isSection = trimmed.StartsWith("[") && trimmed.EndsWith("]") && !trimmed.StartsWith("[[");
if (isSection)
{
bool isTarget = string.Equals(trimmed, "[mcp_servers.unityMCP]", StringComparison.OrdinalIgnoreCase);
if (isTarget)
{
if (!replaced)
{
if (sb.Length > 0 && sb[^1] != '\n') sb.AppendLine();
sb.AppendLine(newBlock.TrimEnd());
replaced = true;
}
inTarget = true;
continue;
root["mcp_servers"] = new TomlTable();
}
var mcpServers = root["mcp_servers"] as TomlTable;
if (inTarget)
{
inTarget = false;
}
}
// Create or update unityMCP table
mcpServers["unityMCP"] = CreateUnityMcpTable(uvPath, serverSrc);
if (inTarget)
{
continue;
}
sb.AppendLine(line);
}
if (!replaced)
{
if (sb.Length > 0 && sb[^1] != '\n') sb.AppendLine();
sb.AppendLine(newBlock.TrimEnd());
}
return sb.ToString().TrimEnd() + Environment.NewLine;
// 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)
{
command = null;
args = null;
if (string.IsNullOrWhiteSpace(toml)) return false;
try
{
using var reader = new StringReader(toml);
TomlTable root = TOML.Parse(reader);
var root = TryParseToml(toml);
if (root == null) return false;
if (!TryGetTable(root, "mcp_servers", out var servers)
@ -130,20 +95,51 @@ namespace MCPForUnity.Editor.Helpers
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 false;
return null;
}
catch (TomlSyntaxException)
{
return false;
return null;
}
catch (FormatException)
{
return false;
return null;
}
}
/// <summary>
/// Creates a TomlTable for the unityMCP server configuration
/// </summary>
private static TomlTable CreateUnityMcpTable(string uvPath, string serverSrc)
{
var unityMCP = new TomlTable();
unityMCP["command"] = new TomlString { Value = uvPath };
var argsArray = new TomlArray();
argsArray.Add(new TomlString { Value = "run" });
argsArray.Add(new TomlString { Value = "--directory" });
argsArray.Add(new TomlString { Value = serverSrc });
argsArray.Add(new TomlString { Value = "server.py" });
unityMCP["args"] = argsArray;
return unityMCP;
}
private static bool TryGetTable(TomlTable parent, string key, out TomlTable table)
{
table = null;
@ -211,33 +207,5 @@ namespace MCPForUnity.Editor.Helpers
return null;
}
private static string FormatTomlStringArray(IEnumerable<string> values)
{
if (values == null) return "[]";
StringBuilder sb = new StringBuilder();
sb.Append('[');
bool first = true;
foreach (string value in values)
{
if (!first)
{
sb.Append(", ");
}
sb.Append('"').Append(EscapeTomlString(value ?? string.Empty)).Append('"');
first = false;
}
sb.Append(']');
return sb.ToString();
}
private static string EscapeTomlString(string value)
{
if (string.IsNullOrEmpty(value)) return string.Empty;
return value
.Replace("\\", "\\\\")
.Replace("\"", "\\\"");
}
}
}

View File

@ -205,8 +205,7 @@ namespace MCPForUnity.Editor.Helpers
return "Configured successfully";
}
string codexBlock = CodexConfigHelper.BuildCodexServerBlock(uvPath, serverSrc);
string updatedToml = CodexConfigHelper.UpsertCodexServerBlock(existingToml, codexBlock);
string updatedToml = CodexConfigHelper.UpsertCodexServerBlock(existingToml, uvPath, serverSrc);
McpConfigFileHelper.WriteAtomicFile(configPath, updatedToml);

View File

@ -0,0 +1,192 @@
using System.IO;
using System.Linq;
using MCPForUnity.Editor.Data;
using MCPForUnity.Editor.Services;
using UnityEditor;
using UnityEngine;
namespace MCPForUnity.Editor.Helpers
{
/// <summary>
/// Automatically syncs Python tools to the MCP server when:
/// - PythonToolsAsset is modified
/// - Python files are imported/reimported
/// - Unity starts up
/// </summary>
[InitializeOnLoad]
public class PythonToolSyncProcessor : AssetPostprocessor
{
private const string SyncEnabledKey = "MCPForUnity.AutoSyncEnabled";
private static bool _isSyncing = false;
static PythonToolSyncProcessor()
{
// Sync on Unity startup
EditorApplication.delayCall += () =>
{
if (IsAutoSyncEnabled())
{
SyncAllTools();
}
};
}
/// <summary>
/// Called after any assets are imported, deleted, or moved
/// </summary>
private static void OnPostprocessAllAssets(
string[] importedAssets,
string[] deletedAssets,
string[] movedAssets,
string[] movedFromAssetPaths)
{
// Prevent infinite loop - don't process if we're currently syncing
if (_isSyncing || !IsAutoSyncEnabled())
return;
bool needsSync = false;
// Only check for .py file changes, not PythonToolsAsset changes
// (PythonToolsAsset changes are internal state updates from syncing)
foreach (string path in importedAssets.Concat(movedAssets))
{
// Check if any .py files were modified
if (path.EndsWith(".py"))
{
needsSync = true;
break;
}
}
// Check if any .py files were deleted
if (!needsSync && deletedAssets.Any(path => path.EndsWith(".py")))
{
needsSync = true;
}
if (needsSync)
{
SyncAllTools();
}
}
/// <summary>
/// Syncs all Python tools from all PythonToolsAsset instances to the MCP server
/// </summary>
public static void SyncAllTools()
{
// Prevent re-entrant calls
if (_isSyncing)
{
McpLog.Warn("Sync already in progress, skipping...");
return;
}
_isSyncing = true;
try
{
if (!ServerPathResolver.TryFindEmbeddedServerSource(out string srcPath))
{
McpLog.Warn("Cannot sync Python tools: MCP server source not found");
return;
}
string toolsDir = Path.Combine(srcPath, "tools", "custom");
var result = MCPServiceLocator.ToolSync.SyncProjectTools(toolsDir);
if (result.Success)
{
if (result.CopiedCount > 0 || result.SkippedCount > 0)
{
McpLog.Info($"Python tools synced: {result.CopiedCount} copied, {result.SkippedCount} skipped");
}
}
else
{
McpLog.Error($"Python tool sync failed with {result.ErrorCount} errors");
foreach (var msg in result.Messages)
{
McpLog.Error($" - {msg}");
}
}
}
catch (System.Exception ex)
{
McpLog.Error($"Python tool sync exception: {ex.Message}");
}
finally
{
_isSyncing = false;
}
}
/// <summary>
/// Checks if auto-sync is enabled (default: true)
/// </summary>
public static bool IsAutoSyncEnabled()
{
return EditorPrefs.GetBool(SyncEnabledKey, true);
}
/// <summary>
/// Enables or disables auto-sync
/// </summary>
public static void SetAutoSyncEnabled(bool enabled)
{
EditorPrefs.SetBool(SyncEnabledKey, enabled);
McpLog.Info($"Python tool auto-sync {(enabled ? "enabled" : "disabled")}");
}
/// <summary>
/// Menu item to reimport all Python files in the project
/// </summary>
[MenuItem("Window/MCP For Unity/Tool Sync/Reimport Python Files", priority = 99)]
public static void ReimportPythonFiles()
{
// Find all Python files (imported as TextAssets by PythonFileImporter)
var pythonGuids = AssetDatabase.FindAssets("t:TextAsset", new[] { "Assets" })
.Select(AssetDatabase.GUIDToAssetPath)
.Where(path => path.EndsWith(".py", System.StringComparison.OrdinalIgnoreCase))
.ToArray();
foreach (string path in pythonGuids)
{
AssetDatabase.ImportAsset(path, ImportAssetOptions.ForceUpdate);
}
int count = pythonGuids.Length;
McpLog.Info($"Reimported {count} Python files");
AssetDatabase.Refresh();
}
/// <summary>
/// Menu item to manually trigger sync
/// </summary>
[MenuItem("Window/MCP For Unity/Tool Sync/Sync Python Tools", priority = 100)]
public static void ManualSync()
{
McpLog.Info("Manually syncing Python tools...");
SyncAllTools();
}
/// <summary>
/// Menu item to toggle auto-sync
/// </summary>
[MenuItem("Window/MCP For Unity/Tool Sync/Auto-Sync Python Tools", priority = 101)]
public static void ToggleAutoSync()
{
SetAutoSyncEnabled(!IsAutoSyncEnabled());
}
/// <summary>
/// Validate menu item (shows checkmark when enabled)
/// </summary>
[MenuItem("Window/MCP For Unity/Tool Sync/Auto-Sync Python Tools", true, priority = 101)]
public static bool ToggleAutoSyncValidate()
{
Menu.SetChecked("Window/MCP For Unity/Tool Sync/Auto-Sync Python Tools", IsAutoSyncEnabled());
return true;
}
}
}

View File

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

View File

@ -65,6 +65,7 @@ namespace MCPForUnity.Editor.Helpers
// Copy the entire UnityMcpServer folder (parent of src)
string embeddedRoot = Path.GetDirectoryName(embeddedSrc) ?? embeddedSrc; // go up from src to UnityMcpServer
CopyDirectoryRecursive(embeddedRoot, destRoot);
// Write/refresh version file
try { File.WriteAllText(Path.Combine(destSrc, VersionFileName), embeddedVer ?? "unknown"); } catch { }
McpLog.Info($"Installed/updated server to {destRoot} (version {embeddedVer}).");
@ -410,6 +411,7 @@ namespace MCPForUnity.Editor.Helpers
}
private static readonly string[] _skipDirs = { ".venv", "__pycache__", ".pytest_cache", ".mypy_cache", ".git" };
private static void CopyDirectoryRecursive(string sourceDir, string destinationDir)
{
Directory.CreateDirectory(destinationDir);

View File

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

View File

@ -0,0 +1,21 @@
using UnityEngine;
using UnityEditor.AssetImporters;
using System.IO;
namespace MCPForUnity.Editor.Importers
{
/// <summary>
/// Custom importer that allows Unity to recognize .py files as TextAssets.
/// This enables Python files to be selected in the Inspector and used like any other text asset.
/// </summary>
[ScriptedImporter(1, "py")]
public class PythonFileImporter : ScriptedImporter
{
public override void OnImportAsset(AssetImportContext ctx)
{
var textAsset = new TextAsset(File.ReadAllText(ctx.assetPath));
ctx.AddObjectToAsset("main obj", textAsset);
ctx.SetMainObject(textAsset);
}
}
}

View File

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

View File

@ -0,0 +1,60 @@
namespace MCPForUnity.Editor.Services
{
/// <summary>
/// Service for checking package updates and version information
/// </summary>
public interface IPackageUpdateService
{
/// <summary>
/// Checks if a newer version of the package is available
/// </summary>
/// <param name="currentVersion">The current package version</param>
/// <returns>Update check result containing availability and latest version info</returns>
UpdateCheckResult CheckForUpdate(string currentVersion);
/// <summary>
/// Compares two version strings to determine if the first is newer than the second
/// </summary>
/// <param name="version1">First version string</param>
/// <param name="version2">Second version string</param>
/// <returns>True if version1 is newer than version2</returns>
bool IsNewerVersion(string version1, string version2);
/// <summary>
/// Determines if the package was installed via Git or Asset Store
/// </summary>
/// <returns>True if installed via Git, false if Asset Store or unknown</returns>
bool IsGitInstallation();
/// <summary>
/// Clears the cached update check data, forcing a fresh check on next request
/// </summary>
void ClearCache();
}
/// <summary>
/// Result of an update check operation
/// </summary>
public class UpdateCheckResult
{
/// <summary>
/// Whether an update is available
/// </summary>
public bool UpdateAvailable { get; set; }
/// <summary>
/// The latest version available (null if check failed or no update)
/// </summary>
public string LatestVersion { get; set; }
/// <summary>
/// Whether the check was successful (false if network error, etc.)
/// </summary>
public bool CheckSucceeded { get; set; }
/// <summary>
/// Optional message about the check result
/// </summary>
public string Message { get; set; }
}
}

View File

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

View File

@ -0,0 +1,14 @@
using System.Collections.Generic;
using UnityEngine;
using MCPForUnity.Editor.Data;
namespace MCPForUnity.Editor.Services
{
public interface IPythonToolRegistryService
{
IEnumerable<PythonToolsAsset> GetAllRegistries();
bool NeedsSync(PythonToolsAsset registry, TextAsset file);
void RecordSync(PythonToolsAsset registry, TextAsset file);
string ComputeHash(TextAsset file);
}
}

View File

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

View File

@ -0,0 +1,18 @@
using System.Collections.Generic;
namespace MCPForUnity.Editor.Services
{
public class ToolSyncResult
{
public int CopiedCount { get; set; }
public int SkippedCount { get; set; }
public int ErrorCount { get; set; }
public List<string> Messages { get; set; } = new List<string>();
public bool Success => ErrorCount == 0;
}
public interface IToolSyncService
{
ToolSyncResult SyncProjectTools(string destToolsDir);
}
}

View File

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

View File

@ -10,27 +10,18 @@ namespace MCPForUnity.Editor.Services
private static IBridgeControlService _bridgeService;
private static IClientConfigurationService _clientService;
private static IPathResolverService _pathService;
private static IPythonToolRegistryService _pythonToolRegistryService;
private static ITestRunnerService _testRunnerService;
private static IToolSyncService _toolSyncService;
private static IPackageUpdateService _packageUpdateService;
/// <summary>
/// Gets the bridge control service
/// </summary>
public static IBridgeControlService Bridge => _bridgeService ??= new BridgeControlService();
/// <summary>
/// Gets the client configuration service
/// </summary>
public static IClientConfigurationService Client => _clientService ??= new ClientConfigurationService();
/// <summary>
/// Gets the path resolver service
/// </summary>
public static IPathResolverService Paths => _pathService ??= new PathResolverService();
/// <summary>
/// Gets the Unity test runner service
/// </summary>
public static IPythonToolRegistryService PythonToolRegistry => _pythonToolRegistryService ??= new PythonToolRegistryService();
public static ITestRunnerService Tests => _testRunnerService ??= new TestRunnerService();
public static IToolSyncService ToolSync => _toolSyncService ??= new ToolSyncService();
public static IPackageUpdateService Updates => _packageUpdateService ??= new PackageUpdateService();
/// <summary>
/// Registers a custom implementation for a service (useful for testing)
@ -45,8 +36,14 @@ namespace MCPForUnity.Editor.Services
_clientService = c;
else if (implementation is IPathResolverService p)
_pathService = p;
else if (implementation is IPythonToolRegistryService ptr)
_pythonToolRegistryService = ptr;
else if (implementation is ITestRunnerService t)
_testRunnerService = t;
else if (implementation is IToolSyncService ts)
_toolSyncService = ts;
else if (implementation is IPackageUpdateService pu)
_packageUpdateService = pu;
}
/// <summary>
@ -57,12 +54,18 @@ namespace MCPForUnity.Editor.Services
(_bridgeService as IDisposable)?.Dispose();
(_clientService as IDisposable)?.Dispose();
(_pathService as IDisposable)?.Dispose();
(_pythonToolRegistryService as IDisposable)?.Dispose();
(_testRunnerService as IDisposable)?.Dispose();
(_toolSyncService as IDisposable)?.Dispose();
(_packageUpdateService as IDisposable)?.Dispose();
_bridgeService = null;
_clientService = null;
_pathService = null;
_pythonToolRegistryService = null;
_testRunnerService = null;
_toolSyncService = null;
_packageUpdateService = null;
}
}
}

View File

@ -0,0 +1,161 @@
using System;
using System.Net;
using MCPForUnity.Editor.Helpers;
using Newtonsoft.Json.Linq;
using UnityEditor;
namespace MCPForUnity.Editor.Services
{
/// <summary>
/// Service for checking package updates from GitHub
/// </summary>
public class PackageUpdateService : IPackageUpdateService
{
private const string LastCheckDateKey = "MCPForUnity.LastUpdateCheck";
private const string CachedVersionKey = "MCPForUnity.LatestKnownVersion";
private const string PackageJsonUrl = "https://raw.githubusercontent.com/CoplayDev/unity-mcp/main/MCPForUnity/package.json";
/// <inheritdoc/>
public UpdateCheckResult CheckForUpdate(string currentVersion)
{
// Check cache first - only check once per day
string lastCheckDate = EditorPrefs.GetString(LastCheckDateKey, "");
string cachedLatestVersion = EditorPrefs.GetString(CachedVersionKey, "");
if (lastCheckDate == DateTime.Now.ToString("yyyy-MM-dd") && !string.IsNullOrEmpty(cachedLatestVersion))
{
return new UpdateCheckResult
{
CheckSucceeded = true,
LatestVersion = cachedLatestVersion,
UpdateAvailable = IsNewerVersion(cachedLatestVersion, currentVersion),
Message = "Using cached version check"
};
}
// Don't check for Asset Store installations
if (!IsGitInstallation())
{
return new UpdateCheckResult
{
CheckSucceeded = false,
UpdateAvailable = false,
Message = "Asset Store installations are updated via Unity Asset Store"
};
}
// Fetch latest version from GitHub
string latestVersion = FetchLatestVersionFromGitHub();
if (!string.IsNullOrEmpty(latestVersion))
{
// Cache the result
EditorPrefs.SetString(LastCheckDateKey, DateTime.Now.ToString("yyyy-MM-dd"));
EditorPrefs.SetString(CachedVersionKey, latestVersion);
return new UpdateCheckResult
{
CheckSucceeded = true,
LatestVersion = latestVersion,
UpdateAvailable = IsNewerVersion(latestVersion, currentVersion),
Message = "Successfully checked for updates"
};
}
return new UpdateCheckResult
{
CheckSucceeded = false,
UpdateAvailable = false,
Message = "Failed to check for updates (network issue or offline)"
};
}
/// <inheritdoc/>
public bool IsNewerVersion(string version1, string version2)
{
try
{
// Remove any "v" prefix
version1 = version1.TrimStart('v', 'V');
version2 = version2.TrimStart('v', 'V');
var version1Parts = version1.Split('.');
var version2Parts = version2.Split('.');
for (int i = 0; i < Math.Min(version1Parts.Length, version2Parts.Length); i++)
{
if (int.TryParse(version1Parts[i], out int v1Num) &&
int.TryParse(version2Parts[i], out int v2Num))
{
if (v1Num > v2Num) return true;
if (v1Num < v2Num) return false;
}
}
return false;
}
catch
{
return false;
}
}
/// <inheritdoc/>
public bool IsGitInstallation()
{
// Git packages are installed via Package Manager and have a package.json in Packages/
// Asset Store packages are in Assets/
string packageRoot = AssetPathUtility.GetMcpPackageRootPath();
if (string.IsNullOrEmpty(packageRoot))
{
return false;
}
// If the package is in Packages/ it's a PM install (likely Git)
// If it's in Assets/ it's an Asset Store install
return packageRoot.StartsWith("Packages/", StringComparison.OrdinalIgnoreCase);
}
/// <inheritdoc/>
public void ClearCache()
{
EditorPrefs.DeleteKey(LastCheckDateKey);
EditorPrefs.DeleteKey(CachedVersionKey);
}
/// <summary>
/// Fetches the latest version from GitHub's main branch package.json
/// </summary>
private string FetchLatestVersionFromGitHub()
{
try
{
// GitHub API endpoint (Option 1 - has rate limits):
// https://api.github.com/repos/CoplayDev/unity-mcp/releases/latest
//
// We use Option 2 (package.json directly) because:
// - No API rate limits (GitHub serves raw files freely)
// - Simpler - just parse JSON for version field
// - More reliable - doesn't require releases to be published
// - Direct source of truth from the main branch
using (var client = new WebClient())
{
client.Headers.Add("User-Agent", "Unity-MCPForUnity-UpdateChecker");
string jsonContent = client.DownloadString(PackageJsonUrl);
var packageJson = JObject.Parse(jsonContent);
string version = packageJson["version"]?.ToString();
return string.IsNullOrEmpty(version) ? null : version;
}
}
catch (Exception ex)
{
// Silent fail - don't interrupt the user if network is unavailable
McpLog.Info($"Update check failed (this is normal if offline): {ex.Message}");
return null;
}
}
}
}

View File

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

View File

@ -0,0 +1,55 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Security.Cryptography;
using UnityEditor;
using UnityEngine;
using MCPForUnity.Editor.Data;
namespace MCPForUnity.Editor.Services
{
public class PythonToolRegistryService : IPythonToolRegistryService
{
public IEnumerable<PythonToolsAsset> GetAllRegistries()
{
// Find all PythonToolsAsset instances in the project
string[] guids = AssetDatabase.FindAssets("t:PythonToolsAsset");
foreach (string guid in guids)
{
string path = AssetDatabase.GUIDToAssetPath(guid);
var asset = AssetDatabase.LoadAssetAtPath<PythonToolsAsset>(path);
if (asset != null)
yield return asset;
}
}
public bool NeedsSync(PythonToolsAsset registry, TextAsset file)
{
if (!registry.useContentHashing) return true;
string currentHash = ComputeHash(file);
return registry.NeedsSync(file, currentHash);
}
public void RecordSync(PythonToolsAsset registry, TextAsset file)
{
string hash = ComputeHash(file);
registry.RecordSync(file, hash);
EditorUtility.SetDirty(registry);
}
public string ComputeHash(TextAsset file)
{
if (file == null || string.IsNullOrEmpty(file.text))
return string.Empty;
using (var sha256 = SHA256.Create())
{
byte[] bytes = System.Text.Encoding.UTF8.GetBytes(file.text);
byte[] hash = sha256.ComputeHash(bytes);
return BitConverter.ToString(hash).Replace("-", "").ToLower();
}
}
}
}

View File

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

View File

@ -0,0 +1,134 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using MCPForUnity.Editor.Helpers;
using UnityEditor;
namespace MCPForUnity.Editor.Services
{
public class ToolSyncService : IToolSyncService
{
private readonly IPythonToolRegistryService _registryService;
public ToolSyncService(IPythonToolRegistryService registryService = null)
{
_registryService = registryService ?? MCPServiceLocator.PythonToolRegistry;
}
public ToolSyncResult SyncProjectTools(string destToolsDir)
{
var result = new ToolSyncResult();
try
{
Directory.CreateDirectory(destToolsDir);
// Get all PythonToolsAsset instances in the project
var registries = _registryService.GetAllRegistries().ToList();
if (!registries.Any())
{
McpLog.Info("No PythonToolsAsset found. Create one via Assets > Create > MCP For Unity > Python Tools");
return result;
}
var syncedFiles = new HashSet<string>();
// Batch all asset modifications together to minimize reimports
AssetDatabase.StartAssetEditing();
try
{
foreach (var registry in registries)
{
foreach (var file in registry.GetValidFiles())
{
try
{
// Check if needs syncing (hash-based or always)
if (_registryService.NeedsSync(registry, file))
{
string destPath = Path.Combine(destToolsDir, file.name + ".py");
// Write the Python file content
File.WriteAllText(destPath, file.text);
// Record sync
_registryService.RecordSync(registry, file);
result.CopiedCount++;
syncedFiles.Add(destPath);
McpLog.Info($"Synced Python tool: {file.name}.py");
}
else
{
string destPath = Path.Combine(destToolsDir, file.name + ".py");
syncedFiles.Add(destPath);
result.SkippedCount++;
}
}
catch (Exception ex)
{
result.ErrorCount++;
result.Messages.Add($"Failed to sync {file.name}: {ex.Message}");
}
}
// Cleanup stale states in registry
registry.CleanupStaleStates();
EditorUtility.SetDirty(registry);
}
// Cleanup stale Python files in destination
CleanupStaleFiles(destToolsDir, syncedFiles);
}
finally
{
// End batch editing - this triggers a single asset refresh
AssetDatabase.StopAssetEditing();
}
// Save all modified registries
AssetDatabase.SaveAssets();
}
catch (Exception ex)
{
result.ErrorCount++;
result.Messages.Add($"Sync failed: {ex.Message}");
}
return result;
}
private void CleanupStaleFiles(string destToolsDir, HashSet<string> currentFiles)
{
try
{
if (!Directory.Exists(destToolsDir)) return;
// Find all .py files in destination that aren't in our current set
var existingFiles = Directory.GetFiles(destToolsDir, "*.py");
foreach (var file in existingFiles)
{
if (!currentFiles.Contains(file))
{
try
{
File.Delete(file);
McpLog.Info($"Cleaned up stale tool: {Path.GetFileName(file)}");
}
catch (Exception ex)
{
McpLog.Warn($"Failed to cleanup {file}: {ex.Message}");
}
}
}
}
catch (Exception ex)
{
McpLog.Warn($"Failed to cleanup stale files: {ex.Message}");
}
}
}
}

View File

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

View File

@ -242,7 +242,7 @@ namespace MCPForUnity.Editor.Windows
private void InitializeUI()
{
// Settings Section
versionLabel.text = AssetPathUtility.GetPackageVersion();
UpdateVersionLabel();
debugLogsToggle.value = EditorPrefs.GetBool("MCPForUnity.DebugLogs", false);
validationLevelField.Init(ValidationLevel.Standard);
@ -833,5 +833,28 @@ namespace MCPForUnity.Editor.Windows
EditorGUIUtility.systemCopyBuffer = configJsonField.value;
McpLog.Info("Configuration copied to clipboard");
}
private void UpdateVersionLabel()
{
string currentVersion = AssetPathUtility.GetPackageVersion();
versionLabel.text = $"v{currentVersion}";
// Check for updates using the service
var updateCheck = MCPServiceLocator.Updates.CheckForUpdate(currentVersion);
if (updateCheck.UpdateAvailable && !string.IsNullOrEmpty(updateCheck.LatestVersion))
{
// Update available - enhance the label
versionLabel.text = $"\u2191 v{currentVersion} (Update available: v{updateCheck.LatestVersion})";
versionLabel.style.color = new Color(1f, 0.7f, 0f); // Orange
versionLabel.tooltip = $"Version {updateCheck.LatestVersion} is available. Update via Package Manager.\n\nGit URL: https://github.com/CoplayDev/unity-mcp.git?path=/MCPForUnity";
}
else
{
versionLabel.style.color = StyleKeyword.Null; // Default color
versionLabel.tooltip = $"Current version: {currentVersion}";
}
}
}
}

View File

@ -0,0 +1,55 @@
"""
Shared module discovery utilities for auto-registering tools and resources.
"""
import importlib
import logging
from pathlib import Path
import pkgutil
from typing import Generator
logger = logging.getLogger("mcp-for-unity-server")
def discover_modules(base_dir: Path, package_name: str) -> Generator[str, None, None]:
"""
Discover and import all Python modules in a directory and its subdirectories.
Args:
base_dir: The base directory to search for modules
package_name: The package name to use for relative imports (e.g., 'tools' or 'resources')
Yields:
Full module names that were successfully imported
"""
# Discover modules in the top level
for _, module_name, _ in pkgutil.iter_modules([str(base_dir)]):
# Skip private modules and __init__
if module_name.startswith('_'):
continue
try:
full_module_name = f'.{module_name}'
importlib.import_module(full_module_name, package_name)
yield full_module_name
except Exception as e:
logger.warning(f"Failed to import module {module_name}: {e}")
# Discover modules in subdirectories (one level deep)
for subdir in base_dir.iterdir():
if not subdir.is_dir() or subdir.name.startswith('_') or subdir.name.startswith('.'):
continue
# Check if subdirectory contains Python modules
for _, module_name, _ in pkgutil.iter_modules([str(subdir)]):
# Skip private modules and __init__
if module_name.startswith('_'):
continue
try:
# Import as package.subdirname.modulename
full_module_name = f'.{subdir.name}.{module_name}'
importlib.import_module(full_module_name, package_name)
yield full_module_name
except Exception as e:
logger.warning(
f"Failed to import module {subdir.name}.{module_name}: {e}")

View File

@ -1,6 +1,6 @@
[project]
name = "MCPForUnityServer"
version = "6.1.0"
version = "6.2.0"
description = "MCP for Unity Server: A Unity package for Unity Editor integration via the Model Context Protocol (MCP)."
readme = "README.md"
requires-python = ">=3.10"

View File

@ -1,15 +1,14 @@
"""
MCP Resources package - Auto-discovers and registers all resources in this directory.
"""
import importlib
import logging
from pathlib import Path
import pkgutil
from mcp.server.fastmcp import FastMCP
from telemetry_decorator import telemetry_resource
from registry import get_registered_resources
from module_discovery import discover_modules
logger = logging.getLogger("mcp-for-unity-server")
@ -21,23 +20,15 @@ def register_all_resources(mcp: FastMCP):
"""
Auto-discover and register all resources in the resources/ directory.
Any .py file in this directory with @mcp_for_unity_resource decorated
Any .py file in this directory or subdirectories with @mcp_for_unity_resource decorated
functions will be automatically registered.
"""
logger.info("Auto-discovering MCP for Unity Server resources...")
# Dynamic import of all modules in this directory
resources_dir = Path(__file__).parent
for _, module_name, _ in pkgutil.iter_modules([str(resources_dir)]):
# Skip private modules and __init__
if module_name.startswith('_'):
continue
try:
importlib.import_module(f'.{module_name}', __package__)
except Exception as e:
logger.warning(
f"Failed to import resource module {module_name}: {e}")
# Discover and import all modules
list(discover_modules(resources_dir, __package__))
resources = get_registered_resources()

View File

@ -1 +1 @@
6.1.0
6.2.0

View File

@ -1,15 +1,14 @@
"""
MCP Tools package - Auto-discovers and registers all tools in this directory.
"""
import importlib
import logging
from pathlib import Path
import pkgutil
from mcp.server.fastmcp import FastMCP
from telemetry_decorator import telemetry_tool
from registry import get_registered_tools
from module_discovery import discover_modules
logger = logging.getLogger("mcp-for-unity-server")
@ -21,22 +20,15 @@ def register_all_tools(mcp: FastMCP):
"""
Auto-discover and register all tools in the tools/ directory.
Any .py file in this directory with @mcp_for_unity_tool decorated
Any .py file in this directory or subdirectories with @mcp_for_unity_tool decorated
functions will be automatically registered.
"""
logger.info("Auto-discovering MCP for Unity Server tools...")
# Dynamic import of all modules in this directory
tools_dir = Path(__file__).parent
for _, module_name, _ in pkgutil.iter_modules([str(tools_dir)]):
# Skip private modules and __init__
if module_name.startswith('_'):
continue
try:
importlib.import_module(f'.{module_name}', __package__)
except Exception as e:
logger.warning(f"Failed to import tool module {module_name}: {e}")
# Discover and import all modules
list(discover_modules(tools_dir, __package__))
tools = get_registered_tools()

View File

@ -1,6 +1,6 @@
{
"name": "com.coplaydev.unity-mcp",
"version": "6.1.0",
"version": "6.2.0",
"displayName": "MCP for Unity",
"description": "A bridge that connects AI assistants to Unity via the MCP (Model Context Protocol). Allows AI clients like Claude Code, Cursor, and VSCode to directly control your Unity Editor for enhanced development workflows.\n\nFeatures automated setup wizard, cross-platform support, and seamless integration with popular AI development tools.\n\nJoin Our Discord: https://discord.gg/y4p8KfzrN4",
"unity": "2021.3",

View File

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

View File

@ -0,0 +1,180 @@
using System;
using System.Linq;
using NUnit.Framework;
using UnityEngine;
using MCPForUnity.Editor.Data;
namespace MCPForUnityTests.Editor.Data
{
public class PythonToolsAssetTests
{
private PythonToolsAsset _asset;
[SetUp]
public void SetUp()
{
_asset = ScriptableObject.CreateInstance<PythonToolsAsset>();
}
[TearDown]
public void TearDown()
{
if (_asset != null)
{
UnityEngine.Object.DestroyImmediate(_asset, true);
}
}
[Test]
public void GetValidFiles_ReturnsEmptyList_WhenNoFilesAdded()
{
var validFiles = _asset.GetValidFiles().ToList();
Assert.IsEmpty(validFiles, "Should return empty list when no files added");
}
[Test]
public void GetValidFiles_FiltersOutNullReferences()
{
_asset.pythonFiles.Add(null);
_asset.pythonFiles.Add(new TextAsset("print('test')"));
_asset.pythonFiles.Add(null);
var validFiles = _asset.GetValidFiles().ToList();
Assert.AreEqual(1, validFiles.Count, "Should filter out null references");
}
[Test]
public void GetValidFiles_ReturnsAllNonNullFiles()
{
var file1 = new TextAsset("print('test1')");
var file2 = new TextAsset("print('test2')");
_asset.pythonFiles.Add(file1);
_asset.pythonFiles.Add(file2);
var validFiles = _asset.GetValidFiles().ToList();
Assert.AreEqual(2, validFiles.Count, "Should return all non-null files");
CollectionAssert.Contains(validFiles, file1);
CollectionAssert.Contains(validFiles, file2);
}
[Test]
public void NeedsSync_ReturnsTrue_WhenHashingDisabled()
{
_asset.useContentHashing = false;
var textAsset = new TextAsset("print('test')");
bool needsSync = _asset.NeedsSync(textAsset, "any_hash");
Assert.IsTrue(needsSync, "Should always need sync when hashing disabled");
}
[Test]
public void NeedsSync_ReturnsTrue_WhenFileNotInStates()
{
_asset.useContentHashing = true;
var textAsset = new TextAsset("print('test')");
bool needsSync = _asset.NeedsSync(textAsset, "new_hash");
Assert.IsTrue(needsSync, "Should need sync for new file");
}
[Test]
public void NeedsSync_ReturnsFalse_WhenHashMatches()
{
_asset.useContentHashing = true;
var textAsset = new TextAsset("print('test')");
string hash = "test_hash_123";
// Record the file with a hash
_asset.RecordSync(textAsset, hash);
// Check if needs sync with same hash
bool needsSync = _asset.NeedsSync(textAsset, hash);
Assert.IsFalse(needsSync, "Should not need sync when hash matches");
}
[Test]
public void NeedsSync_ReturnsTrue_WhenHashDiffers()
{
_asset.useContentHashing = true;
var textAsset = new TextAsset("print('test')");
// Record with one hash
_asset.RecordSync(textAsset, "old_hash");
// Check with different hash
bool needsSync = _asset.NeedsSync(textAsset, "new_hash");
Assert.IsTrue(needsSync, "Should need sync when hash differs");
}
[Test]
public void RecordSync_AddsNewFileState()
{
var textAsset = new TextAsset("print('test')");
string hash = "test_hash";
_asset.RecordSync(textAsset, hash);
Assert.AreEqual(1, _asset.fileStates.Count, "Should add one file state");
Assert.AreEqual(hash, _asset.fileStates[0].contentHash, "Should store the hash");
Assert.IsNotNull(_asset.fileStates[0].assetGuid, "Should store the GUID");
}
[Test]
public void RecordSync_UpdatesExistingFileState()
{
var textAsset = new TextAsset("print('test')");
// Record first time
_asset.RecordSync(textAsset, "hash1");
var firstTime = _asset.fileStates[0].lastSyncTime;
// Wait a tiny bit to ensure time difference
System.Threading.Thread.Sleep(10);
// Record second time with different hash
_asset.RecordSync(textAsset, "hash2");
Assert.AreEqual(1, _asset.fileStates.Count, "Should still have only one state");
Assert.AreEqual("hash2", _asset.fileStates[0].contentHash, "Should update the hash");
Assert.Greater(_asset.fileStates[0].lastSyncTime, firstTime, "Should update sync time");
}
[Test]
public void CleanupStaleStates_KeepsStatesForCurrentFiles()
{
var file1 = new TextAsset("print('test1')");
_asset.pythonFiles.Add(file1);
_asset.RecordSync(file1, "hash1");
_asset.CleanupStaleStates();
Assert.AreEqual(1, _asset.fileStates.Count, "Should keep state for current file");
}
[Test]
public void CleanupStaleStates_HandlesEmptyFilesList()
{
// Add some states without corresponding files
_asset.fileStates.Add(new PythonFileState
{
assetGuid = "fake_guid_1",
contentHash = "hash1",
fileName = "test1.py",
lastSyncTime = DateTime.UtcNow
});
_asset.CleanupStaleStates();
Assert.IsEmpty(_asset.fileStates, "Should remove all states when no files exist");
}
}
}

View File

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

View File

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

View File

@ -0,0 +1,295 @@
using System;
using NUnit.Framework;
using UnityEditor;
using MCPForUnity.Editor.Services;
namespace MCPForUnityTests.Editor.Services
{
public class PackageUpdateServiceTests
{
private PackageUpdateService _service;
private const string TestLastCheckDateKey = "MCPForUnity.LastUpdateCheck";
private const string TestCachedVersionKey = "MCPForUnity.LatestKnownVersion";
[SetUp]
public void SetUp()
{
_service = new PackageUpdateService();
// Clean up any existing test data
CleanupEditorPrefs();
}
[TearDown]
public void TearDown()
{
// Clean up test data
CleanupEditorPrefs();
}
private void CleanupEditorPrefs()
{
if (EditorPrefs.HasKey(TestLastCheckDateKey))
{
EditorPrefs.DeleteKey(TestLastCheckDateKey);
}
if (EditorPrefs.HasKey(TestCachedVersionKey))
{
EditorPrefs.DeleteKey(TestCachedVersionKey);
}
}
[Test]
public void IsNewerVersion_ReturnsTrue_WhenMajorVersionIsNewer()
{
bool result = _service.IsNewerVersion("2.0.0", "1.0.0");
Assert.IsTrue(result, "2.0.0 should be newer than 1.0.0");
}
[Test]
public void IsNewerVersion_ReturnsTrue_WhenMinorVersionIsNewer()
{
bool result = _service.IsNewerVersion("1.2.0", "1.1.0");
Assert.IsTrue(result, "1.2.0 should be newer than 1.1.0");
}
[Test]
public void IsNewerVersion_ReturnsTrue_WhenPatchVersionIsNewer()
{
bool result = _service.IsNewerVersion("1.0.2", "1.0.1");
Assert.IsTrue(result, "1.0.2 should be newer than 1.0.1");
}
[Test]
public void IsNewerVersion_ReturnsFalse_WhenVersionsAreEqual()
{
bool result = _service.IsNewerVersion("1.0.0", "1.0.0");
Assert.IsFalse(result, "Same versions should return false");
}
[Test]
public void IsNewerVersion_ReturnsFalse_WhenVersionIsOlder()
{
bool result = _service.IsNewerVersion("1.0.0", "2.0.0");
Assert.IsFalse(result, "1.0.0 should not be newer than 2.0.0");
}
[Test]
public void IsNewerVersion_HandlesVersionPrefix_v()
{
bool result = _service.IsNewerVersion("v2.0.0", "v1.0.0");
Assert.IsTrue(result, "Should handle 'v' prefix correctly");
}
[Test]
public void IsNewerVersion_HandlesVersionPrefix_V()
{
bool result = _service.IsNewerVersion("V2.0.0", "V1.0.0");
Assert.IsTrue(result, "Should handle 'V' prefix correctly");
}
[Test]
public void IsNewerVersion_HandlesMixedPrefixes()
{
bool result = _service.IsNewerVersion("v2.0.0", "1.0.0");
Assert.IsTrue(result, "Should handle mixed prefixes correctly");
}
[Test]
public void IsNewerVersion_ComparesCorrectly_WhenMajorDiffers()
{
bool result1 = _service.IsNewerVersion("10.0.0", "9.0.0");
bool result2 = _service.IsNewerVersion("2.0.0", "10.0.0");
Assert.IsTrue(result1, "10.0.0 should be newer than 9.0.0");
Assert.IsFalse(result2, "2.0.0 should not be newer than 10.0.0");
}
[Test]
public void IsNewerVersion_ReturnsFalse_OnInvalidVersionFormat()
{
// Service should handle errors gracefully
bool result = _service.IsNewerVersion("invalid", "1.0.0");
Assert.IsFalse(result, "Should return false for invalid version format");
}
[Test]
public void CheckForUpdate_ReturnsCachedVersion_WhenCacheIsValid()
{
// Arrange: Set up valid cache
string today = DateTime.Now.ToString("yyyy-MM-dd");
string cachedVersion = "5.5.5";
EditorPrefs.SetString(TestLastCheckDateKey, today);
EditorPrefs.SetString(TestCachedVersionKey, cachedVersion);
// Act
var result = _service.CheckForUpdate("5.0.0");
// Assert
Assert.IsTrue(result.CheckSucceeded, "Check should succeed with valid cache");
Assert.AreEqual(cachedVersion, result.LatestVersion, "Should return cached version");
Assert.IsTrue(result.UpdateAvailable, "Update should be available (5.5.5 > 5.0.0)");
}
[Test]
public void CheckForUpdate_DetectsUpdateAvailable_WhenNewerVersionCached()
{
// Arrange
string today = DateTime.Now.ToString("yyyy-MM-dd");
EditorPrefs.SetString(TestLastCheckDateKey, today);
EditorPrefs.SetString(TestCachedVersionKey, "6.0.0");
// Act
var result = _service.CheckForUpdate("5.0.0");
// Assert
Assert.IsTrue(result.UpdateAvailable, "Should detect update is available");
Assert.AreEqual("6.0.0", result.LatestVersion);
}
[Test]
public void CheckForUpdate_DetectsNoUpdate_WhenVersionsMatch()
{
// Arrange
string today = DateTime.Now.ToString("yyyy-MM-dd");
EditorPrefs.SetString(TestLastCheckDateKey, today);
EditorPrefs.SetString(TestCachedVersionKey, "5.0.0");
// Act
var result = _service.CheckForUpdate("5.0.0");
// Assert
Assert.IsFalse(result.UpdateAvailable, "Should detect no update needed");
Assert.AreEqual("5.0.0", result.LatestVersion);
}
[Test]
public void CheckForUpdate_DetectsNoUpdate_WhenCurrentVersionIsNewer()
{
// Arrange
string today = DateTime.Now.ToString("yyyy-MM-dd");
EditorPrefs.SetString(TestLastCheckDateKey, today);
EditorPrefs.SetString(TestCachedVersionKey, "5.0.0");
// Act
var result = _service.CheckForUpdate("6.0.0");
// Assert
Assert.IsFalse(result.UpdateAvailable, "Should detect no update when current is newer");
Assert.AreEqual("5.0.0", result.LatestVersion);
}
[Test]
public void CheckForUpdate_IgnoresExpiredCache_AndAttemptsFreshFetch()
{
// Arrange: Set cache from yesterday (expired)
string yesterday = DateTime.Now.AddDays(-1).ToString("yyyy-MM-dd");
string cachedVersion = "4.0.0";
EditorPrefs.SetString(TestLastCheckDateKey, yesterday);
EditorPrefs.SetString(TestCachedVersionKey, cachedVersion);
// Act
var result = _service.CheckForUpdate("5.0.0");
// Assert
Assert.IsNotNull(result, "Should return a result");
// If the check succeeded (network available), verify it didn't use the expired cache
if (result.CheckSucceeded)
{
Assert.AreNotEqual(cachedVersion, result.LatestVersion,
"Should not return expired cached version when fresh fetch succeeds");
Assert.IsNotNull(result.LatestVersion, "Should have fetched a new version");
}
else
{
// If offline, check should fail (not succeed with cached data)
Assert.IsFalse(result.UpdateAvailable,
"Should not report update available when fetch fails and cache is expired");
}
}
[Test]
public void CheckForUpdate_ReturnsAssetStoreMessage_ForNonGitInstallations()
{
// Note: This test verifies the service behavior when IsGitInstallation() returns false.
// Since the actual result depends on package installation method, we create a mock
// implementation to test this specific code path.
var mockService = new MockAssetStorePackageUpdateService();
// Act
var result = mockService.CheckForUpdate("5.0.0");
// Assert
Assert.IsFalse(result.CheckSucceeded, "Check should not succeed for Asset Store installs");
Assert.IsFalse(result.UpdateAvailable, "No update should be reported for Asset Store installs");
Assert.AreEqual("Asset Store installations are updated via Unity Asset Store", result.Message,
"Should return Asset Store update message");
Assert.IsNull(result.LatestVersion, "Latest version should be null for Asset Store installs");
}
[Test]
public void ClearCache_RemovesAllCachedData()
{
// Arrange: Set up cache
EditorPrefs.SetString(TestLastCheckDateKey, DateTime.Now.ToString("yyyy-MM-dd"));
EditorPrefs.SetString(TestCachedVersionKey, "5.0.0");
// Verify cache exists
Assert.IsTrue(EditorPrefs.HasKey(TestLastCheckDateKey), "Cache should exist before clearing");
Assert.IsTrue(EditorPrefs.HasKey(TestCachedVersionKey), "Cache should exist before clearing");
// Act
_service.ClearCache();
// Assert
Assert.IsFalse(EditorPrefs.HasKey(TestLastCheckDateKey), "Date cache should be cleared");
Assert.IsFalse(EditorPrefs.HasKey(TestCachedVersionKey), "Version cache should be cleared");
}
[Test]
public void ClearCache_DoesNotThrow_WhenNoCacheExists()
{
// Ensure no cache exists
CleanupEditorPrefs();
// Act & Assert - should not throw
Assert.DoesNotThrow(() => _service.ClearCache(), "Should not throw when clearing non-existent cache");
}
}
/// <summary>
/// Mock implementation of IPackageUpdateService that simulates Asset Store installation behavior
/// </summary>
internal class MockAssetStorePackageUpdateService : IPackageUpdateService
{
public UpdateCheckResult CheckForUpdate(string currentVersion)
{
// Simulate Asset Store installation (IsGitInstallation returns false)
return new UpdateCheckResult
{
CheckSucceeded = false,
UpdateAvailable = false,
Message = "Asset Store installations are updated via Unity Asset Store"
};
}
public bool IsNewerVersion(string version1, string version2)
{
// Not used in the Asset Store test, but required by interface
return false;
}
public bool IsGitInstallation()
{
// Simulate non-Git installation (Asset Store)
return false;
}
public void ClearCache()
{
// Not used in the Asset Store test, but required by interface
}
}
}

View File

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

View File

@ -0,0 +1,135 @@
using System.Collections.Generic;
using System.Linq;
using NUnit.Framework;
using UnityEngine;
using MCPForUnity.Editor.Data;
using MCPForUnity.Editor.Services;
namespace MCPForUnityTests.Editor.Services
{
public class PythonToolRegistryServiceTests
{
private PythonToolRegistryService _service;
[SetUp]
public void SetUp()
{
_service = new PythonToolRegistryService();
}
[Test]
public void GetAllRegistries_ReturnsEmptyList_WhenNoPythonToolsAssetsExist()
{
var registries = _service.GetAllRegistries().ToList();
// Note: This might find assets in the test project, so we just verify it doesn't throw
Assert.IsNotNull(registries, "Should return a non-null list");
}
[Test]
public void NeedsSync_ReturnsTrue_WhenHashingDisabled()
{
var asset = ScriptableObject.CreateInstance<PythonToolsAsset>();
asset.useContentHashing = false;
var textAsset = new TextAsset("print('test')");
bool needsSync = _service.NeedsSync(asset, textAsset);
Assert.IsTrue(needsSync, "Should always need sync when hashing is disabled");
Object.DestroyImmediate(asset);
}
[Test]
public void NeedsSync_ReturnsTrue_WhenFileNotPreviouslySynced()
{
var asset = ScriptableObject.CreateInstance<PythonToolsAsset>();
asset.useContentHashing = true;
var textAsset = new TextAsset("print('test')");
bool needsSync = _service.NeedsSync(asset, textAsset);
Assert.IsTrue(needsSync, "Should need sync for new file");
Object.DestroyImmediate(asset);
}
[Test]
public void NeedsSync_ReturnsFalse_WhenHashMatches()
{
var asset = ScriptableObject.CreateInstance<PythonToolsAsset>();
asset.useContentHashing = true;
var textAsset = new TextAsset("print('test')");
// First sync
_service.RecordSync(asset, textAsset);
// Check if needs sync again
bool needsSync = _service.NeedsSync(asset, textAsset);
Assert.IsFalse(needsSync, "Should not need sync when hash matches");
Object.DestroyImmediate(asset);
}
[Test]
public void RecordSync_StoresFileState()
{
var asset = ScriptableObject.CreateInstance<PythonToolsAsset>();
var textAsset = new TextAsset("print('test')");
_service.RecordSync(asset, textAsset);
Assert.AreEqual(1, asset.fileStates.Count, "Should have one file state recorded");
Assert.IsNotNull(asset.fileStates[0].contentHash, "Hash should be stored");
Assert.IsNotNull(asset.fileStates[0].assetGuid, "GUID should be stored");
Object.DestroyImmediate(asset);
}
[Test]
public void RecordSync_UpdatesExistingState_WhenFileAlreadyRecorded()
{
var asset = ScriptableObject.CreateInstance<PythonToolsAsset>();
var textAsset = new TextAsset("print('test')");
// Record twice
_service.RecordSync(asset, textAsset);
var firstHash = asset.fileStates[0].contentHash;
_service.RecordSync(asset, textAsset);
Assert.AreEqual(1, asset.fileStates.Count, "Should still have only one state");
Assert.AreEqual(firstHash, asset.fileStates[0].contentHash, "Hash should remain the same");
Object.DestroyImmediate(asset);
}
[Test]
public void ComputeHash_ReturnsSameHash_ForSameContent()
{
var textAsset1 = new TextAsset("print('hello')");
var textAsset2 = new TextAsset("print('hello')");
string hash1 = _service.ComputeHash(textAsset1);
string hash2 = _service.ComputeHash(textAsset2);
Assert.AreEqual(hash1, hash2, "Same content should produce same hash");
}
[Test]
public void ComputeHash_ReturnsDifferentHash_ForDifferentContent()
{
var textAsset1 = new TextAsset("print('hello')");
var textAsset2 = new TextAsset("print('world')");
string hash1 = _service.ComputeHash(textAsset1);
string hash2 = _service.ComputeHash(textAsset2);
Assert.AreNotEqual(hash1, hash2, "Different content should produce different hash");
}
}
}

View File

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

View File

@ -0,0 +1,72 @@
using System.IO;
using NUnit.Framework;
using UnityEngine;
using MCPForUnity.Editor.Data;
using MCPForUnity.Editor.Services;
namespace MCPForUnityTests.Editor.Services
{
public class ToolSyncServiceTests
{
private ToolSyncService _service;
private string _testToolsDir;
[SetUp]
public void SetUp()
{
_service = new ToolSyncService();
_testToolsDir = Path.Combine(Path.GetTempPath(), "UnityMCPTests", "tools");
// Clean up any existing test directory
if (Directory.Exists(_testToolsDir))
{
Directory.Delete(_testToolsDir, true);
}
}
[TearDown]
public void TearDown()
{
// Clean up test directory
if (Directory.Exists(_testToolsDir))
{
try
{
Directory.Delete(_testToolsDir, true);
}
catch
{
// Ignore cleanup errors
}
}
}
[Test]
public void SyncProjectTools_CreatesDestinationDirectory()
{
_service.SyncProjectTools(_testToolsDir);
Assert.IsTrue(Directory.Exists(_testToolsDir), "Should create destination directory");
}
[Test]
public void SyncProjectTools_ReturnsSuccess_WhenNoPythonToolsAssets()
{
var result = _service.SyncProjectTools(_testToolsDir);
Assert.IsNotNull(result, "Should return a result");
Assert.AreEqual(0, result.CopiedCount, "Should not copy any files");
Assert.AreEqual(0, result.ErrorCount, "Should not have errors");
}
[Test]
public void SyncProjectTools_ReportsCorrectCounts()
{
var result = _service.SyncProjectTools(_testToolsDir);
Assert.IsTrue(result.CopiedCount >= 0, "Copied count should be non-negative");
Assert.IsTrue(result.SkippedCount >= 0, "Skipped count should be non-negative");
Assert.IsTrue(result.ErrorCount >= 0, "Error count should be non-negative");
}
}
}

View File

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

View File

@ -71,6 +71,9 @@ namespace MCPForUnity.Editor.Windows
// Load validation level setting
LoadValidationLevelSetting();
// Show one-time migration dialog
ShowMigrationDialogIfNeeded();
// First-run auto-setup only if Claude CLI is available
if (autoRegisterEnabled && !string.IsNullOrEmpty(ExecPath.ResolveClaude()))
{
@ -170,6 +173,9 @@ namespace MCPForUnity.Editor.Windows
{
scrollPosition = EditorGUILayout.BeginScrollView(scrollPosition);
// Migration warning banner (non-dismissible)
DrawMigrationWarningBanner();
// Header
DrawHeader();
@ -1573,6 +1579,65 @@ namespace MCPForUnity.Editor.Windows
}
}
private void ShowMigrationDialogIfNeeded()
{
const string dialogShownKey = "MCPForUnity.LegacyMigrationDialogShown";
if (EditorPrefs.GetBool(dialogShownKey, false))
{
return; // Already shown
}
int result = EditorUtility.DisplayDialogComplex(
"Migration Required",
"This is the legacy UnityMcpBridge package.\n\n" +
"Please migrate to the new MCPForUnity package to receive updates and support.\n\n" +
"Migration takes just a few minutes.",
"View Migration Guide",
"Remind Me Later",
"I'll Migrate Later"
);
if (result == 0) // View Migration Guide
{
Application.OpenURL("https://github.com/CoplayDev/unity-mcp/blob/main/docs/v5_MIGRATION.md");
EditorPrefs.SetBool(dialogShownKey, true);
}
else if (result == 2) // I'll Migrate Later
{
EditorPrefs.SetBool(dialogShownKey, true);
}
// result == 1 (Remind Me Later) - don't set the flag, show again next time
}
private void DrawMigrationWarningBanner()
{
// Warning banner - not dismissible, always visible
EditorGUILayout.Space(5);
Rect bannerRect = EditorGUILayout.GetControlRect(false, 50);
EditorGUI.DrawRect(bannerRect, new Color(1f, 0.6f, 0f, 0.3f)); // Orange background
GUIStyle warningStyle = new GUIStyle(EditorStyles.boldLabel)
{
fontSize = 13,
alignment = TextAnchor.MiddleLeft,
richText = true
};
// Use Unicode warning triangle (same as used elsewhere in codebase at line 647, 652)
string warningText = "\u26A0 <color=#FF8C00>LEGACY PACKAGE:</color> Please migrate to MCPForUnity for updates and support.";
Rect textRect = new Rect(bannerRect.x + 15, bannerRect.y + 8, bannerRect.width - 180, bannerRect.height - 16);
GUI.Label(textRect, warningText, warningStyle);
// Button on the right
Rect buttonRect = new Rect(bannerRect.xMax - 160, bannerRect.y + 10, 145, 30);
if (GUI.Button(buttonRect, "View Migration Guide"))
{
Application.OpenURL("https://github.com/CoplayDev/unity-mcp/blob/main/docs/v5_MIGRATION.md");
}
EditorGUILayout.Space(5);
}
private bool IsPythonDetected()
{
try

View File

@ -1,19 +1,31 @@
# Adding Custom Tools to MCP for Unity
MCP for Unity now supports auto-discovery of custom tools using decorators (Python) and attributes (C#). This allows you to easily extend the MCP server with your own tools without modifying core files.
MCP for Unity supports auto-discovery of custom tools using decorators (Python) and attributes (C#). This allows you to easily extend the MCP server with your own tools.
Be sure to review the developer README first:
| [English](README-DEV.md) | [简体中文](README-DEV-zh.md) |
|---------------------------|------------------------------|
## Python Side (MCP Server)
---
### Creating a Custom Tool
# Part 1: How to Use (Quick Start Guide)
1. **Create a new Python file** in `MCPForUnity/UnityMcpServer~/src/tools/` (or any location that gets imported)
This section shows you how to add custom tools to your Unity project.
2. **Use the `@mcp_for_unity_tool` decorator**:
## Step 1: Create a PythonToolsAsset
First, create a ScriptableObject to manage your Python tools:
1. In Unity, right-click in the Project window
2. Select **Assets > Create > MCP For Unity > Python Tools**
3. Name it (e.g., `MyPythonTools`)
![Create Python Tools Asset](screenshots/v6_2_create_python_tools_asset.png)
## Step 2: Create Your Python Tool File
Create a Python file **anywhere in your Unity project**. For example, `Assets/Editor/MyTools/my_custom_tool.py`:
```python
from typing import Annotated, Any
@ -44,39 +56,20 @@ async def my_custom_tool(
return response if isinstance(response, dict) else {"success": False, "message": str(response)}
```
3. **The tool is automatically registered!** The decorator:
- Auto-generates the tool name from the function name (e.g., `my_custom_tool`)
- Registers the tool with FastMCP during module import
## Step 3: Add Python File to Asset
4. **Rebuild the server** in the MCP for Unity window (in the Unity Editor) to apply the changes.
1. Select your `PythonToolsAsset` in the Project window
2. In the Inspector, expand **Python Files**
3. Drag your `.py` file into the list (or click **+** and select it)
### Decorator Options
![Python Tools Asset Inspector](screenshots/v6_2_python_tools_asset.png)
```python
@mcp_for_unity_tool(
name="custom_name", # Optional: the function name is used by default
description="Tool description", # Required: describe what the tool does
)
```
**Note:** If you can't see `.py` files in the object picker, go to **Window > MCP For Unity > Tool Sync > Reimport Python Files** to force Unity to recognize them as text assets.
You can use all options available in FastMCP's `mcp.tool` function decorator: <https://gofastmcp.com/servers/tools#tools>.
## Step 4: Create C# Handler
**Note:** All tools should have the `description` field. It's not strictly required, however, that parameter is the best place to define a description so that most MCP clients can read it. See [issue #289](https://github.com/CoplayDev/unity-mcp/issues/289).
Create a C# file anywhere in your Unity project (typically in `Editor/`):
### Auto-Discovery
Tools are automatically discovered when:
- The Python file is in the `tools/` directory
- The file is imported during server startup
- The decorator `@mcp_for_unity_tool` is used
## C# Side (Unity Editor)
### Creating a Custom Tool Handler
1. **Create a new C# file** anywhere in your Unity project (typically in `Editor/`)
2. **Add the `[McpForUnityTool]` attribute** and implement `HandleCommand`:
```csharp
using Newtonsoft.Json.Linq;
@ -84,7 +77,6 @@ using MCPForUnity.Editor.Helpers;
namespace MyProject.Editor.CustomTools
{
// The name argument is optional, it uses a snake_case version of the class name by default
[McpForUnityTool("my_custom_tool")]
public static class MyCustomTool
{
@ -114,30 +106,23 @@ namespace MyProject.Editor.CustomTools
}
```
3. **The tool is automatically registered!** Unity will discover it via reflection on startup.
## Step 5: Rebuild the MCP Server
### Attribute Options
1. Open the MCP for Unity window in the Unity Editor
2. Click **Rebuild Server** to apply your changes
3. Your tool is now available to MCP clients!
```csharp
// Explicit command name
[McpForUnityTool("my_custom_tool")]
public static class MyCustomTool { }
**What happens automatically:**
- ✅ Python files are synced to the MCP server on Unity startup
- ✅ Python files are synced when modified (you would need to rebuild the server)
- ✅ C# handlers are discovered via reflection
- ✅ Tools are registered with the MCP server
// Auto-generated from class name (MyCustomTool → my_custom_tool)
[McpForUnityTool]
public static class MyCustomTool { }
```
## Complete Example: Screenshot Tool
### Auto-Discovery
Here's a complete example showing how to create a screenshot capture tool.
Tools are automatically discovered when:
- The class has the `[McpForUnityTool]` attribute
- The class has a `public static HandleCommand(JObject)` method
- Unity loads the assembly containing the class
## Complete Example: Custom Screenshot Tool
### Python (`UnityMcpServer~/src/tools/screenshot_tool.py`)
### Python File (`Assets/Editor/ScreenShots/Python/screenshot_tool.py`)
```python
from typing import Annotated, Any
@ -167,7 +152,13 @@ async def capture_screenshot(
return response if isinstance(response, dict) else {"success": False, "message": str(response)}
```
### C# (`Editor/CaptureScreenshotTool.cs`)
### Add to PythonToolsAsset
1. Select your `PythonToolsAsset`
2. Add `screenshot_tool.py` to the **Python Files** list
3. The file will automatically sync to the MCP server
### C# Handler (`Assets/Editor/ScreenShots/CaptureScreenshotTool.cs`)
```csharp
using System.IO;
@ -243,6 +234,94 @@ namespace MyProject.Editor.Tools
}
```
### Rebuild and Test
1. Open the MCP for Unity window
2. Click **Rebuild Server**
3. Test your tool from your MCP client!
---
# Part 2: How It Works (Technical Details)
This section explains the technical implementation of the custom tools system.
## Python Side: Decorator System
### The `@mcp_for_unity_tool` Decorator
The decorator automatically registers your function as an MCP tool:
```python
@mcp_for_unity_tool(
name="custom_name", # Optional: function name used by default
description="Tool description", # Required: describe what the tool does
)
```
**How it works:**
- Auto-generates the tool name from the function name (e.g., `my_custom_tool`)
- Registers the tool with FastMCP during module import
- Supports all FastMCP `mcp.tool` decorator options: <https://gofastmcp.com/servers/tools#tools>
**Note:** All tools should have the `description` field. It's not strictly required, however, that parameter is the best place to define a description so that most MCP clients can read it. See [issue #289](https://github.com/CoplayDev/unity-mcp/issues/289).
### Auto-Discovery
Python tools are automatically discovered when:
- The Python file is added to a `PythonToolsAsset`
- The file is synced to `MCPForUnity/UnityMcpServer~/src/tools/custom/`
- The file is imported during server startup
- The decorator `@mcp_for_unity_tool` is used
### Sync System
The `PythonToolsAsset` system automatically syncs your Python files:
**When sync happens:**
- ✅ Unity starts up
- ✅ Python files are modified
- ✅ Python files are added/removed from the asset
**Manual controls:**
- **Sync Now:** Window > MCP For Unity > Tool Sync > Sync Python Tools
- **Toggle Auto-Sync:** Window > MCP For Unity > Tool Sync > Auto-Sync Python Tools
- **Reimport Python Files:** Window > MCP For Unity > Tool Sync > Reimport Python Files
**How it works:**
- Uses content hashing to detect changes (only syncs modified files)
- Files are copied to `MCPForUnity/UnityMcpServer~/src/tools/custom/`
- Stale files are automatically cleaned up
## C# Side: Attribute System
### The `[McpForUnityTool]` Attribute
The attribute marks your class as a tool handler:
```csharp
// Explicit command name
[McpForUnityTool("my_custom_tool")]
public static class MyCustomTool { }
// Auto-generated from class name (MyCustomTool → my_custom_tool)
[McpForUnityTool]
public static class MyCustomTool { }
```
### Auto-Discovery
C# handlers are automatically discovered when:
- The class has the `[McpForUnityTool]` attribute
- The class has a `public static HandleCommand(JObject)` method
- Unity loads the assembly containing the class
**How it works:**
- Unity scans all assemblies on startup
- Finds classes with `[McpForUnityTool]` attribute
- Registers them in the command registry
- Routes MCP commands to the appropriate handler
## Best Practices
### Python
@ -274,8 +353,26 @@ namespace MyProject.Editor.Tools
## Troubleshooting
**Tool not appearing:**
- Python: Ensure the file is in `tools/` directory and imports the decorator
- C#: Ensure the class has `[McpForUnityTool]` attribute and `HandleCommand` method
- **Python:**
- Ensure the `.py` file is added to a `PythonToolsAsset`
- Check Unity Console for sync messages: "Python tools synced: X copied"
- Verify file was synced to `UnityMcpServer~/src/tools/custom/`
- Try manual sync: Window > MCP For Unity > Tool Sync > Sync Python Tools
- Rebuild the server in the MCP for Unity window
- **C#:**
- Ensure the class has `[McpForUnityTool]` attribute
- Ensure the class has a `public static HandleCommand(JObject)` method
- Check Unity Console for: "MCP-FOR-UNITY: Auto-discovered X tools"
**Python files not showing in Inspector:**
- Go to **Window > MCP For Unity > Tool Sync > Reimport Python Files**
- This forces Unity to recognize `.py` files as TextAssets
- Check that `.py.meta` files show `ScriptedImporter` (not `DefaultImporter`)
**Sync not working:**
- Check if auto-sync is enabled: Window > MCP For Unity > Tool Sync > Auto-Sync Python Tools
- Look for errors in Unity Console
- Verify `PythonToolsAsset` has the correct files added
**Name conflicts:**
- Use explicit names in decorators/attributes to avoid conflicts

Binary file not shown.

After

Width:  |  Height:  |  Size: 330 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 402 KiB