diff --git a/MCPForUnity/Editor/Dependencies/Models.meta b/MCPForUnity/Editor/Dependencies/Models.meta index 2174dd52..3ba640c9 100644 --- a/MCPForUnity/Editor/Dependencies/Models.meta +++ b/MCPForUnity/Editor/Dependencies/Models.meta @@ -1,5 +1,5 @@ fileFormatVersion: 2 -guid: b2c3d4e5f6789012345678901234abcd +guid: 2b4fca8c8f964494e82a2c1d1d8d2041 folderAsset: yes DefaultImporter: externalObjects: {} diff --git a/MCPForUnity/Editor/Dependencies/PlatformDetectors.meta b/MCPForUnity/Editor/Dependencies/PlatformDetectors.meta index 22a6b1db..be8b8dce 100644 --- a/MCPForUnity/Editor/Dependencies/PlatformDetectors.meta +++ b/MCPForUnity/Editor/Dependencies/PlatformDetectors.meta @@ -1,5 +1,5 @@ fileFormatVersion: 2 -guid: c3d4e5f6789012345678901234abcdef +guid: c6d16631d05433740a7193d3384364a8 folderAsset: yes DefaultImporter: externalObjects: {} diff --git a/MCPForUnity/Editor/Helpers/PackageDetector.cs b/MCPForUnity/Editor/Helpers/PackageDetector.cs index bb8861fe..cb044093 100644 --- a/MCPForUnity/Editor/Helpers/PackageDetector.cs +++ b/MCPForUnity/Editor/Helpers/PackageDetector.cs @@ -23,7 +23,10 @@ static PackageDetector() bool legacyPresent = LegacyRootsExist(); bool canonicalMissing = !System.IO.File.Exists(System.IO.Path.Combine(ServerInstaller.GetServerPath(), "server.py")); - if (!EditorPrefs.GetBool(key, false) || legacyPresent || canonicalMissing) + // Check if any MCPForUnityTools have updated versions + bool toolsNeedUpdate = ToolsVersionsChanged(); + + if (!EditorPrefs.GetBool(key, false) || legacyPresent || canonicalMissing || toolsNeedUpdate) { // Marshal the entire flow to the main thread. EnsureServerInstalled may touch Unity APIs. EditorApplication.delayCall += () => @@ -103,5 +106,76 @@ private static bool LegacyRootsExist() catch { } return false; } + + /// + /// Checks if any MCPForUnityTools folders have version.txt files that differ from installed versions. + /// Returns true if any tool needs updating. + /// + private static bool ToolsVersionsChanged() + { + try + { + // Get Unity project root + string projectRoot = System.IO.Directory.GetParent(UnityEngine.Application.dataPath)?.FullName; + if (string.IsNullOrEmpty(projectRoot)) + { + return false; + } + + // Get server tools directory + string serverPath = ServerInstaller.GetServerPath(); + string toolsDir = System.IO.Path.Combine(serverPath, "tools"); + + if (!System.IO.Directory.Exists(toolsDir)) + { + // Tools directory doesn't exist yet, needs initial setup + return true; + } + + // Find all MCPForUnityTools folders in project + var toolsFolders = System.IO.Directory.GetDirectories(projectRoot, "MCPForUnityTools", System.IO.SearchOption.AllDirectories); + + foreach (var folder in toolsFolders) + { + // Check if version.txt exists in this folder + string versionFile = System.IO.Path.Combine(folder, "version.txt"); + if (!System.IO.File.Exists(versionFile)) + { + continue; // No version tracking for this folder + } + + // Read source version + string sourceVersion = System.IO.File.ReadAllText(versionFile)?.Trim(); + if (string.IsNullOrEmpty(sourceVersion)) + { + continue; + } + + // Get folder identifier (same logic as ServerInstaller.GetToolsFolderIdentifier) + string folderIdentifier = ServerInstaller.GetToolsFolderIdentifier(folder); + string trackingFile = System.IO.Path.Combine(toolsDir, $"{folderIdentifier}_version.txt"); + + // Read installed version + string installedVersion = null; + if (System.IO.File.Exists(trackingFile)) + { + installedVersion = System.IO.File.ReadAllText(trackingFile)?.Trim(); + } + + // Check if versions differ + if (string.IsNullOrEmpty(installedVersion) || sourceVersion != installedVersion) + { + return true; // Version changed, needs update + } + } + + return false; // All versions match + } + catch + { + // On error, assume update needed to be safe + return true; + } + } } } diff --git a/MCPForUnity/Editor/Helpers/ServerInstaller.cs b/MCPForUnity/Editor/Helpers/ServerInstaller.cs index f41e03c3..0368b596 100644 --- a/MCPForUnity/Editor/Helpers/ServerInstaller.cs +++ b/MCPForUnity/Editor/Helpers/ServerInstaller.cs @@ -52,11 +52,16 @@ public static void EnsureServerInstalled() // 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})."); } + // Copy Unity project tools (runs independently of server version updates) + string destToolsDir = Path.Combine(destSrc, "tools"); + CopyUnityProjectTools(destToolsDir); + // Cleanup legacy installs that are missing version or older than embedded foreach (var legacyRoot in GetLegacyRootsForDetection()) { @@ -397,6 +402,232 @@ private static bool TryGetEmbeddedServerSource(out string srcPath) } private static readonly string[] _skipDirs = { ".venv", "__pycache__", ".pytest_cache", ".mypy_cache", ".git" }; + + /// + /// Searches Unity project for MCPForUnityTools folders and copies .py files to server tools directory. + /// Only copies if the tool's version.txt has changed (or doesn't exist). + /// Files are copied into per-folder subdirectories to avoid conflicts. + /// + private static void CopyUnityProjectTools(string destToolsDir) + { + try + { + // Get Unity project root + string projectRoot = Directory.GetParent(Application.dataPath)?.FullName; + if (string.IsNullOrEmpty(projectRoot)) + { + return; + } + + // Ensure destToolsDir exists + Directory.CreateDirectory(destToolsDir); + + // Limit scan to specific directories to avoid deep recursion + var searchRoots = new List(); + var assetsPath = Path.Combine(projectRoot, "Assets"); + var packagesPath = Path.Combine(projectRoot, "Packages"); + var packageCachePath = Path.Combine(projectRoot, "Library", "PackageCache"); + + if (Directory.Exists(assetsPath)) searchRoots.Add(assetsPath); + if (Directory.Exists(packagesPath)) searchRoots.Add(packagesPath); + if (Directory.Exists(packageCachePath)) searchRoots.Add(packageCachePath); + + // Find all MCPForUnityTools folders in limited search roots + var toolsFolders = new List(); + foreach (var searchRoot in searchRoots) + { + try + { + toolsFolders.AddRange(Directory.GetDirectories(searchRoot, "MCPForUnityTools", SearchOption.AllDirectories)); + } + catch (Exception ex) + { + McpLog.Warn($"Failed to search {searchRoot}: {ex.Message}"); + } + } + + int copiedCount = 0; + int skippedCount = 0; + + // Track all active folder identifiers (for cleanup) + var activeFolderIdentifiers = new HashSet(); + + foreach (var folder in toolsFolders) + { + // Generate unique identifier for this tools folder based on its parent directory structure + // e.g., "MooseRunner_MCPForUnityTools" or "MyPackage_MCPForUnityTools" + string folderIdentifier = GetToolsFolderIdentifier(folder); + activeFolderIdentifiers.Add(folderIdentifier); + + // Create per-folder subdirectory in destToolsDir + string destFolderSubdir = Path.Combine(destToolsDir, folderIdentifier); + Directory.CreateDirectory(destFolderSubdir); + + string versionTrackingFile = Path.Combine(destFolderSubdir, "version.txt"); + + // Read source version + string sourceVersionFile = Path.Combine(folder, "version.txt"); + string sourceVersion = ReadVersionFile(sourceVersionFile) ?? "0.0.0"; + + // Read installed version (tracked separately per tools folder) + string installedVersion = ReadVersionFile(versionTrackingFile); + + // Check if update is needed (version different or no tracking file) + bool needsUpdate = string.IsNullOrEmpty(installedVersion) || sourceVersion != installedVersion; + + if (needsUpdate) + { + // Get all .py files (excluding __init__.py) + var pyFiles = Directory.GetFiles(folder, "*.py") + .Where(f => !Path.GetFileName(f).Equals("__init__.py", StringComparison.OrdinalIgnoreCase)); + + // Skip folders with no .py files + if (!pyFiles.Any()) + { + skippedCount++; + continue; + } + + bool copyFailed = false; + foreach (var pyFile in pyFiles) + { + string fileName = Path.GetFileName(pyFile); + string destFile = Path.Combine(destFolderSubdir, fileName); + + try + { + File.Copy(pyFile, destFile, overwrite: true); + copiedCount++; + McpLog.Info($"Copied Unity project tool: {fileName} from {folderIdentifier} (v{sourceVersion})"); + } + catch (Exception ex) + { + McpLog.Warn($"Failed to copy {fileName}: {ex.Message}"); + copyFailed = true; + } + } + + // Update version tracking file only on full success + if (!copyFailed) + { + try + { + File.WriteAllText(versionTrackingFile, sourceVersion); + } + catch (Exception ex) + { + McpLog.Warn($"Failed to write version tracking file for {folderIdentifier}: {ex.Message}"); + } + } + } + else + { + skippedCount++; + } + } + + // Clean up stale subdirectories (folders removed from upstream) + CleanupStaleToolFolders(destToolsDir, activeFolderIdentifiers); + + if (copiedCount > 0) + { + McpLog.Info($"Copied {copiedCount} Unity project tool(s) to server"); + } + } + catch (Exception ex) + { + McpLog.Warn($"Failed to scan Unity project for tools: {ex.Message}"); + } + } + + /// + /// Removes stale tool subdirectories that are no longer present in the Unity project. + /// + private static void CleanupStaleToolFolders(string destToolsDir, HashSet activeFolderIdentifiers) + { + try + { + if (!Directory.Exists(destToolsDir)) return; + + // Get all subdirectories in destToolsDir + var existingSubdirs = Directory.GetDirectories(destToolsDir); + + foreach (var subdir in existingSubdirs) + { + string subdirName = Path.GetFileName(subdir); + + // Skip Python cache and virtual environment directories + foreach (var skip in _skipDirs) + { + if (subdirName.Equals(skip, StringComparison.OrdinalIgnoreCase)) + goto NextSubdir; + } + + // Only manage per-folder tool installs created by this feature + if (!subdirName.EndsWith("_MCPForUnityTools", StringComparison.OrdinalIgnoreCase)) + goto NextSubdir; + + // Check if this subdirectory corresponds to an active tools folder + if (!activeFolderIdentifiers.Contains(subdirName)) + { + try + { + Directory.Delete(subdir, recursive: true); + McpLog.Info($"Cleaned up stale tools folder: {subdirName}"); + } + catch (Exception ex) + { + McpLog.Warn($"Failed to delete stale folder {subdirName}: {ex.Message}"); + } + } + NextSubdir:; + } + } + catch (Exception ex) + { + McpLog.Warn($"Failed to cleanup stale tool folders: {ex.Message}"); + } + } + + /// + /// Generates a unique identifier for a MCPForUnityTools folder based on its parent directory. + /// Example: "Assets/MooseRunner/Editor/MCPForUnityTools" → "MooseRunner_MCPForUnityTools" + /// + internal static string GetToolsFolderIdentifier(string toolsFolderPath) + { + try + { + // Get parent directory name (e.g., "Editor" or package name) + DirectoryInfo parent = Directory.GetParent(toolsFolderPath); + if (parent == null) return "MCPForUnityTools"; + + // Walk up to find a distinctive parent (Assets/PackageName or Packages/PackageName) + DirectoryInfo current = parent; + while (current != null) + { + string name = current.Name; + DirectoryInfo grandparent = current.Parent; + + // Stop at Assets, Packages, or if we find a package-like structure + if (grandparent != null && + (grandparent.Name.Equals("Assets", StringComparison.OrdinalIgnoreCase) || + grandparent.Name.Equals("Packages", StringComparison.OrdinalIgnoreCase))) + { + return $"{grandparent.Name}_{name}_MCPForUnityTools"; + } + + current = grandparent; + } + + // Fallback: use immediate parent + return $"{parent.Name}_MCPForUnityTools"; + } + catch + { + return "MCPForUnityTools"; + } + } + private static void CopyDirectoryRecursive(string sourceDir, string destinationDir) { Directory.CreateDirectory(destinationDir); @@ -461,6 +692,10 @@ public static bool RebuildMcpServer() Directory.CreateDirectory(destRoot); CopyDirectoryRecursive(embeddedRoot, destRoot); + // Copy Unity project tools + string destToolsDir = Path.Combine(destSrc, "tools"); + CopyUnityProjectTools(destToolsDir); + // Write version file string embeddedVer = ReadVersionFile(Path.Combine(embeddedSrc, VersionFileName)) ?? "unknown"; try diff --git a/MCPForUnity/UnityMcpServer~/src/tools/__init__.py b/MCPForUnity/UnityMcpServer~/src/tools/__init__.py index 6ede53d3..a6fcb17c 100644 --- a/MCPForUnity/UnityMcpServer~/src/tools/__init__.py +++ b/MCPForUnity/UnityMcpServer~/src/tools/__init__.py @@ -21,13 +21,14 @@ 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 + # Discover modules in the top level for _, module_name, _ in pkgutil.iter_modules([str(tools_dir)]): # Skip private modules and __init__ if module_name.startswith('_'): @@ -38,6 +39,24 @@ def register_all_tools(mcp: FastMCP): except Exception as e: logger.warning(f"Failed to import tool module {module_name}: {e}") + # Discover modules in subdirectories (one level deep) + for subdir in tools_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 tools.subdirname.modulename + full_module_name = f'.{subdir.name}.{module_name}' + importlib.import_module(full_module_name, __package__) + except Exception as e: + logger.warning(f"Failed to import tool module {subdir.name}.{module_name}: {e}") + tools = get_registered_tools() if not tools: