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`) + +![Create Python Tools Asset](screenshots/v6_2_create_python_tools_asset.png) + +## Step 2: Create Your Python Tool File + +Create a Python file **anywhere in your Unity project**. For example, `Assets/Editor/MyTools/my_custom_tool.py`: ```python from typing import Annotated, Any @@ -44,39 +56,20 @@ async def my_custom_tool( return response if isinstance(response, dict) else {"success": False, "message": str(response)} ``` -3. **The tool is automatically registered!** The decorator: - - Auto-generates the tool name from the function name (e.g., `my_custom_tool`) - - Registers the tool with FastMCP during module import +## Step 3: Add Python File to Asset -4. **Rebuild the server** in the MCP for Unity window (in the Unity Editor) to apply the changes. +1. Select your `PythonToolsAsset` in the Project window +2. In the Inspector, expand **Python Files** +3. Drag your `.py` file into the list (or click **+** and select it) -### Decorator Options +![Python Tools Asset Inspector](screenshots/v6_2_python_tools_asset.png) -```python -@mcp_for_unity_tool( - name="custom_name", # Optional: the function name is used by default - description="Tool description", # Required: describe what the tool does -) -``` +**Note:** If you can't see `.py` files in the object picker, go to **Window > MCP For Unity > Tool Sync > Reimport Python Files** to force Unity to recognize them as text assets. -You can use all options available in FastMCP's `mcp.tool` function decorator: . +## 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