Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 75 additions & 1 deletion MCPForUnity/Editor/Helpers/PackageDetector.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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 += () =>
Expand Down Expand Up @@ -103,5 +106,76 @@ private static bool LegacyRootsExist()
catch { }
return false;
}

/// <summary>
/// Checks if any MCPForUnityTools folders have version.txt files that differ from installed versions.
/// Returns true if any tool needs updating.
/// </summary>
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;
}
}
}
}
235 changes: 235 additions & 0 deletions MCPForUnity/Editor/Helpers/ServerInstaller.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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())
{
Expand Down Expand Up @@ -397,6 +402,232 @@ private static bool TryGetEmbeddedServerSource(out string srcPath)
}

private static readonly string[] _skipDirs = { ".venv", "__pycache__", ".pytest_cache", ".mypy_cache", ".git" };

/// <summary>
/// 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.
/// </summary>
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<string>();
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<string>();
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<string>();

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

/// <summary>
/// Removes stale tool subdirectories that are no longer present in the Unity project.
/// </summary>
private static void CleanupStaleToolFolders(string destToolsDir, HashSet<string> 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}");
}
}

/// <summary>
/// Generates a unique identifier for a MCPForUnityTools folder based on its parent directory.
/// Example: "Assets/MooseRunner/Editor/MCPForUnityTools" → "MooseRunner_MCPForUnityTools"
/// </summary>
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);
Expand Down Expand Up @@ -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
Expand Down
21 changes: 20 additions & 1 deletion MCPForUnity/UnityMcpServer~/src/tools/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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('_'):
Expand All @@ -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:
Expand Down