Allow users to easily add tools in the Asset folder (#324)
* Fix issue #308: Find py files in MCPForUnityTools and version.txt This allows for auto finding new tools. A good dir on a custom tool would look like this: CustomTool/ ├── CustomTool.MCPEnabler.asmdef ├── CustomTool.MCPEnabler.asmdef.meta ├── ExternalAssetToolFunction.cs ├── ExternalAssetToolFunction.cs.meta ├── external_asset_tool_function.py ├── external_asset_tool_function.py.meta ├── version.txt └── version.txt.meta CS files are left in the tools folder. The asmdef is recommended to allow for dependency on MCPForUnity when MCP For Unity is installed: asmdef example { "name": "CustomTool.MCPEnabler", "rootNamespace": "MCPForUnity.Editor.Tools", "references": [ "CustomTool", "MCPForUnity.Editor" ], "includePlatforms": [ "Editor" ], "excludePlatforms": [], "allowUnsafeCode": false, "overrideReferences": false, "precompiledReferences": [], "autoReferenced": true, "defineConstraints": [], "versionDefines": [], "noEngineReferences": false } * Follow-up: address CodeRabbit feedback for #308 (<GetToolsFolderIdentifier was duplicated>) * Follow-up: address CodeRabbit feedback for #308 – centralize GetToolsFolderIdentifier, fix tools copy dir, and limit scan scope * Fixing so the MCP don't removes _skipDirs e.g. __pycache__ * skip empty folders with no py files * Rabbit: "Fix identifier collision between different package roots." * Update MCPForUnity/Editor/Helpers/ServerInstaller.cs Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * Rabbbit: Cleanup may delete server’s built-in tool subfolders — restrict to managed names. * Fixed minor + missed onadding rabit change * Revert "Fixed minor + missed onadding rabit change" This reverts commit 571ca8c5de3d07da3791dad558677909a07e886d. * refactor: remove Unity project tools copying and version tracking functionality * refactor: consolidate module discovery logic into shared utility function * Remove unused imports * feat: add Python tool registry and sync system for MCP server integration * feat: add auto-sync processor for Python tools with Unity editor integration * feat: add menu item to reimport all Python files in project Good to give users a manual option * Fix infinite loop error Don't react to PythonToolAsset changes - it only needs to react to Python file changes. And we also batch asset edits to minimise the DB refreshes * refactor: move Python tool sync menu items under Window/MCP For Unity/Tool Sync * Update docs * Remove duplicate header * feat: add OnValidate handler to sync Python tools when asset is modified This fixes the issue with deletions in the asset, now file removals are synced * test: add unit tests for Python tools asset and sync services * Update MCPForUnity/Editor/Helpers/PythonToolSyncProcessor.cs Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * style: remove trailing whitespace from Python tool sync files * test: remove incomplete unit tests from ToolSyncServiceTests * perf: optimize Python file reimport by using AssetDatabase.FindAssets instead of GetAllAssetPaths --------- Co-authored-by: Johan Holtby <72528418+JohanHoltby@users.noreply.github.com> Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>main
parent
4a0e6336b2
commit
85cc93f33c
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -0,0 +1,107 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using UnityEngine;
|
||||
|
||||
namespace MCPForUnity.Editor.Data
|
||||
{
|
||||
/// <summary>
|
||||
/// Registry of Python tool files to sync to the MCP server.
|
||||
/// Add your Python files here - they can be stored anywhere in your project.
|
||||
/// </summary>
|
||||
[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<TextAsset> pythonFiles = new List<TextAsset>();
|
||||
|
||||
[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<PythonFileState> fileStates = new List<PythonFileState>();
|
||||
|
||||
/// <summary>
|
||||
/// Gets all valid Python files (filters out null/missing references)
|
||||
/// </summary>
|
||||
public IEnumerable<TextAsset> GetValidFiles()
|
||||
{
|
||||
return pythonFiles.Where(f => f != null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if a file needs syncing
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records that a file was synced
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes state entries for files no longer in the list
|
||||
/// </summary>
|
||||
public void CleanupStaleStates()
|
||||
{
|
||||
var validGuids = new HashSet<string>(GetValidFiles().Select(GetAssetGuid));
|
||||
fileStates.RemoveAll(s => !validGuids.Contains(s.assetGuid));
|
||||
}
|
||||
|
||||
private string GetAssetGuid(TextAsset asset)
|
||||
{
|
||||
return UnityEditor.AssetDatabase.AssetPathToGUID(UnityEditor.AssetDatabase.GetAssetPath(asset));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Called when the asset is modified in the Inspector
|
||||
/// Triggers sync to handle file additions/removals
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 1ad9865b38bcc4efe85d4970c6d3a997
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// Automatically syncs Python tools to the MCP server when:
|
||||
/// - PythonToolsAsset is modified
|
||||
/// - Python files are imported/reimported
|
||||
/// - Unity starts up
|
||||
/// </summary>
|
||||
[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();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Called after any assets are imported, deleted, or moved
|
||||
/// </summary>
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Syncs all Python tools from all PythonToolsAsset instances to the MCP server
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if auto-sync is enabled (default: true)
|
||||
/// </summary>
|
||||
public static bool IsAutoSyncEnabled()
|
||||
{
|
||||
return EditorPrefs.GetBool(SyncEnabledKey, true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Enables or disables auto-sync
|
||||
/// </summary>
|
||||
public static void SetAutoSyncEnabled(bool enabled)
|
||||
{
|
||||
EditorPrefs.SetBool(SyncEnabledKey, enabled);
|
||||
McpLog.Info($"Python tool auto-sync {(enabled ? "enabled" : "disabled")}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Menu item to reimport all Python files in the project
|
||||
/// </summary>
|
||||
[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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Menu item to manually trigger sync
|
||||
/// </summary>
|
||||
[MenuItem("Window/MCP For Unity/Tool Sync/Sync Python Tools", priority = 100)]
|
||||
public static void ManualSync()
|
||||
{
|
||||
McpLog.Info("Manually syncing Python tools...");
|
||||
SyncAllTools();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Menu item to toggle auto-sync
|
||||
/// </summary>
|
||||
[MenuItem("Window/MCP For Unity/Tool Sync/Auto-Sync Python Tools", priority = 101)]
|
||||
public static void ToggleAutoSync()
|
||||
{
|
||||
SetAutoSyncEnabled(!IsAutoSyncEnabled());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validate menu item (shows checkmark when enabled)
|
||||
/// </summary>
|
||||
[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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 4bdcf382960c842aab0a08c90411ab43
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
@ -65,6 +65,7 @@ namespace MCPForUnity.Editor.Helpers
|
|||
// 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 @@ namespace MCPForUnity.Editor.Helpers
|
|||
}
|
||||
|
||||
private static readonly string[] _skipDirs = { ".venv", "__pycache__", ".pytest_cache", ".mypy_cache", ".git" };
|
||||
|
||||
private static void CopyDirectoryRecursive(string sourceDir, string destinationDir)
|
||||
{
|
||||
Directory.CreateDirectory(destinationDir);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,8 @@
|
|||
fileFormatVersion: 2
|
||||
guid: b104663d2f6c648e1b99633082385db2
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
using UnityEngine;
|
||||
using UnityEditor.AssetImporters;
|
||||
using System.IO;
|
||||
|
||||
namespace MCPForUnity.Editor.Importers
|
||||
{
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
fileFormatVersion: 2
|
||||
guid: d68ef794590944f1ea7ee102c91887c7
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
using MCPForUnity.Editor.Data;
|
||||
|
||||
namespace MCPForUnity.Editor.Services
|
||||
{
|
||||
public interface IPythonToolRegistryService
|
||||
{
|
||||
IEnumerable<PythonToolsAsset> GetAllRegistries();
|
||||
bool NeedsSync(PythonToolsAsset registry, TextAsset file);
|
||||
void RecordSync(PythonToolsAsset registry, TextAsset file);
|
||||
string ComputeHash(TextAsset file);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
fileFormatVersion: 2
|
||||
guid: a2487319df5cc47baa2c635b911038c5
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
@ -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<string> Messages { get; set; } = new List<string>();
|
||||
public bool Success => ErrorCount == 0;
|
||||
}
|
||||
|
||||
public interface IToolSyncService
|
||||
{
|
||||
ToolSyncResult SyncProjectTools(string destToolsDir);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
fileFormatVersion: 2
|
||||
guid: b9627dbaa92d24783a9f20e42efcea18
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
@ -10,27 +10,16 @@ namespace MCPForUnity.Editor.Services
|
|||
private static IBridgeControlService _bridgeService;
|
||||
private static IClientConfigurationService _clientService;
|
||||
private static IPathResolverService _pathService;
|
||||
private static IPythonToolRegistryService _pythonToolRegistryService;
|
||||
private static ITestRunnerService _testRunnerService;
|
||||
private static IToolSyncService _toolSyncService;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the bridge control service
|
||||
/// </summary>
|
||||
public static IBridgeControlService Bridge => _bridgeService ??= new BridgeControlService();
|
||||
|
||||
/// <summary>
|
||||
/// Gets the client configuration service
|
||||
/// </summary>
|
||||
public static IClientConfigurationService Client => _clientService ??= new ClientConfigurationService();
|
||||
|
||||
/// <summary>
|
||||
/// Gets the path resolver service
|
||||
/// </summary>
|
||||
public static IPathResolverService Paths => _pathService ??= new PathResolverService();
|
||||
|
||||
/// <summary>
|
||||
/// Gets the Unity test runner service
|
||||
/// </summary>
|
||||
public static IPythonToolRegistryService PythonToolRegistry => _pythonToolRegistryService ??= new PythonToolRegistryService();
|
||||
public static ITestRunnerService Tests => _testRunnerService ??= new TestRunnerService();
|
||||
public static IToolSyncService ToolSync => _toolSyncService ??= new ToolSyncService();
|
||||
|
||||
/// <summary>
|
||||
/// Registers a custom implementation for a service (useful for testing)
|
||||
|
|
@ -45,8 +34,12 @@ namespace MCPForUnity.Editor.Services
|
|||
_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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -57,12 +50,16 @@ namespace MCPForUnity.Editor.Services
|
|||
(_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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<PythonToolsAsset> 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<PythonToolsAsset>(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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 2da2869749c764f16a45e010eefbd679
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
@ -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<string>();
|
||||
|
||||
// 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<string> 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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 9ad084cf3b6c04174b9202bf63137bae
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
@ -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}")
|
||||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,8 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 7ceb57590b405440da51ee3ec8c7daa5
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
@ -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<PythonToolsAsset>();
|
||||
}
|
||||
|
||||
[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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
fileFormatVersion: 2
|
||||
guid: c3d4e5f678901234567890123456abcd
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
fileFormatVersion: 2
|
||||
guid: a7b66499ec8924852a539d5cc4378c0d
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
@ -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<PythonToolsAsset>();
|
||||
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<PythonToolsAsset>();
|
||||
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<PythonToolsAsset>();
|
||||
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<PythonToolsAsset>();
|
||||
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<PythonToolsAsset>();
|
||||
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
fileFormatVersion: 2
|
||||
guid: fb9be9b99beba4112a7e3182df1d1d10
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
fileFormatVersion: 2
|
||||
guid: b2c3d4e5f67890123456789012345abc
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
@ -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: <https://gofastmcp.com/servers/tools#tools>.
|
||||
## 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 { }
|
||||
**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-generated from class name (MyCustomTool → my_custom_tool)
|
||||
[McpForUnityTool]
|
||||
public static class MyCustomTool { }
|
||||
```
|
||||
## Complete Example: Screenshot Tool
|
||||
|
||||
### Auto-Discovery
|
||||
Here's a complete example showing how to create a screenshot capture tool.
|
||||
|
||||
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: Custom Screenshot 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: <https://gofastmcp.com/servers/tools#tools>
|
||||
|
||||
**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
|
||||
|
|
|
|||
Binary file not shown.
|
After Width: | Height: | Size: 330 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 402 KiB |
Loading…
Reference in New Issue