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
diff --git a/MCPForUnity/Editor/Data/PythonToolsAsset.cs b/MCPForUnity/Editor/Data/PythonToolsAsset.cs
new file mode 100644
index 00000000..22719a57
--- /dev/null
+++ b/MCPForUnity/Editor/Data/PythonToolsAsset.cs
@@ -0,0 +1,107 @@
+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
+ {
+ [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));
+ }
+
+ ///
+ /// Called when the asset is modified in the Inspector
+ /// Triggers sync to handle file additions/removals
+ ///
+ private void OnValidate()
+ {
+ // Cleanup stale states immediately
+ CleanupStaleStates();
+
+ // Trigger sync after a delay to handle file removals
+ // Delay ensures the asset is saved before sync runs
+ UnityEditor.EditorApplication.delayCall += () =>
+ {
+ if (this != null) // Check if asset still exists
+ {
+ MCPForUnity.Editor.Helpers.PythonToolSyncProcessor.SyncAllTools();
+ }
+ };
+ }
+ }
+
+ [Serializable]
+ public class PythonFileState
+ {
+ public string assetGuid;
+ public string fileName;
+ public string contentHash;
+ public DateTime lastSyncTime;
+ }
+}
\ 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/Helpers/PythonToolSyncProcessor.cs b/MCPForUnity/Editor/Helpers/PythonToolSyncProcessor.cs
new file mode 100644
index 00000000..fce0e783
--- /dev/null
+++ b/MCPForUnity/Editor/Helpers/PythonToolSyncProcessor.cs
@@ -0,0 +1,192 @@
+using System.IO;
+using System.Linq;
+using MCPForUnity.Editor.Data;
+using MCPForUnity.Editor.Services;
+using UnityEditor;
+using UnityEngine;
+
+namespace MCPForUnity.Editor.Helpers
+{
+ ///
+ /// 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;
+
+ // Only check for .py file changes, not PythonToolsAsset changes
+ // (PythonToolsAsset changes are internal state updates from syncing)
+ foreach (string path in importedAssets.Concat(movedAssets))
+ {
+ // Check if any .py files were modified
+ if (path.EndsWith(".py"))
+ {
+ needsSync = true;
+ break;
+ }
+ }
+
+ // Check if any .py files were deleted
+ if (!needsSync && deletedAssets.Any(path => path.EndsWith(".py")))
+ {
+ needsSync = true;
+ }
+
+ if (needsSync)
+ {
+ SyncAllTools();
+ }
+ }
+
+ ///
+ /// 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 reimport all Python files in the project
+ ///
+ [MenuItem("Window/MCP For Unity/Tool Sync/Reimport Python Files", priority = 99)]
+ public static void ReimportPythonFiles()
+ {
+ // Find all Python files (imported as TextAssets by PythonFileImporter)
+ var pythonGuids = AssetDatabase.FindAssets("t:TextAsset", new[] { "Assets" })
+ .Select(AssetDatabase.GUIDToAssetPath)
+ .Where(path => path.EndsWith(".py", System.StringComparison.OrdinalIgnoreCase))
+ .ToArray();
+
+ foreach (string path in pythonGuids)
+ {
+ AssetDatabase.ImportAsset(path, ImportAssetOptions.ForceUpdate);
+ }
+
+ int count = pythonGuids.Length;
+ McpLog.Info($"Reimported {count} Python files");
+ AssetDatabase.Refresh();
+ }
+
+ ///
+ /// Menu item to manually trigger sync
+ ///
+ [MenuItem("Window/MCP For Unity/Tool Sync/Sync Python Tools", priority = 100)]
+ public static void ManualSync()
+ {
+ McpLog.Info("Manually syncing Python tools...");
+ SyncAllTools();
+ }
+
+ ///
+ /// Menu item to toggle auto-sync
+ ///
+ [MenuItem("Window/MCP For Unity/Tool Sync/Auto-Sync Python Tools", priority = 101)]
+ public static void ToggleAutoSync()
+ {
+ SetAutoSyncEnabled(!IsAutoSyncEnabled());
+ }
+
+ ///
+ /// Validate menu item (shows checkmark when enabled)
+ ///
+ [MenuItem("Window/MCP For Unity/Tool Sync/Auto-Sync Python Tools", true, priority = 101)]
+ public static bool ToggleAutoSyncValidate()
+ {
+ Menu.SetChecked("Window/MCP For Unity/Tool Sync/Auto-Sync Python Tools", IsAutoSyncEnabled());
+ return true;
+ }
+ }
+}
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:
diff --git a/MCPForUnity/Editor/Helpers/ServerInstaller.cs b/MCPForUnity/Editor/Helpers/ServerInstaller.cs
index 5ab823eb..2b0c8f45 100644
--- a/MCPForUnity/Editor/Helpers/ServerInstaller.cs
+++ b/MCPForUnity/Editor/Helpers/ServerInstaller.cs
@@ -65,6 +65,7 @@ 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}).");
@@ -410,6 +411,7 @@ private static bool TryGetEmbeddedServerSource(out string srcPath)
}
private static readonly string[] _skipDirs = { ".venv", "__pycache__", ".pytest_cache", ".mypy_cache", ".git" };
+
private static void CopyDirectoryRecursive(string sourceDir, string destinationDir)
{
Directory.CreateDirectory(destinationDir);
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..dde40d10
--- /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);
+ }
+}
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..bd17f996
--- /dev/null
+++ b/MCPForUnity/Editor/Services/ToolSyncService.cs
@@ -0,0 +1,134 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using MCPForUnity.Editor.Helpers;
+using UnityEditor;
+
+namespace MCPForUnity.Editor.Services
+{
+ public class ToolSyncService : IToolSyncService
+ {
+ private readonly IPythonToolRegistryService _registryService;
+
+ public ToolSyncService(IPythonToolRegistryService registryService = null)
+ {
+ _registryService = registryService ?? MCPServiceLocator.PythonToolRegistry;
+ }
+
+ public ToolSyncResult SyncProjectTools(string destToolsDir)
+ {
+ var result = new ToolSyncResult();
+
+ try
+ {
+ Directory.CreateDirectory(destToolsDir);
+
+ // Get all PythonToolsAsset instances in the project
+ var registries = _registryService.GetAllRegistries().ToList();
+
+ if (!registries.Any())
+ {
+ McpLog.Info("No PythonToolsAsset found. Create one via Assets > Create > MCP For Unity > Python Tools");
+ return result;
+ }
+
+ var syncedFiles = new HashSet();
+
+ // Batch all asset modifications together to minimize reimports
+ AssetDatabase.StartAssetEditing();
+ try
+ {
+ foreach (var registry in registries)
+ {
+ foreach (var file in registry.GetValidFiles())
+ {
+ try
+ {
+ // Check if needs syncing (hash-based or always)
+ if (_registryService.NeedsSync(registry, file))
+ {
+ string destPath = Path.Combine(destToolsDir, file.name + ".py");
+
+ // Write the Python file content
+ File.WriteAllText(destPath, file.text);
+
+ // Record sync
+ _registryService.RecordSync(registry, file);
+
+ result.CopiedCount++;
+ syncedFiles.Add(destPath);
+ McpLog.Info($"Synced Python tool: {file.name}.py");
+ }
+ else
+ {
+ string destPath = Path.Combine(destToolsDir, file.name + ".py");
+ syncedFiles.Add(destPath);
+ result.SkippedCount++;
+ }
+ }
+ catch (Exception ex)
+ {
+ result.ErrorCount++;
+ result.Messages.Add($"Failed to sync {file.name}: {ex.Message}");
+ }
+ }
+
+ // Cleanup stale states in registry
+ registry.CleanupStaleStates();
+ EditorUtility.SetDirty(registry);
+ }
+
+ // Cleanup stale Python files in destination
+ CleanupStaleFiles(destToolsDir, syncedFiles);
+ }
+ finally
+ {
+ // End batch editing - this triggers a single asset refresh
+ AssetDatabase.StopAssetEditing();
+ }
+
+ // Save all modified registries
+ AssetDatabase.SaveAssets();
+ }
+ catch (Exception ex)
+ {
+ result.ErrorCount++;
+ result.Messages.Add($"Sync failed: {ex.Message}");
+ }
+
+ return result;
+ }
+
+ private void CleanupStaleFiles(string destToolsDir, HashSet 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:
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 77ec7f61..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")
@@ -21,22 +20,15 @@ def register_all_tools(mcp: FastMCP):
"""
Auto-discover and register all tools in the tools/ directory.
- Any .py file in this directory with @mcp_for_unity_tool decorated
+ Any .py file in this directory or subdirectories with @mcp_for_unity_tool decorated
functions will be automatically registered.
"""
logger.info("Auto-discovering MCP for Unity Server tools...")
# Dynamic import of all modules in this directory
tools_dir = Path(__file__).parent
- for _, module_name, _ in pkgutil.iter_modules([str(tools_dir)]):
- # Skip private modules and __init__
- if module_name.startswith('_'):
- continue
-
- try:
- importlib.import_module(f'.{module_name}', __package__)
- except Exception as e:
- logger.warning(f"Failed to import tool module {module_name}: {e}")
+ # Discover and import all modules
+ list(discover_modules(tools_dir, __package__))
tools = get_registered_tools()
diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Data.meta b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Data.meta
new file mode 100644
index 00000000..0f1c846d
--- /dev/null
+++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Data.meta
@@ -0,0 +1,8 @@
+fileFormatVersion: 2
+guid: 7ceb57590b405440da51ee3ec8c7daa5
+folderAsset: yes
+DefaultImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Data/PythonToolsAssetTests.cs b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Data/PythonToolsAssetTests.cs
new file mode 100644
index 00000000..ac442695
--- /dev/null
+++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Data/PythonToolsAssetTests.cs
@@ -0,0 +1,205 @@
+using System;
+using System.Linq;
+using NUnit.Framework;
+using UnityEngine;
+using MCPForUnity.Editor.Data;
+
+namespace MCPForUnityTests.Editor.Data
+{
+ public class PythonToolsAssetTests
+ {
+ private PythonToolsAsset _asset;
+
+ [SetUp]
+ public void SetUp()
+ {
+ _asset = ScriptableObject.CreateInstance();
+ }
+
+ [TearDown]
+ public void TearDown()
+ {
+ if (_asset != null)
+ {
+ UnityEngine.Object.DestroyImmediate(_asset, true);
+ }
+ }
+
+ [Test]
+ public void GetValidFiles_ReturnsEmptyList_WhenNoFilesAdded()
+ {
+ var validFiles = _asset.GetValidFiles().ToList();
+
+ Assert.IsEmpty(validFiles, "Should return empty list when no files added");
+ }
+
+ [Test]
+ public void GetValidFiles_FiltersOutNullReferences()
+ {
+ _asset.pythonFiles.Add(null);
+ _asset.pythonFiles.Add(new TextAsset("print('test')"));
+ _asset.pythonFiles.Add(null);
+
+ var validFiles = _asset.GetValidFiles().ToList();
+
+ Assert.AreEqual(1, validFiles.Count, "Should filter out null references");
+ }
+
+ [Test]
+ public void GetValidFiles_ReturnsAllNonNullFiles()
+ {
+ var file1 = new TextAsset("print('test1')");
+ var file2 = new TextAsset("print('test2')");
+
+ _asset.pythonFiles.Add(file1);
+ _asset.pythonFiles.Add(file2);
+
+ var validFiles = _asset.GetValidFiles().ToList();
+
+ Assert.AreEqual(2, validFiles.Count, "Should return all non-null files");
+ CollectionAssert.Contains(validFiles, file1);
+ CollectionAssert.Contains(validFiles, file2);
+ }
+
+ [Test]
+ public void NeedsSync_ReturnsTrue_WhenHashingDisabled()
+ {
+ _asset.useContentHashing = false;
+ var textAsset = new TextAsset("print('test')");
+
+ bool needsSync = _asset.NeedsSync(textAsset, "any_hash");
+
+ Assert.IsTrue(needsSync, "Should always need sync when hashing disabled");
+ }
+
+ [Test]
+ public void NeedsSync_ReturnsTrue_WhenFileNotInStates()
+ {
+ _asset.useContentHashing = true;
+ var textAsset = new TextAsset("print('test')");
+
+ bool needsSync = _asset.NeedsSync(textAsset, "new_hash");
+
+ Assert.IsTrue(needsSync, "Should need sync for new file");
+ }
+
+ [Test]
+ public void NeedsSync_ReturnsFalse_WhenHashMatches()
+ {
+ _asset.useContentHashing = true;
+ var textAsset = new TextAsset("print('test')");
+ string hash = "test_hash_123";
+
+ // Record the file with a hash
+ _asset.RecordSync(textAsset, hash);
+
+ // Check if needs sync with same hash
+ bool needsSync = _asset.NeedsSync(textAsset, hash);
+
+ Assert.IsFalse(needsSync, "Should not need sync when hash matches");
+ }
+
+ [Test]
+ public void NeedsSync_ReturnsTrue_WhenHashDiffers()
+ {
+ _asset.useContentHashing = true;
+ var textAsset = new TextAsset("print('test')");
+
+ // Record with one hash
+ _asset.RecordSync(textAsset, "old_hash");
+
+ // Check with different hash
+ bool needsSync = _asset.NeedsSync(textAsset, "new_hash");
+
+ Assert.IsTrue(needsSync, "Should need sync when hash differs");
+ }
+
+ [Test]
+ public void RecordSync_AddsNewFileState()
+ {
+ var textAsset = new TextAsset("print('test')");
+ string hash = "test_hash";
+
+ _asset.RecordSync(textAsset, hash);
+
+ Assert.AreEqual(1, _asset.fileStates.Count, "Should add one file state");
+ Assert.AreEqual(hash, _asset.fileStates[0].contentHash, "Should store the hash");
+ Assert.IsNotNull(_asset.fileStates[0].assetGuid, "Should store the GUID");
+ }
+
+ [Test]
+ public void RecordSync_UpdatesExistingFileState()
+ {
+ var textAsset = new TextAsset("print('test')");
+
+ // Record first time
+ _asset.RecordSync(textAsset, "hash1");
+ var firstTime = _asset.fileStates[0].lastSyncTime;
+
+ // Wait a tiny bit to ensure time difference
+ System.Threading.Thread.Sleep(10);
+
+ // Record second time with different hash
+ _asset.RecordSync(textAsset, "hash2");
+
+ Assert.AreEqual(1, _asset.fileStates.Count, "Should still have only one state");
+ Assert.AreEqual("hash2", _asset.fileStates[0].contentHash, "Should update the hash");
+ Assert.Greater(_asset.fileStates[0].lastSyncTime, firstTime, "Should update sync time");
+ }
+
+ [Test]
+ public void CleanupStaleStates_RemovesStatesForRemovedFiles()
+ {
+ var file1 = new TextAsset("print('test1')");
+ var file2 = new TextAsset("print('test2')");
+
+ // Add both files
+ _asset.pythonFiles.Add(file1);
+ _asset.pythonFiles.Add(file2);
+
+ // Record sync for both
+ _asset.RecordSync(file1, "hash1");
+ _asset.RecordSync(file2, "hash2");
+
+ Assert.AreEqual(2, _asset.fileStates.Count, "Should have two states");
+
+ // Remove one file
+ _asset.pythonFiles.Remove(file1);
+
+ // Cleanup
+ _asset.CleanupStaleStates();
+
+ Assert.AreEqual(1, _asset.fileStates.Count, "Should have one state after cleanup");
+ }
+
+ [Test]
+ public void CleanupStaleStates_KeepsStatesForCurrentFiles()
+ {
+ var file1 = new TextAsset("print('test1')");
+
+ _asset.pythonFiles.Add(file1);
+ _asset.RecordSync(file1, "hash1");
+
+ _asset.CleanupStaleStates();
+
+ Assert.AreEqual(1, _asset.fileStates.Count, "Should keep state for current file");
+ }
+
+ [Test]
+ public void CleanupStaleStates_HandlesEmptyFilesList()
+ {
+ // Add some states without corresponding files
+ _asset.fileStates.Add(new PythonFileState
+ {
+ assetGuid = "fake_guid_1",
+ contentHash = "hash1",
+ fileName = "test1.py",
+ lastSyncTime = DateTime.UtcNow
+ });
+
+ _asset.CleanupStaleStates();
+
+ Assert.IsEmpty(_asset.fileStates, "Should remove all states when no files exist");
+ }
+ }
+}
diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Data/PythonToolsAssetTests.cs.meta b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Data/PythonToolsAssetTests.cs.meta
new file mode 100644
index 00000000..62edb5af
--- /dev/null
+++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Data/PythonToolsAssetTests.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: c3d4e5f678901234567890123456abcd
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Services.meta b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Services.meta
new file mode 100644
index 00000000..6a67a5a9
--- /dev/null
+++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Services.meta
@@ -0,0 +1,8 @@
+fileFormatVersion: 2
+guid: a7b66499ec8924852a539d5cc4378c0d
+folderAsset: yes
+DefaultImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Services/PythonToolRegistryServiceTests.cs b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Services/PythonToolRegistryServiceTests.cs
new file mode 100644
index 00000000..1c9f71e1
--- /dev/null
+++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Services/PythonToolRegistryServiceTests.cs
@@ -0,0 +1,135 @@
+using System.Collections.Generic;
+using System.Linq;
+using NUnit.Framework;
+using UnityEngine;
+using MCPForUnity.Editor.Data;
+using MCPForUnity.Editor.Services;
+
+namespace MCPForUnityTests.Editor.Services
+{
+ public class PythonToolRegistryServiceTests
+ {
+ private PythonToolRegistryService _service;
+
+ [SetUp]
+ public void SetUp()
+ {
+ _service = new PythonToolRegistryService();
+ }
+
+ [Test]
+ public void GetAllRegistries_ReturnsEmptyList_WhenNoPythonToolsAssetsExist()
+ {
+ var registries = _service.GetAllRegistries().ToList();
+
+ // Note: This might find assets in the test project, so we just verify it doesn't throw
+ Assert.IsNotNull(registries, "Should return a non-null list");
+ }
+
+ [Test]
+ public void NeedsSync_ReturnsTrue_WhenHashingDisabled()
+ {
+ var asset = ScriptableObject.CreateInstance();
+ asset.useContentHashing = false;
+
+ var textAsset = new TextAsset("print('test')");
+
+ bool needsSync = _service.NeedsSync(asset, textAsset);
+
+ Assert.IsTrue(needsSync, "Should always need sync when hashing is disabled");
+
+ Object.DestroyImmediate(asset);
+ }
+
+ [Test]
+ public void NeedsSync_ReturnsTrue_WhenFileNotPreviouslySynced()
+ {
+ var asset = ScriptableObject.CreateInstance();
+ asset.useContentHashing = true;
+
+ var textAsset = new TextAsset("print('test')");
+
+ bool needsSync = _service.NeedsSync(asset, textAsset);
+
+ Assert.IsTrue(needsSync, "Should need sync for new file");
+
+ Object.DestroyImmediate(asset);
+ }
+
+ [Test]
+ public void NeedsSync_ReturnsFalse_WhenHashMatches()
+ {
+ var asset = ScriptableObject.CreateInstance();
+ asset.useContentHashing = true;
+
+ var textAsset = new TextAsset("print('test')");
+
+ // First sync
+ _service.RecordSync(asset, textAsset);
+
+ // Check if needs sync again
+ bool needsSync = _service.NeedsSync(asset, textAsset);
+
+ Assert.IsFalse(needsSync, "Should not need sync when hash matches");
+
+ Object.DestroyImmediate(asset);
+ }
+
+ [Test]
+ public void RecordSync_StoresFileState()
+ {
+ var asset = ScriptableObject.CreateInstance();
+ var textAsset = new TextAsset("print('test')");
+
+ _service.RecordSync(asset, textAsset);
+
+ Assert.AreEqual(1, asset.fileStates.Count, "Should have one file state recorded");
+ Assert.IsNotNull(asset.fileStates[0].contentHash, "Hash should be stored");
+ Assert.IsNotNull(asset.fileStates[0].assetGuid, "GUID should be stored");
+
+ Object.DestroyImmediate(asset);
+ }
+
+ [Test]
+ public void RecordSync_UpdatesExistingState_WhenFileAlreadyRecorded()
+ {
+ var asset = ScriptableObject.CreateInstance();
+ var textAsset = new TextAsset("print('test')");
+
+ // Record twice
+ _service.RecordSync(asset, textAsset);
+ var firstHash = asset.fileStates[0].contentHash;
+
+ _service.RecordSync(asset, textAsset);
+
+ Assert.AreEqual(1, asset.fileStates.Count, "Should still have only one state");
+ Assert.AreEqual(firstHash, asset.fileStates[0].contentHash, "Hash should remain the same");
+
+ Object.DestroyImmediate(asset);
+ }
+
+ [Test]
+ public void ComputeHash_ReturnsSameHash_ForSameContent()
+ {
+ var textAsset1 = new TextAsset("print('hello')");
+ var textAsset2 = new TextAsset("print('hello')");
+
+ string hash1 = _service.ComputeHash(textAsset1);
+ string hash2 = _service.ComputeHash(textAsset2);
+
+ Assert.AreEqual(hash1, hash2, "Same content should produce same hash");
+ }
+
+ [Test]
+ public void ComputeHash_ReturnsDifferentHash_ForDifferentContent()
+ {
+ var textAsset1 = new TextAsset("print('hello')");
+ var textAsset2 = new TextAsset("print('world')");
+
+ string hash1 = _service.ComputeHash(textAsset1);
+ string hash2 = _service.ComputeHash(textAsset2);
+
+ Assert.AreNotEqual(hash1, hash2, "Different content should produce different hash");
+ }
+ }
+}
diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Services/PythonToolRegistryServiceTests.cs.meta b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Services/PythonToolRegistryServiceTests.cs.meta
new file mode 100644
index 00000000..b694a93a
--- /dev/null
+++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Services/PythonToolRegistryServiceTests.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: fb9be9b99beba4112a7e3182df1d1d10
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Services/ToolSyncServiceTests.cs b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Services/ToolSyncServiceTests.cs
new file mode 100644
index 00000000..7b708456
--- /dev/null
+++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Services/ToolSyncServiceTests.cs
@@ -0,0 +1,88 @@
+using System.IO;
+using NUnit.Framework;
+using UnityEngine;
+using MCPForUnity.Editor.Data;
+using MCPForUnity.Editor.Services;
+
+namespace MCPForUnityTests.Editor.Services
+{
+ public class ToolSyncServiceTests
+ {
+ private ToolSyncService _service;
+ private string _testToolsDir;
+
+ [SetUp]
+ public void SetUp()
+ {
+ _service = new ToolSyncService();
+ _testToolsDir = Path.Combine(Path.GetTempPath(), "UnityMCPTests", "tools");
+
+ // Clean up any existing test directory
+ if (Directory.Exists(_testToolsDir))
+ {
+ Directory.Delete(_testToolsDir, true);
+ }
+ }
+
+ [TearDown]
+ public void TearDown()
+ {
+ // Clean up test directory
+ if (Directory.Exists(_testToolsDir))
+ {
+ try
+ {
+ Directory.Delete(_testToolsDir, true);
+ }
+ catch
+ {
+ // Ignore cleanup errors
+ }
+ }
+ }
+
+ [Test]
+ public void SyncProjectTools_CreatesDestinationDirectory()
+ {
+ _service.SyncProjectTools(_testToolsDir);
+
+ Assert.IsTrue(Directory.Exists(_testToolsDir), "Should create destination directory");
+ }
+
+ [Test]
+ public void SyncProjectTools_ReturnsSuccess_WhenNoPythonToolsAssets()
+ {
+ var result = _service.SyncProjectTools(_testToolsDir);
+
+ Assert.IsNotNull(result, "Should return a result");
+ Assert.AreEqual(0, result.CopiedCount, "Should not copy any files");
+ Assert.AreEqual(0, result.ErrorCount, "Should not have errors");
+ }
+
+ [Test]
+ public void SyncProjectTools_CleansUpStaleFiles()
+ {
+ // Create a stale file in the destination
+ Directory.CreateDirectory(_testToolsDir);
+ string staleFile = Path.Combine(_testToolsDir, "old_tool.py");
+ File.WriteAllText(staleFile, "print('old')");
+
+ Assert.IsTrue(File.Exists(staleFile), "Stale file should exist before sync");
+
+ // Sync with no assets (should cleanup the stale file)
+ _service.SyncProjectTools(_testToolsDir);
+
+ Assert.IsFalse(File.Exists(staleFile), "Stale file should be removed after sync");
+ }
+
+ [Test]
+ public void SyncProjectTools_ReportsCorrectCounts()
+ {
+ var result = _service.SyncProjectTools(_testToolsDir);
+
+ Assert.IsTrue(result.CopiedCount >= 0, "Copied count should be non-negative");
+ Assert.IsTrue(result.SkippedCount >= 0, "Skipped count should be non-negative");
+ Assert.IsTrue(result.ErrorCount >= 0, "Error count should be non-negative");
+ }
+ }
+}
diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Services/ToolSyncServiceTests.cs.meta b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Services/ToolSyncServiceTests.cs.meta
new file mode 100644
index 00000000..a91f013a
--- /dev/null
+++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Services/ToolSyncServiceTests.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: b2c3d4e5f67890123456789012345abc
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
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 00000000..ef3cf365
Binary files /dev/null and b/docs/screenshots/v6_2_create_python_tools_asset.png differ
diff --git a/docs/screenshots/v6_2_python_tools_asset.png b/docs/screenshots/v6_2_python_tools_asset.png
new file mode 100644
index 00000000..42596288
Binary files /dev/null and b/docs/screenshots/v6_2_python_tools_asset.png differ