From f7138105616180020dde244f7b0bc5b69c8962c6 Mon Sep 17 00:00:00 2001
From: Johan Holtby <72528418+JohanHoltby@users.noreply.github.com>
Date: Thu, 9 Oct 2025 09:36:07 +0200
Subject: [PATCH 01/26] Fix issue #308: Find py files in MCPForUnityTools and
version.txt
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
This allows for auto finding new tools. A good dir on a custom tool would look like this:
CustomTool/
├── CustomTool.MCPEnabler.asmdef
├── CustomTool.MCPEnabler.asmdef.meta
├── ExternalAssetToolFunction.cs
├── ExternalAssetToolFunction.cs.meta
├── external_asset_tool_function.py
├── external_asset_tool_function.py.meta
├── version.txt
└── version.txt.meta
CS files are left in the tools folder. The asmdef is recommended to allow for dependency on MCPForUnity when MCP For Unity is installed:
asmdef example
{
"name": "CustomTool.MCPEnabler",
"rootNamespace": "MCPForUnity.Editor.Tools",
"references": [
"CustomTool",
"MCPForUnity.Editor"
],
"includePlatforms": [
"Editor"
],
"excludePlatforms": [],
"allowUnsafeCode": false,
"overrideReferences": false,
"precompiledReferences": [],
"autoReferenced": true,
"defineConstraints": [],
"versionDefines": [],
"noEngineReferences": false
}
---
MCPForUnity/Editor/Helpers/PackageDetector.cs | 110 +++++++++++++-
MCPForUnity/Editor/Helpers/ServerInstaller.cs | 137 ++++++++++++++++++
2 files changed, 246 insertions(+), 1 deletion(-)
diff --git a/MCPForUnity/Editor/Helpers/PackageDetector.cs b/MCPForUnity/Editor/Helpers/PackageDetector.cs
index bb8861fe..80bb96ac 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,110 @@ 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 = 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;
+ }
+ }
+
+ ///
+ /// Generates a unique identifier for a MCPForUnityTools folder (duplicates ServerInstaller logic).
+ ///
+ private static string GetToolsFolderIdentifier(string toolsFolderPath)
+ {
+ try
+ {
+ System.IO.DirectoryInfo parent = System.IO.Directory.GetParent(toolsFolderPath);
+ if (parent == null) return "MCPForUnityTools";
+
+ System.IO.DirectoryInfo current = parent;
+ while (current != null)
+ {
+ string name = current.Name;
+ System.IO.DirectoryInfo grandparent = current.Parent;
+
+ if (grandparent != null &&
+ (grandparent.Name.Equals("Assets", System.StringComparison.OrdinalIgnoreCase) ||
+ grandparent.Name.Equals("Packages", System.StringComparison.OrdinalIgnoreCase)))
+ {
+ return $"{name}_MCPForUnityTools";
+ }
+
+ current = grandparent;
+ }
+
+ return $"{parent.Name}_MCPForUnityTools";
+ }
+ catch
+ {
+ return "MCPForUnityTools";
+ }
+ }
}
}
diff --git a/MCPForUnity/Editor/Helpers/ServerInstaller.cs b/MCPForUnity/Editor/Helpers/ServerInstaller.cs
index f41e03c3..713c00a0 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,134 @@ 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).
+ ///
+ private static void CopyUnityProjectTools(string destToolsDir)
+ {
+ try
+ {
+ // Get Unity project root
+ string projectRoot = Directory.GetParent(Application.dataPath)?.FullName;
+ if (string.IsNullOrEmpty(projectRoot))
+ {
+ return;
+ }
+
+ // Find all MCPForUnityTools folders
+ var toolsFolders = Directory.GetDirectories(projectRoot, "MCPForUnityTools", SearchOption.AllDirectories);
+
+ int copiedCount = 0;
+ int skippedCount = 0;
+
+ 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);
+ string versionTrackingFile = Path.Combine(destToolsDir, $"{folderIdentifier}_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));
+
+ foreach (var pyFile in pyFiles)
+ {
+ string fileName = Path.GetFileName(pyFile);
+ string destFile = Path.Combine(destToolsDir, 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}");
+ }
+ }
+
+ // Update version tracking file
+ try
+ {
+ File.WriteAllText(versionTrackingFile, sourceVersion);
+ }
+ catch (Exception ex)
+ {
+ McpLog.Warn($"Failed to write version tracking file for {folderIdentifier}: {ex.Message}");
+ }
+ }
+ else
+ {
+ skippedCount++;
+ }
+ }
+
+ 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}");
+ }
+ }
+
+ ///
+ /// Generates a unique identifier for a MCPForUnityTools folder based on its parent directory.
+ /// Example: "Assets/MooseRunner/Editor/MCPForUnityTools" → "MooseRunner_MCPForUnityTools"
+ ///
+ private 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 $"{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 +594,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
From 48d912e0ef62890eb9b5e8e379450cea06010e09 Mon Sep 17 00:00:00 2001
From: Johan Holtby <72528418+JohanHoltby@users.noreply.github.com>
Date: Thu, 9 Oct 2025 10:38:42 +0200
Subject: [PATCH 02/26] Follow-up: address CodeRabbit feedback for #308
()
---
MCPForUnity/Editor/Helpers/PackageDetector.cs | 36 +------------------
MCPForUnity/Editor/Helpers/ServerInstaller.cs | 2 +-
2 files changed, 2 insertions(+), 36 deletions(-)
diff --git a/MCPForUnity/Editor/Helpers/PackageDetector.cs b/MCPForUnity/Editor/Helpers/PackageDetector.cs
index 80bb96ac..cb044093 100644
--- a/MCPForUnity/Editor/Helpers/PackageDetector.cs
+++ b/MCPForUnity/Editor/Helpers/PackageDetector.cs
@@ -152,7 +152,7 @@ private static bool ToolsVersionsChanged()
}
// Get folder identifier (same logic as ServerInstaller.GetToolsFolderIdentifier)
- string folderIdentifier = GetToolsFolderIdentifier(folder);
+ string folderIdentifier = ServerInstaller.GetToolsFolderIdentifier(folder);
string trackingFile = System.IO.Path.Combine(toolsDir, $"{folderIdentifier}_version.txt");
// Read installed version
@@ -177,39 +177,5 @@ private static bool ToolsVersionsChanged()
return true;
}
}
-
- ///
- /// Generates a unique identifier for a MCPForUnityTools folder (duplicates ServerInstaller logic).
- ///
- private static string GetToolsFolderIdentifier(string toolsFolderPath)
- {
- try
- {
- System.IO.DirectoryInfo parent = System.IO.Directory.GetParent(toolsFolderPath);
- if (parent == null) return "MCPForUnityTools";
-
- System.IO.DirectoryInfo current = parent;
- while (current != null)
- {
- string name = current.Name;
- System.IO.DirectoryInfo grandparent = current.Parent;
-
- if (grandparent != null &&
- (grandparent.Name.Equals("Assets", System.StringComparison.OrdinalIgnoreCase) ||
- grandparent.Name.Equals("Packages", System.StringComparison.OrdinalIgnoreCase)))
- {
- return $"{name}_MCPForUnityTools";
- }
-
- current = grandparent;
- }
-
- return $"{parent.Name}_MCPForUnityTools";
- }
- catch
- {
- return "MCPForUnityTools";
- }
- }
}
}
diff --git a/MCPForUnity/Editor/Helpers/ServerInstaller.cs b/MCPForUnity/Editor/Helpers/ServerInstaller.cs
index 713c00a0..7ee13db0 100644
--- a/MCPForUnity/Editor/Helpers/ServerInstaller.cs
+++ b/MCPForUnity/Editor/Helpers/ServerInstaller.cs
@@ -495,7 +495,7 @@ private static void CopyUnityProjectTools(string destToolsDir)
/// Generates a unique identifier for a MCPForUnityTools folder based on its parent directory.
/// Example: "Assets/MooseRunner/Editor/MCPForUnityTools" → "MooseRunner_MCPForUnityTools"
///
- private static string GetToolsFolderIdentifier(string toolsFolderPath)
+ internal static string GetToolsFolderIdentifier(string toolsFolderPath)
{
try
{
From 68f09e12b0757a9945aa81d2882ee5479862c68a Mon Sep 17 00:00:00 2001
From: Johan Holtby <72528418+JohanHoltby@users.noreply.github.com>
Date: Thu, 9 Oct 2025 12:19:13 +0200
Subject: [PATCH 03/26] =?UTF-8?q?Follow-up:=20address=20CodeRabbit=20feedb?=
=?UTF-8?q?ack=20for=20#308=20=E2=80=93=20centralize=20GetToolsFolderIdent?=
=?UTF-8?q?ifier,=20fix=20tools=20copy=20dir,=20and=20limit=20scan=20scope?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
MCPForUnity/Editor/Helpers/ServerInstaller.cs | 82 ++++++++++++++++++-
.../UnityMcpServer~/src/tools/__init__.py | 21 ++++-
2 files changed, 98 insertions(+), 5 deletions(-)
diff --git a/MCPForUnity/Editor/Helpers/ServerInstaller.cs b/MCPForUnity/Editor/Helpers/ServerInstaller.cs
index 7ee13db0..a31eca9a 100644
--- a/MCPForUnity/Editor/Helpers/ServerInstaller.cs
+++ b/MCPForUnity/Editor/Helpers/ServerInstaller.cs
@@ -406,6 +406,7 @@ private static bool TryGetEmbeddedServerSource(out string srcPath)
///
/// 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)
{
@@ -418,18 +419,51 @@ private static void CopyUnityProjectTools(string destToolsDir)
return;
}
- // Find all MCPForUnityTools folders
- var toolsFolders = Directory.GetDirectories(projectRoot, "MCPForUnityTools", SearchOption.AllDirectories);
+ // 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);
- string versionTrackingFile = Path.Combine(destToolsDir, $"{folderIdentifier}_version.txt");
+ 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");
@@ -450,7 +484,7 @@ private static void CopyUnityProjectTools(string destToolsDir)
foreach (var pyFile in pyFiles)
{
string fileName = Path.GetFileName(pyFile);
- string destFile = Path.Combine(destToolsDir, fileName);
+ string destFile = Path.Combine(destFolderSubdir, fileName);
try
{
@@ -480,6 +514,9 @@ private static void CopyUnityProjectTools(string destToolsDir)
}
}
+ // Clean up stale subdirectories (folders removed from upstream)
+ CleanupStaleToolFolders(destToolsDir, activeFolderIdentifiers);
+
if (copiedCount > 0)
{
McpLog.Info($"Copied {copiedCount} Unity project tool(s) to server");
@@ -491,6 +528,43 @@ private static void CopyUnityProjectTools(string destToolsDir)
}
}
+ ///
+ /// 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);
+
+ // 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}");
+ }
+ }
+ }
+ }
+ 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"
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:
From 069f1bebcd035e70bf15ce6807e64bedf380f917 Mon Sep 17 00:00:00 2001
From: Johan Holtby <72528418+JohanHoltby@users.noreply.github.com>
Date: Fri, 10 Oct 2025 14:08:15 +0200
Subject: [PATCH 04/26] Fixing so the MCP don't removes _skipDirs e.g.
__pycache__
---
MCPForUnity/Editor/Helpers/ServerInstaller.cs | 8 ++++++++
1 file changed, 8 insertions(+)
diff --git a/MCPForUnity/Editor/Helpers/ServerInstaller.cs b/MCPForUnity/Editor/Helpers/ServerInstaller.cs
index a31eca9a..2cf5b198 100644
--- a/MCPForUnity/Editor/Helpers/ServerInstaller.cs
+++ b/MCPForUnity/Editor/Helpers/ServerInstaller.cs
@@ -544,6 +544,13 @@ private static void CleanupStaleToolFolders(string destToolsDir, HashSet
{
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;
+ }
+
// Check if this subdirectory corresponds to an active tools folder
if (!activeFolderIdentifiers.Contains(subdirName))
{
@@ -557,6 +564,7 @@ private static void CleanupStaleToolFolders(string destToolsDir, HashSet
McpLog.Warn($"Failed to delete stale folder {subdirName}: {ex.Message}");
}
}
+ NextSubdir:;
}
}
catch (Exception ex)
From b1f696d8343b2d15c26ca383c88d8594883b970b Mon Sep 17 00:00:00 2001
From: Johan Holtby <72528418+JohanHoltby@users.noreply.github.com>
Date: Fri, 10 Oct 2025 20:45:26 +0200
Subject: [PATCH 05/26] skip empty folders with no py files
---
MCPForUnity/Editor/Helpers/ServerInstaller.cs | 7 +++++++
1 file changed, 7 insertions(+)
diff --git a/MCPForUnity/Editor/Helpers/ServerInstaller.cs b/MCPForUnity/Editor/Helpers/ServerInstaller.cs
index 2cf5b198..6d9c7834 100644
--- a/MCPForUnity/Editor/Helpers/ServerInstaller.cs
+++ b/MCPForUnity/Editor/Helpers/ServerInstaller.cs
@@ -480,6 +480,13 @@ private static void CopyUnityProjectTools(string destToolsDir)
// 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;
+ }
foreach (var pyFile in pyFiles)
{
From 88a64911a005bed115dc0414401caf9ac53f8c82 Mon Sep 17 00:00:00 2001
From: Johan Holtby <72528418+JohanHoltby@users.noreply.github.com>
Date: Fri, 10 Oct 2025 22:26:59 +0200
Subject: [PATCH 06/26] Rabbit: "Fix identifier collision between different
package roots."
---
MCPForUnity/Editor/Helpers/ServerInstaller.cs | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/MCPForUnity/Editor/Helpers/ServerInstaller.cs b/MCPForUnity/Editor/Helpers/ServerInstaller.cs
index 6d9c7834..a44d904f 100644
--- a/MCPForUnity/Editor/Helpers/ServerInstaller.cs
+++ b/MCPForUnity/Editor/Helpers/ServerInstaller.cs
@@ -604,7 +604,7 @@ internal static string GetToolsFolderIdentifier(string toolsFolderPath)
(grandparent.Name.Equals("Assets", StringComparison.OrdinalIgnoreCase) ||
grandparent.Name.Equals("Packages", StringComparison.OrdinalIgnoreCase)))
{
- return $"{name}_MCPForUnityTools";
+ return $"{grandparent.Name}_{name}_MCPForUnityTools";
}
current = grandparent;
From 6a4d55b366b06e55c734517521018c330140baac Mon Sep 17 00:00:00 2001
From: Johan Holtby <72528418+JohanHoltby@users.noreply.github.com>
Date: Fri, 10 Oct 2025 22:36:50 +0200
Subject: [PATCH 07/26] Update MCPForUnity/Editor/Helpers/ServerInstaller.cs
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
---
MCPForUnity/Editor/Helpers/ServerInstaller.cs | 19 ++++++++++++-------
1 file changed, 12 insertions(+), 7 deletions(-)
diff --git a/MCPForUnity/Editor/Helpers/ServerInstaller.cs b/MCPForUnity/Editor/Helpers/ServerInstaller.cs
index a44d904f..984b487e 100644
--- a/MCPForUnity/Editor/Helpers/ServerInstaller.cs
+++ b/MCPForUnity/Editor/Helpers/ServerInstaller.cs
@@ -488,6 +488,7 @@ private static void CopyUnityProjectTools(string destToolsDir)
continue;
}
+ bool copyFailed = false;
foreach (var pyFile in pyFiles)
{
string fileName = Path.GetFileName(pyFile);
@@ -502,17 +503,21 @@ private static void CopyUnityProjectTools(string destToolsDir)
catch (Exception ex)
{
McpLog.Warn($"Failed to copy {fileName}: {ex.Message}");
+ copyFailed = true;
}
}
- // Update version tracking file
- try
+ // Update version tracking file only on full success
+ if (!copyFailed)
{
- File.WriteAllText(versionTrackingFile, sourceVersion);
- }
- catch (Exception ex)
- {
- McpLog.Warn($"Failed to write version tracking file for {folderIdentifier}: {ex.Message}");
+ try
+ {
+ File.WriteAllText(versionTrackingFile, sourceVersion);
+ }
+ catch (Exception ex)
+ {
+ McpLog.Warn($"Failed to write version tracking file for {folderIdentifier}: {ex.Message}");
+ }
}
}
else
From fac3b954a30a74de8c04a8558aa1b01e41742e2e Mon Sep 17 00:00:00 2001
From: Johan Holtby <72528418+JohanHoltby@users.noreply.github.com>
Date: Fri, 10 Oct 2025 22:40:11 +0200
Subject: [PATCH 08/26] =?UTF-8?q?Rabbbit:=20Cleanup=20may=20delete=20serve?=
=?UTF-8?q?r=E2=80=99s=20built-in=20tool=20subfolders=20=E2=80=94=20restri?=
=?UTF-8?q?ct=20to=20managed=20names.?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
MCPForUnity/Editor/Helpers/ServerInstaller.cs | 4 ++++
1 file changed, 4 insertions(+)
diff --git a/MCPForUnity/Editor/Helpers/ServerInstaller.cs b/MCPForUnity/Editor/Helpers/ServerInstaller.cs
index a44d904f..4882bb81 100644
--- a/MCPForUnity/Editor/Helpers/ServerInstaller.cs
+++ b/MCPForUnity/Editor/Helpers/ServerInstaller.cs
@@ -557,6 +557,10 @@ private static void CleanupStaleToolFolders(string destToolsDir, HashSet
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))
From 571ca8c5de3d07da3791dad558677909a07e886d Mon Sep 17 00:00:00 2001
From: Johan Holtby <72528418+JohanHoltby@users.noreply.github.com>
Date: Sun, 12 Oct 2025 16:32:33 +0200
Subject: [PATCH 09/26] Fixed minor + missed onadding rabit change
---
MCPForUnity/Editor/Dependencies/Models.meta | 2 +-
MCPForUnity/Editor/Dependencies/PlatformDetectors.meta | 2 +-
MCPForUnity/Editor/Helpers/ServerInstaller.cs | 4 ++--
3 files changed, 4 insertions(+), 4 deletions(-)
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/ServerInstaller.cs b/MCPForUnity/Editor/Helpers/ServerInstaller.cs
index bd632880..0368b596 100644
--- a/MCPForUnity/Editor/Helpers/ServerInstaller.cs
+++ b/MCPForUnity/Editor/Helpers/ServerInstaller.cs
@@ -564,8 +564,8 @@ private static void CleanupStaleToolFolders(string destToolsDir, HashSet
}
// Only manage per-folder tool installs created by this feature
-+ if (!subdirName.EndsWith("_MCPForUnityTools", StringComparison.OrdinalIgnoreCase))
-+ goto NextSubdir;
+ if (!subdirName.EndsWith("_MCPForUnityTools", StringComparison.OrdinalIgnoreCase))
+ goto NextSubdir;
// Check if this subdirectory corresponds to an active tools folder
if (!activeFolderIdentifiers.Contains(subdirName))
From da98ba9a69c65fd8324df737e5019335f1d9c501 Mon Sep 17 00:00:00 2001
From: Marcus Sanatan
Date: Fri, 17 Oct 2025 14:16:02 -0400
Subject: [PATCH 10/26] Revert "Fixed minor + missed onadding rabit change"
This reverts commit 571ca8c5de3d07da3791dad558677909a07e886d.
---
MCPForUnity/Editor/Dependencies/Models.meta | 2 +-
MCPForUnity/Editor/Dependencies/PlatformDetectors.meta | 2 +-
MCPForUnity/Editor/Helpers/ServerInstaller.cs | 4 ++--
3 files changed, 4 insertions(+), 4 deletions(-)
diff --git a/MCPForUnity/Editor/Dependencies/Models.meta b/MCPForUnity/Editor/Dependencies/Models.meta
index 3ba640c9..2174dd52 100644
--- a/MCPForUnity/Editor/Dependencies/Models.meta
+++ b/MCPForUnity/Editor/Dependencies/Models.meta
@@ -1,5 +1,5 @@
fileFormatVersion: 2
-guid: 2b4fca8c8f964494e82a2c1d1d8d2041
+guid: b2c3d4e5f6789012345678901234abcd
folderAsset: yes
DefaultImporter:
externalObjects: {}
diff --git a/MCPForUnity/Editor/Dependencies/PlatformDetectors.meta b/MCPForUnity/Editor/Dependencies/PlatformDetectors.meta
index be8b8dce..22a6b1db 100644
--- a/MCPForUnity/Editor/Dependencies/PlatformDetectors.meta
+++ b/MCPForUnity/Editor/Dependencies/PlatformDetectors.meta
@@ -1,5 +1,5 @@
fileFormatVersion: 2
-guid: c6d16631d05433740a7193d3384364a8
+guid: c3d4e5f6789012345678901234abcdef
folderAsset: yes
DefaultImporter:
externalObjects: {}
diff --git a/MCPForUnity/Editor/Helpers/ServerInstaller.cs b/MCPForUnity/Editor/Helpers/ServerInstaller.cs
index 0368b596..bd632880 100644
--- a/MCPForUnity/Editor/Helpers/ServerInstaller.cs
+++ b/MCPForUnity/Editor/Helpers/ServerInstaller.cs
@@ -564,8 +564,8 @@ private static void CleanupStaleToolFolders(string destToolsDir, HashSet
}
// Only manage per-folder tool installs created by this feature
- if (!subdirName.EndsWith("_MCPForUnityTools", StringComparison.OrdinalIgnoreCase))
- goto NextSubdir;
++ if (!subdirName.EndsWith("_MCPForUnityTools", StringComparison.OrdinalIgnoreCase))
++ goto NextSubdir;
// Check if this subdirectory corresponds to an active tools folder
if (!activeFolderIdentifiers.Contains(subdirName))
From 81f50063c1b11ba101ec0e2cea709684dd3c4850 Mon Sep 17 00:00:00 2001
From: Marcus Sanatan
Date: Fri, 17 Oct 2025 15:43:02 -0400
Subject: [PATCH 11/26] refactor: remove Unity project tools copying and
version tracking functionality
---
MCPForUnity/Editor/Helpers/PackageDetector.cs | 76 +-----
MCPForUnity/Editor/Helpers/ServerInstaller.cs | 233 ------------------
2 files changed, 1 insertion(+), 308 deletions(-)
diff --git a/MCPForUnity/Editor/Helpers/PackageDetector.cs b/MCPForUnity/Editor/Helpers/PackageDetector.cs
index acea04c7..59e22348 100644
--- a/MCPForUnity/Editor/Helpers/PackageDetector.cs
+++ b/MCPForUnity/Editor/Helpers/PackageDetector.cs
@@ -23,10 +23,7 @@ static PackageDetector()
bool legacyPresent = LegacyRootsExist();
bool canonicalMissing = !System.IO.File.Exists(System.IO.Path.Combine(ServerInstaller.GetServerPath(), "server.py"));
- // Check if any MCPForUnityTools have updated versions
- bool toolsNeedUpdate = ToolsVersionsChanged();
-
- if (!EditorPrefs.GetBool(key, false) || legacyPresent || canonicalMissing || toolsNeedUpdate)
+ if (!EditorPrefs.GetBool(key, false) || legacyPresent || canonicalMissing)
{
// Marshal the entire flow to the main thread. EnsureServerInstalled may touch Unity APIs.
EditorApplication.delayCall += () =>
@@ -105,76 +102,5 @@ 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 63c987d2..2b0c8f45 100644
--- a/MCPForUnity/Editor/Helpers/ServerInstaller.cs
+++ b/MCPForUnity/Editor/Helpers/ServerInstaller.cs
@@ -71,10 +71,6 @@ public static void EnsureServerInstalled()
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())
{
@@ -416,231 +412,6 @@ 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);
@@ -705,10 +476,6 @@ 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
From 9a152e35f6f05d77f311ec8b0790589b152bf260 Mon Sep 17 00:00:00 2001
From: Marcus Sanatan
Date: Fri, 17 Oct 2025 16:02:01 -0400
Subject: [PATCH 12/26] refactor: consolidate module discovery logic into
shared utility function
---
.../UnityMcpServer~/src/module_discovery.py | 55 +++++++++++++++++++
.../UnityMcpServer~/src/resources/__init__.py | 17 ++----
.../UnityMcpServer~/src/tools/__init__.py | 33 +----------
3 files changed, 62 insertions(+), 43 deletions(-)
create mode 100644 MCPForUnity/UnityMcpServer~/src/module_discovery.py
diff --git a/MCPForUnity/UnityMcpServer~/src/module_discovery.py b/MCPForUnity/UnityMcpServer~/src/module_discovery.py
new file mode 100644
index 00000000..4350a998
--- /dev/null
+++ b/MCPForUnity/UnityMcpServer~/src/module_discovery.py
@@ -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}")
diff --git a/MCPForUnity/UnityMcpServer~/src/resources/__init__.py b/MCPForUnity/UnityMcpServer~/src/resources/__init__.py
index 23c5604a..c19a3174 100644
--- a/MCPForUnity/UnityMcpServer~/src/resources/__init__.py
+++ b/MCPForUnity/UnityMcpServer~/src/resources/__init__.py
@@ -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()
diff --git a/MCPForUnity/UnityMcpServer~/src/tools/__init__.py b/MCPForUnity/UnityMcpServer~/src/tools/__init__.py
index fea4be68..afb6c757 100644
--- a/MCPForUnity/UnityMcpServer~/src/tools/__init__.py
+++ b/MCPForUnity/UnityMcpServer~/src/tools/__init__.py
@@ -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")
@@ -28,34 +27,8 @@ def register_all_tools(mcp: FastMCP):
# 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('_'):
- 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 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}")
+ # Discover and import all modules
+ list(discover_modules(tools_dir, __package__))
tools = get_registered_tools()
From 6e5ccb57ada930ac4431f9ac3610134c8c8c76c3 Mon Sep 17 00:00:00 2001
From: Marcus Sanatan
Date: Fri, 17 Oct 2025 17:47:57 -0400
Subject: [PATCH 13/26] Remove unused imports
---
MCPForUnity/Editor/Data/McpClients.cs | 1 -
1 file changed, 1 deletion(-)
diff --git a/MCPForUnity/Editor/Data/McpClients.cs b/MCPForUnity/Editor/Data/McpClients.cs
index 9e718847..6ddf304d 100644
--- a/MCPForUnity/Editor/Data/McpClients.cs
+++ b/MCPForUnity/Editor/Data/McpClients.cs
@@ -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
From 650806f32d311fa2ba374284def5cdc82d3bc9df Mon Sep 17 00:00:00 2001
From: Marcus Sanatan
Date: Fri, 17 Oct 2025 18:23:12 -0400
Subject: [PATCH 14/26] feat: add Python tool registry and sync system for MCP
server integration
---
MCPForUnity/Editor/Data/PythonToolsAsset.cs | 88 +++++++++++++
.../Editor/Data/PythonToolsAsset.cs.meta | 11 ++
MCPForUnity/Editor/Importers.meta | 8 ++
.../Editor/Importers/PythonFileImporter.cs | 21 +++
.../Importers/PythonFileImporter.cs.meta | 11 ++
.../Services/IPythonToolRegistryService.cs | 14 ++
.../IPythonToolRegistryService.cs.meta | 11 ++
.../Editor/Services/IToolSyncService.cs | 18 +++
.../Editor/Services/IToolSyncService.cs.meta | 11 ++
.../Editor/Services/MCPServiceLocator.cs | 27 ++--
.../Services/PythonToolRegistryService.cs | 55 ++++++++
.../PythonToolRegistryService.cs.meta | 11 ++
.../Editor/Services/ToolSyncService.cs | 124 ++++++++++++++++++
.../Editor/Services/ToolSyncService.cs.meta | 11 ++
14 files changed, 406 insertions(+), 15 deletions(-)
create mode 100644 MCPForUnity/Editor/Data/PythonToolsAsset.cs
create mode 100644 MCPForUnity/Editor/Data/PythonToolsAsset.cs.meta
create mode 100644 MCPForUnity/Editor/Importers.meta
create mode 100644 MCPForUnity/Editor/Importers/PythonFileImporter.cs
create mode 100644 MCPForUnity/Editor/Importers/PythonFileImporter.cs.meta
create mode 100644 MCPForUnity/Editor/Services/IPythonToolRegistryService.cs
create mode 100644 MCPForUnity/Editor/Services/IPythonToolRegistryService.cs.meta
create mode 100644 MCPForUnity/Editor/Services/IToolSyncService.cs
create mode 100644 MCPForUnity/Editor/Services/IToolSyncService.cs.meta
create mode 100644 MCPForUnity/Editor/Services/PythonToolRegistryService.cs
create mode 100644 MCPForUnity/Editor/Services/PythonToolRegistryService.cs.meta
create mode 100644 MCPForUnity/Editor/Services/ToolSyncService.cs
create mode 100644 MCPForUnity/Editor/Services/ToolSyncService.cs.meta
diff --git a/MCPForUnity/Editor/Data/PythonToolsAsset.cs b/MCPForUnity/Editor/Data/PythonToolsAsset.cs
new file mode 100644
index 00000000..989bb6b8
--- /dev/null
+++ b/MCPForUnity/Editor/Data/PythonToolsAsset.cs
@@ -0,0 +1,88 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using UnityEngine;
+
+namespace MCPForUnity.Editor.Data
+{
+ ///
+ /// Registry of Python tool files to sync to the MCP server.
+ /// Add your Python files here - they can be stored anywhere in your project.
+ ///
+ [CreateAssetMenu(fileName = "PythonTools", menuName = "MCP For Unity/Python Tools")]
+ public class PythonToolsAsset : ScriptableObject
+ {
+ [Header("Python Tool Files")]
+ [Tooltip("Add Python files (.py) to sync to the MCP server. Files can be located anywhere in your project.")]
+ public List pythonFiles = new List();
+
+ [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 fileStates = new List();
+
+ ///
+ /// Gets all valid Python files (filters out null/missing references)
+ ///
+ public IEnumerable GetValidFiles()
+ {
+ return pythonFiles.Where(f => f != null);
+ }
+
+ ///
+ /// Checks if a file needs syncing
+ ///
+ 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;
+ }
+
+ ///
+ /// Records that a file was synced
+ ///
+ 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;
+ }
+
+ ///
+ /// Removes state entries for files no longer in the list
+ ///
+ public void CleanupStaleStates()
+ {
+ var validGuids = new HashSet(GetValidFiles().Select(GetAssetGuid));
+ fileStates.RemoveAll(s => !validGuids.Contains(s.assetGuid));
+ }
+
+ private string GetAssetGuid(TextAsset asset)
+ {
+ return UnityEditor.AssetDatabase.AssetPathToGUID(UnityEditor.AssetDatabase.GetAssetPath(asset));
+ }
+ }
+
+ [Serializable]
+ public class PythonFileState
+ {
+ public string assetGuid;
+ public string fileName;
+ public string contentHash;
+ public DateTime lastSyncTime;
+ }
+}
\ No newline at end of file
diff --git a/MCPForUnity/Editor/Data/PythonToolsAsset.cs.meta b/MCPForUnity/Editor/Data/PythonToolsAsset.cs.meta
new file mode 100644
index 00000000..bfe30d9f
--- /dev/null
+++ b/MCPForUnity/Editor/Data/PythonToolsAsset.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 1ad9865b38bcc4efe85d4970c6d3a997
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/MCPForUnity/Editor/Importers.meta b/MCPForUnity/Editor/Importers.meta
new file mode 100644
index 00000000..3d242086
--- /dev/null
+++ b/MCPForUnity/Editor/Importers.meta
@@ -0,0 +1,8 @@
+fileFormatVersion: 2
+guid: b104663d2f6c648e1b99633082385db2
+folderAsset: yes
+DefaultImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/MCPForUnity/Editor/Importers/PythonFileImporter.cs b/MCPForUnity/Editor/Importers/PythonFileImporter.cs
new file mode 100644
index 00000000..8c60a1c2
--- /dev/null
+++ b/MCPForUnity/Editor/Importers/PythonFileImporter.cs
@@ -0,0 +1,21 @@
+using UnityEngine;
+using UnityEditor.AssetImporters;
+using System.IO;
+
+namespace MCPForUnity.Editor.Importers
+{
+ ///
+ /// 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.
+ ///
+ [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);
+ }
+ }
+}
diff --git a/MCPForUnity/Editor/Importers/PythonFileImporter.cs.meta b/MCPForUnity/Editor/Importers/PythonFileImporter.cs.meta
new file mode 100644
index 00000000..7e2edb2e
--- /dev/null
+++ b/MCPForUnity/Editor/Importers/PythonFileImporter.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: d68ef794590944f1ea7ee102c91887c7
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/MCPForUnity/Editor/Services/IPythonToolRegistryService.cs b/MCPForUnity/Editor/Services/IPythonToolRegistryService.cs
new file mode 100644
index 00000000..6e1d7902
--- /dev/null
+++ b/MCPForUnity/Editor/Services/IPythonToolRegistryService.cs
@@ -0,0 +1,14 @@
+using System.Collections.Generic;
+using UnityEngine;
+using MCPForUnity.Editor.Data;
+
+namespace MCPForUnity.Editor.Services
+{
+ public interface IPythonToolRegistryService
+ {
+ IEnumerable GetAllRegistries();
+ bool NeedsSync(PythonToolsAsset registry, TextAsset file);
+ void RecordSync(PythonToolsAsset registry, TextAsset file);
+ string ComputeHash(TextAsset file);
+ }
+}
\ No newline at end of file
diff --git a/MCPForUnity/Editor/Services/IPythonToolRegistryService.cs.meta b/MCPForUnity/Editor/Services/IPythonToolRegistryService.cs.meta
new file mode 100644
index 00000000..3f4835fc
--- /dev/null
+++ b/MCPForUnity/Editor/Services/IPythonToolRegistryService.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: a2487319df5cc47baa2c635b911038c5
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/MCPForUnity/Editor/Services/IToolSyncService.cs b/MCPForUnity/Editor/Services/IToolSyncService.cs
new file mode 100644
index 00000000..3a62fdfb
--- /dev/null
+++ b/MCPForUnity/Editor/Services/IToolSyncService.cs
@@ -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 Messages { get; set; } = new List();
+ public bool Success => ErrorCount == 0;
+ }
+
+ public interface IToolSyncService
+ {
+ ToolSyncResult SyncProjectTools(string destToolsDir);
+ }
+}
diff --git a/MCPForUnity/Editor/Services/IToolSyncService.cs.meta b/MCPForUnity/Editor/Services/IToolSyncService.cs.meta
new file mode 100644
index 00000000..02828285
--- /dev/null
+++ b/MCPForUnity/Editor/Services/IToolSyncService.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: b9627dbaa92d24783a9f20e42efcea18
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/MCPForUnity/Editor/Services/MCPServiceLocator.cs b/MCPForUnity/Editor/Services/MCPServiceLocator.cs
index 96c92909..ac286b1b 100644
--- a/MCPForUnity/Editor/Services/MCPServiceLocator.cs
+++ b/MCPForUnity/Editor/Services/MCPServiceLocator.cs
@@ -10,27 +10,16 @@ public static class MCPServiceLocator
private static IBridgeControlService _bridgeService;
private static IClientConfigurationService _clientService;
private static IPathResolverService _pathService;
+ private static IPythonToolRegistryService _pythonToolRegistryService;
private static ITestRunnerService _testRunnerService;
+ private static IToolSyncService _toolSyncService;
- ///
- /// Gets the bridge control service
- ///
public static IBridgeControlService Bridge => _bridgeService ??= new BridgeControlService();
-
- ///
- /// Gets the client configuration service
- ///
public static IClientConfigurationService Client => _clientService ??= new ClientConfigurationService();
-
- ///
- /// Gets the path resolver service
- ///
public static IPathResolverService Paths => _pathService ??= new PathResolverService();
-
- ///
- /// Gets the Unity test runner service
- ///
+ public static IPythonToolRegistryService PythonToolRegistry => _pythonToolRegistryService ??= new PythonToolRegistryService();
public static ITestRunnerService Tests => _testRunnerService ??= new TestRunnerService();
+ public static IToolSyncService ToolSync => _toolSyncService ??= new ToolSyncService();
///
/// Registers a custom implementation for a service (useful for testing)
@@ -45,8 +34,12 @@ public static void Register(T implementation) where T : class
_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;
}
///
@@ -57,12 +50,16 @@ public static void Reset()
(_bridgeService as IDisposable)?.Dispose();
(_clientService as IDisposable)?.Dispose();
(_pathService as IDisposable)?.Dispose();
+ (_pythonToolRegistryService as IDisposable)?.Dispose();
(_testRunnerService as IDisposable)?.Dispose();
+ (_toolSyncService as IDisposable)?.Dispose();
_bridgeService = null;
_clientService = null;
_pathService = null;
+ _pythonToolRegistryService = null;
_testRunnerService = null;
+ _toolSyncService = null;
}
}
}
diff --git a/MCPForUnity/Editor/Services/PythonToolRegistryService.cs b/MCPForUnity/Editor/Services/PythonToolRegistryService.cs
new file mode 100644
index 00000000..1fab20c8
--- /dev/null
+++ b/MCPForUnity/Editor/Services/PythonToolRegistryService.cs
@@ -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 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(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();
+ }
+ }
+ }
+}
diff --git a/MCPForUnity/Editor/Services/PythonToolRegistryService.cs.meta b/MCPForUnity/Editor/Services/PythonToolRegistryService.cs.meta
new file mode 100644
index 00000000..9fba1e9f
--- /dev/null
+++ b/MCPForUnity/Editor/Services/PythonToolRegistryService.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 2da2869749c764f16a45e010eefbd679
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/MCPForUnity/Editor/Services/ToolSyncService.cs b/MCPForUnity/Editor/Services/ToolSyncService.cs
new file mode 100644
index 00000000..1303637a
--- /dev/null
+++ b/MCPForUnity/Editor/Services/ToolSyncService.cs
@@ -0,0 +1,124 @@
+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();
+
+ 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);
+
+ // 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 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}");
+ }
+ }
+ }
+}
diff --git a/MCPForUnity/Editor/Services/ToolSyncService.cs.meta b/MCPForUnity/Editor/Services/ToolSyncService.cs.meta
new file mode 100644
index 00000000..31db4399
--- /dev/null
+++ b/MCPForUnity/Editor/Services/ToolSyncService.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 9ad084cf3b6c04174b9202bf63137bae
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
From 38fe922974162a6fda22510f13e33e0832fed14c Mon Sep 17 00:00:00 2001
From: Marcus Sanatan
Date: Fri, 17 Oct 2025 18:35:48 -0400
Subject: [PATCH 15/26] feat: add auto-sync processor for Python tools with
Unity editor integration
---
.../Editor/Helpers/PythonToolSyncProcessor.cs | 179 ++++++++++++++++++
.../Helpers/PythonToolSyncProcessor.cs.meta | 11 ++
2 files changed, 190 insertions(+)
create mode 100644 MCPForUnity/Editor/Helpers/PythonToolSyncProcessor.cs
create mode 100644 MCPForUnity/Editor/Helpers/PythonToolSyncProcessor.cs.meta
diff --git a/MCPForUnity/Editor/Helpers/PythonToolSyncProcessor.cs b/MCPForUnity/Editor/Helpers/PythonToolSyncProcessor.cs
new file mode 100644
index 00000000..08d2c9e8
--- /dev/null
+++ b/MCPForUnity/Editor/Helpers/PythonToolSyncProcessor.cs
@@ -0,0 +1,179 @@
+using System.IO;
+using System.Linq;
+using MCPForUnity.Editor.Data;
+using MCPForUnity.Editor.Services;
+using UnityEditor;
+using UnityEngine;
+
+namespace MCPForUnity.Editor.Helpers
+{
+ ///
+ /// Automatically syncs Python tools to the MCP server when:
+ /// - PythonToolsAsset is modified
+ /// - Python files are imported/reimported
+ /// - Unity starts up
+ ///
+ [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();
+ }
+ };
+ }
+
+ ///
+ /// Called after any assets are imported, deleted, or moved
+ ///
+ 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;
+
+ // Check if any PythonToolsAsset was modified
+ foreach (string path in importedAssets.Concat(movedAssets))
+ {
+ if (path.EndsWith(".asset"))
+ {
+ var asset = AssetDatabase.LoadAssetAtPath(path);
+ if (asset != null)
+ {
+ needsSync = true;
+ break;
+ }
+ }
+
+ // 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();
+ }
+ }
+
+ ///
+ /// Syncs all Python tools from all PythonToolsAsset instances to the MCP server
+ ///
+ 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;
+ }
+ }
+
+ ///
+ /// Checks if auto-sync is enabled (default: true)
+ ///
+ public static bool IsAutoSyncEnabled()
+ {
+ return EditorPrefs.GetBool(SyncEnabledKey, true);
+ }
+
+ ///
+ /// Enables or disables auto-sync
+ ///
+ public static void SetAutoSyncEnabled(bool enabled)
+ {
+ EditorPrefs.SetBool(SyncEnabledKey, enabled);
+ McpLog.Info($"Python tool auto-sync {(enabled ? "enabled" : "disabled")}");
+ }
+
+ ///
+ /// Menu item to manually trigger sync
+ ///
+ [MenuItem("MCP For Unity/Sync Python Tools", priority = 100)]
+ public static void ManualSync()
+ {
+ McpLog.Info("Manually syncing Python tools...");
+ SyncAllTools();
+ }
+
+ ///
+ /// Menu item to toggle auto-sync
+ ///
+ [MenuItem("MCP For Unity/Auto-Sync Python Tools", priority = 101)]
+ public static void ToggleAutoSync()
+ {
+ SetAutoSyncEnabled(!IsAutoSyncEnabled());
+ }
+
+ ///
+ /// Validate menu item (shows checkmark when enabled)
+ ///
+ [MenuItem("MCP For Unity/Auto-Sync Python Tools", true, priority = 101)]
+ public static bool ToggleAutoSyncValidate()
+ {
+ Menu.SetChecked("MCP For Unity/Auto-Sync Python Tools", IsAutoSyncEnabled());
+ return true;
+ }
+ }
+}
diff --git a/MCPForUnity/Editor/Helpers/PythonToolSyncProcessor.cs.meta b/MCPForUnity/Editor/Helpers/PythonToolSyncProcessor.cs.meta
new file mode 100644
index 00000000..d3a3719c
--- /dev/null
+++ b/MCPForUnity/Editor/Helpers/PythonToolSyncProcessor.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 4bdcf382960c842aab0a08c90411ab43
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
From 04798f934fdd9ecb6c64d08be1865a36638cad00 Mon Sep 17 00:00:00 2001
From: Marcus Sanatan
Date: Fri, 17 Oct 2025 18:36:18 -0400
Subject: [PATCH 16/26] feat: add menu item to reimport all Python files in
project
Good to give users a manual option
---
.../Editor/Helpers/PythonToolSyncProcessor.cs | 22 +++++++++++++++++++
1 file changed, 22 insertions(+)
diff --git a/MCPForUnity/Editor/Helpers/PythonToolSyncProcessor.cs b/MCPForUnity/Editor/Helpers/PythonToolSyncProcessor.cs
index 08d2c9e8..1d8704db 100644
--- a/MCPForUnity/Editor/Helpers/PythonToolSyncProcessor.cs
+++ b/MCPForUnity/Editor/Helpers/PythonToolSyncProcessor.cs
@@ -147,6 +147,28 @@ public static void SetAutoSyncEnabled(bool enabled)
McpLog.Info($"Python tool auto-sync {(enabled ? "enabled" : "disabled")}");
}
+ ///
+ /// Menu item to reimport all Python files in the project
+ ///
+ [MenuItem("MCP For Unity/Reimport Python Files", priority = 99)]
+ public static void ReimportPythonFiles()
+ {
+ string[] allAssets = AssetDatabase.GetAllAssetPaths();
+ int count = 0;
+
+ foreach (string path in allAssets)
+ {
+ if (path.EndsWith(".py") && path.StartsWith("Assets/"))
+ {
+ AssetDatabase.ImportAsset(path, ImportAssetOptions.ForceUpdate);
+ count++;
+ }
+ }
+
+ McpLog.Info($"Reimported {count} Python files");
+ AssetDatabase.Refresh();
+ }
+
///
/// Menu item to manually trigger sync
///
From 4c8c611847fef33798babdf3333aa0a49a24dc21 Mon Sep 17 00:00:00 2001
From: Marcus Sanatan
Date: Fri, 17 Oct 2025 19:18:21 -0400
Subject: [PATCH 17/26] Fix infinite loop error
Don't react to PythonToolAsset changes - it only needs to react to Python file changes.
And we also batch asset edits to minimise the DB refreshes
---
.../Editor/Helpers/PythonToolSyncProcessor.cs | 13 +---
.../Editor/Services/ToolSyncService.cs | 72 +++++++++++--------
2 files changed, 43 insertions(+), 42 deletions(-)
diff --git a/MCPForUnity/Editor/Helpers/PythonToolSyncProcessor.cs b/MCPForUnity/Editor/Helpers/PythonToolSyncProcessor.cs
index 1d8704db..3ccd9a1e 100644
--- a/MCPForUnity/Editor/Helpers/PythonToolSyncProcessor.cs
+++ b/MCPForUnity/Editor/Helpers/PythonToolSyncProcessor.cs
@@ -46,19 +46,10 @@ private static void OnPostprocessAllAssets(
bool needsSync = false;
- // Check if any PythonToolsAsset was modified
+ // Only check for .py file changes, not PythonToolsAsset changes
+ // (PythonToolsAsset changes are internal state updates from syncing)
foreach (string path in importedAssets.Concat(movedAssets))
{
- if (path.EndsWith(".asset"))
- {
- var asset = AssetDatabase.LoadAssetAtPath(path);
- if (asset != null)
- {
- needsSync = true;
- break;
- }
- }
-
// Check if any .py files were modified
if (path.EndsWith(".py"))
{
diff --git a/MCPForUnity/Editor/Services/ToolSyncService.cs b/MCPForUnity/Editor/Services/ToolSyncService.cs
index 1303637a..bd17f996 100644
--- a/MCPForUnity/Editor/Services/ToolSyncService.cs
+++ b/MCPForUnity/Editor/Services/ToolSyncService.cs
@@ -35,48 +35,58 @@ public ToolSyncResult SyncProjectTools(string destToolsDir)
var syncedFiles = new HashSet();
- foreach (var registry in registries)
+ // Batch all asset modifications together to minimize reimports
+ AssetDatabase.StartAssetEditing();
+ try
{
- foreach (var file in registry.GetValidFiles())
+ foreach (var registry in registries)
{
- try
+ foreach (var file in registry.GetValidFiles())
{
- // Check if needs syncing (hash-based or always)
- if (_registryService.NeedsSync(registry, file))
+ try
{
- 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");
+ // 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++;
+ }
}
- else
+ catch (Exception ex)
{
- string destPath = Path.Combine(destToolsDir, file.name + ".py");
- syncedFiles.Add(destPath);
- result.SkippedCount++;
+ result.ErrorCount++;
+ result.Messages.Add($"Failed to sync {file.name}: {ex.Message}");
}
}
- 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 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();
}
-
- // Cleanup stale Python files in destination
- CleanupStaleFiles(destToolsDir, syncedFiles);
// Save all modified registries
AssetDatabase.SaveAssets();
From 8753c304f27e746d987f3fabfac102db29e8f18c Mon Sep 17 00:00:00 2001
From: Marcus Sanatan
Date: Fri, 17 Oct 2025 19:30:13 -0400
Subject: [PATCH 18/26] refactor: move Python tool sync menu items under
Window/MCP For Unity/Tool Sync
---
MCPForUnity/Editor/Helpers/PythonToolSyncProcessor.cs | 8 ++++----
1 file changed, 4 insertions(+), 4 deletions(-)
diff --git a/MCPForUnity/Editor/Helpers/PythonToolSyncProcessor.cs b/MCPForUnity/Editor/Helpers/PythonToolSyncProcessor.cs
index 3ccd9a1e..bfc711cb 100644
--- a/MCPForUnity/Editor/Helpers/PythonToolSyncProcessor.cs
+++ b/MCPForUnity/Editor/Helpers/PythonToolSyncProcessor.cs
@@ -141,7 +141,7 @@ public static void SetAutoSyncEnabled(bool enabled)
///
/// Menu item to reimport all Python files in the project
///
- [MenuItem("MCP For Unity/Reimport Python Files", priority = 99)]
+ [MenuItem("Window/MCP For Unity/Tool Sync/Reimport Python Files", priority = 99)]
public static void ReimportPythonFiles()
{
string[] allAssets = AssetDatabase.GetAllAssetPaths();
@@ -163,7 +163,7 @@ public static void ReimportPythonFiles()
///
/// Menu item to manually trigger sync
///
- [MenuItem("MCP For Unity/Sync Python Tools", priority = 100)]
+ [MenuItem("Window/MCP For Unity/Tool Sync/Sync Python Tools", priority = 100)]
public static void ManualSync()
{
McpLog.Info("Manually syncing Python tools...");
@@ -173,7 +173,7 @@ public static void ManualSync()
///
/// Menu item to toggle auto-sync
///
- [MenuItem("MCP For Unity/Auto-Sync Python Tools", priority = 101)]
+ [MenuItem("Window/MCP For Unity/Tool Sync/Auto-Sync Python Tools", priority = 101)]
public static void ToggleAutoSync()
{
SetAutoSyncEnabled(!IsAutoSyncEnabled());
@@ -182,7 +182,7 @@ public static void ToggleAutoSync()
///
/// Validate menu item (shows checkmark when enabled)
///
- [MenuItem("MCP For Unity/Auto-Sync Python Tools", true, priority = 101)]
+ [MenuItem("Window/MCP For Unity/Tool Sync/Auto-Sync Python Tools", true, priority = 101)]
public static bool ToggleAutoSyncValidate()
{
Menu.SetChecked("MCP For Unity/Auto-Sync Python Tools", IsAutoSyncEnabled());
From 9627e47cd176576e4bb2ff94b80e2c7c46bedaf3 Mon Sep 17 00:00:00 2001
From: Marcus Sanatan
Date: Fri, 17 Oct 2025 21:35:06 -0400
Subject: [PATCH 19/26] Update docs
---
docs/CUSTOM_TOOLS.md | 207 +++++++++++++-----
.../v6_2_create_python_tools_asset.png | Bin 0 -> 337375 bytes
docs/screenshots/v6_2_python_tools_asset.png | Bin 0 -> 411157 bytes
3 files changed, 152 insertions(+), 55 deletions(-)
create mode 100644 docs/screenshots/v6_2_create_python_tools_asset.png
create mode 100644 docs/screenshots/v6_2_python_tools_asset.png
diff --git a/docs/CUSTOM_TOOLS.md b/docs/CUSTOM_TOOLS.md
index a212eb09..d1a16219 100644
--- a/docs/CUSTOM_TOOLS.md
+++ b/docs/CUSTOM_TOOLS.md
@@ -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`)
+
+
+
+## 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
-@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: .
+## 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 { }
-
-// Auto-generated from class name (MyCustomTool → my_custom_tool)
-[McpForUnityTool]
-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-Discovery
-
-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: Screenshot Tool
-## Complete Example: Custom Screenshot Tool
+Here's a complete example showing how to create a screenshot capture 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:
+
+**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
diff --git a/docs/screenshots/v6_2_create_python_tools_asset.png b/docs/screenshots/v6_2_create_python_tools_asset.png
new file mode 100644
index 0000000000000000000000000000000000000000..ef3cf365462d6607a9bf1bbe914ae51b752dd9d3
GIT binary patch
literal 337375
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