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;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Runtime.InteropServices;
|
|
||||||
using MCPForUnity.Editor.Models;
|
using MCPForUnity.Editor.Models;
|
||||||
|
|
||||||
namespace MCPForUnity.Editor.Data
|
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)
|
// Copy the entire UnityMcpServer folder (parent of src)
|
||||||
string embeddedRoot = Path.GetDirectoryName(embeddedSrc) ?? embeddedSrc; // go up from src to UnityMcpServer
|
string embeddedRoot = Path.GetDirectoryName(embeddedSrc) ?? embeddedSrc; // go up from src to UnityMcpServer
|
||||||
CopyDirectoryRecursive(embeddedRoot, destRoot);
|
CopyDirectoryRecursive(embeddedRoot, destRoot);
|
||||||
|
|
||||||
// Write/refresh version file
|
// Write/refresh version file
|
||||||
try { File.WriteAllText(Path.Combine(destSrc, VersionFileName), embeddedVer ?? "unknown"); } catch { }
|
try { File.WriteAllText(Path.Combine(destSrc, VersionFileName), embeddedVer ?? "unknown"); } catch { }
|
||||||
McpLog.Info($"Installed/updated server to {destRoot} (version {embeddedVer}).");
|
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 readonly string[] _skipDirs = { ".venv", "__pycache__", ".pytest_cache", ".mypy_cache", ".git" };
|
||||||
|
|
||||||
private static void CopyDirectoryRecursive(string sourceDir, string destinationDir)
|
private static void CopyDirectoryRecursive(string sourceDir, string destinationDir)
|
||||||
{
|
{
|
||||||
Directory.CreateDirectory(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 IBridgeControlService _bridgeService;
|
||||||
private static IClientConfigurationService _clientService;
|
private static IClientConfigurationService _clientService;
|
||||||
private static IPathResolverService _pathService;
|
private static IPathResolverService _pathService;
|
||||||
|
private static IPythonToolRegistryService _pythonToolRegistryService;
|
||||||
private static ITestRunnerService _testRunnerService;
|
private static ITestRunnerService _testRunnerService;
|
||||||
|
private static IToolSyncService _toolSyncService;
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets the bridge control service
|
|
||||||
/// </summary>
|
|
||||||
public static IBridgeControlService Bridge => _bridgeService ??= new BridgeControlService();
|
public static IBridgeControlService Bridge => _bridgeService ??= new BridgeControlService();
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets the client configuration service
|
|
||||||
/// </summary>
|
|
||||||
public static IClientConfigurationService Client => _clientService ??= new ClientConfigurationService();
|
public static IClientConfigurationService Client => _clientService ??= new ClientConfigurationService();
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets the path resolver service
|
|
||||||
/// </summary>
|
|
||||||
public static IPathResolverService Paths => _pathService ??= new PathResolverService();
|
public static IPathResolverService Paths => _pathService ??= new PathResolverService();
|
||||||
|
public static IPythonToolRegistryService PythonToolRegistry => _pythonToolRegistryService ??= new PythonToolRegistryService();
|
||||||
/// <summary>
|
|
||||||
/// Gets the Unity test runner service
|
|
||||||
/// </summary>
|
|
||||||
public static ITestRunnerService Tests => _testRunnerService ??= new TestRunnerService();
|
public static ITestRunnerService Tests => _testRunnerService ??= new TestRunnerService();
|
||||||
|
public static IToolSyncService ToolSync => _toolSyncService ??= new ToolSyncService();
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Registers a custom implementation for a service (useful for testing)
|
/// Registers a custom implementation for a service (useful for testing)
|
||||||
|
|
@ -45,8 +34,12 @@ namespace MCPForUnity.Editor.Services
|
||||||
_clientService = c;
|
_clientService = c;
|
||||||
else if (implementation is IPathResolverService p)
|
else if (implementation is IPathResolverService p)
|
||||||
_pathService = p;
|
_pathService = p;
|
||||||
|
else if (implementation is IPythonToolRegistryService ptr)
|
||||||
|
_pythonToolRegistryService = ptr;
|
||||||
else if (implementation is ITestRunnerService t)
|
else if (implementation is ITestRunnerService t)
|
||||||
_testRunnerService = t;
|
_testRunnerService = t;
|
||||||
|
else if (implementation is IToolSyncService ts)
|
||||||
|
_toolSyncService = ts;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
@ -57,12 +50,16 @@ namespace MCPForUnity.Editor.Services
|
||||||
(_bridgeService as IDisposable)?.Dispose();
|
(_bridgeService as IDisposable)?.Dispose();
|
||||||
(_clientService as IDisposable)?.Dispose();
|
(_clientService as IDisposable)?.Dispose();
|
||||||
(_pathService as IDisposable)?.Dispose();
|
(_pathService as IDisposable)?.Dispose();
|
||||||
|
(_pythonToolRegistryService as IDisposable)?.Dispose();
|
||||||
(_testRunnerService as IDisposable)?.Dispose();
|
(_testRunnerService as IDisposable)?.Dispose();
|
||||||
|
(_toolSyncService as IDisposable)?.Dispose();
|
||||||
|
|
||||||
_bridgeService = null;
|
_bridgeService = null;
|
||||||
_clientService = null;
|
_clientService = null;
|
||||||
_pathService = null;
|
_pathService = null;
|
||||||
|
_pythonToolRegistryService = null;
|
||||||
_testRunnerService = 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.
|
MCP Resources package - Auto-discovers and registers all resources in this directory.
|
||||||
"""
|
"""
|
||||||
import importlib
|
|
||||||
import logging
|
import logging
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import pkgutil
|
|
||||||
|
|
||||||
from mcp.server.fastmcp import FastMCP
|
from mcp.server.fastmcp import FastMCP
|
||||||
from telemetry_decorator import telemetry_resource
|
from telemetry_decorator import telemetry_resource
|
||||||
|
|
||||||
from registry import get_registered_resources
|
from registry import get_registered_resources
|
||||||
|
from module_discovery import discover_modules
|
||||||
|
|
||||||
logger = logging.getLogger("mcp-for-unity-server")
|
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.
|
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.
|
functions will be automatically registered.
|
||||||
"""
|
"""
|
||||||
logger.info("Auto-discovering MCP for Unity Server resources...")
|
logger.info("Auto-discovering MCP for Unity Server resources...")
|
||||||
# Dynamic import of all modules in this directory
|
# Dynamic import of all modules in this directory
|
||||||
resources_dir = Path(__file__).parent
|
resources_dir = Path(__file__).parent
|
||||||
|
|
||||||
for _, module_name, _ in pkgutil.iter_modules([str(resources_dir)]):
|
# Discover and import all modules
|
||||||
# Skip private modules and __init__
|
list(discover_modules(resources_dir, __package__))
|
||||||
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}")
|
|
||||||
|
|
||||||
resources = get_registered_resources()
|
resources = get_registered_resources()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,14 @@
|
||||||
"""
|
"""
|
||||||
MCP Tools package - Auto-discovers and registers all tools in this directory.
|
MCP Tools package - Auto-discovers and registers all tools in this directory.
|
||||||
"""
|
"""
|
||||||
import importlib
|
|
||||||
import logging
|
import logging
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import pkgutil
|
|
||||||
|
|
||||||
from mcp.server.fastmcp import FastMCP
|
from mcp.server.fastmcp import FastMCP
|
||||||
from telemetry_decorator import telemetry_tool
|
from telemetry_decorator import telemetry_tool
|
||||||
|
|
||||||
from registry import get_registered_tools
|
from registry import get_registered_tools
|
||||||
|
from module_discovery import discover_modules
|
||||||
|
|
||||||
logger = logging.getLogger("mcp-for-unity-server")
|
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.
|
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.
|
functions will be automatically registered.
|
||||||
"""
|
"""
|
||||||
logger.info("Auto-discovering MCP for Unity Server tools...")
|
logger.info("Auto-discovering MCP for Unity Server tools...")
|
||||||
# Dynamic import of all modules in this directory
|
# Dynamic import of all modules in this directory
|
||||||
tools_dir = Path(__file__).parent
|
tools_dir = Path(__file__).parent
|
||||||
|
|
||||||
for _, module_name, _ in pkgutil.iter_modules([str(tools_dir)]):
|
# Discover and import all modules
|
||||||
# Skip private modules and __init__
|
list(discover_modules(tools_dir, __package__))
|
||||||
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}")
|
|
||||||
|
|
||||||
tools = get_registered_tools()
|
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
|
# 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:
|
Be sure to review the developer README first:
|
||||||
|
|
||||||
| [English](README-DEV.md) | [简体中文](README-DEV-zh.md) |
|
| [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
|
```python
|
||||||
from typing import Annotated, Any
|
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)}
|
return response if isinstance(response, dict) else {"success": False, "message": str(response)}
|
||||||
```
|
```
|
||||||
|
|
||||||
3. **The tool is automatically registered!** The decorator:
|
## Step 3: Add Python File to Asset
|
||||||
- Auto-generates the tool name from the function name (e.g., `my_custom_tool`)
|
|
||||||
- Registers the tool with FastMCP during module import
|
|
||||||
|
|
||||||
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
|
**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.
|
||||||
@mcp_for_unity_tool(
|
|
||||||
name="custom_name", # Optional: the function name is used by default
|
|
||||||
description="Tool description", # Required: describe what the tool does
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
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
|
```csharp
|
||||||
using Newtonsoft.Json.Linq;
|
using Newtonsoft.Json.Linq;
|
||||||
|
|
@ -84,7 +77,6 @@ using MCPForUnity.Editor.Helpers;
|
||||||
|
|
||||||
namespace MyProject.Editor.CustomTools
|
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")]
|
[McpForUnityTool("my_custom_tool")]
|
||||||
public static class MyCustomTool
|
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
|
**What happens automatically:**
|
||||||
// Explicit command name
|
- ✅ Python files are synced to the MCP server on Unity startup
|
||||||
[McpForUnityTool("my_custom_tool")]
|
- ✅ Python files are synced when modified (you would need to rebuild the server)
|
||||||
public static class MyCustomTool { }
|
- ✅ C# handlers are discovered via reflection
|
||||||
|
- ✅ Tools are registered with the MCP server
|
||||||
|
|
||||||
// Auto-generated from class name (MyCustomTool → my_custom_tool)
|
## Complete Example: Screenshot Tool
|
||||||
[McpForUnityTool]
|
|
||||||
public static class MyCustomTool { }
|
|
||||||
```
|
|
||||||
|
|
||||||
### Auto-Discovery
|
Here's a complete example showing how to create a screenshot capture tool.
|
||||||
|
|
||||||
Tools are automatically discovered when:
|
### Python File (`Assets/Editor/ScreenShots/Python/screenshot_tool.py`)
|
||||||
- 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
|
```python
|
||||||
from typing import Annotated, Any
|
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)}
|
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
|
```csharp
|
||||||
using System.IO;
|
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
|
## Best Practices
|
||||||
|
|
||||||
### Python
|
### Python
|
||||||
|
|
@ -274,8 +353,26 @@ namespace MyProject.Editor.Tools
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
|
|
||||||
**Tool not appearing:**
|
**Tool not appearing:**
|
||||||
- Python: Ensure the file is in `tools/` directory and imports the decorator
|
- **Python:**
|
||||||
- C#: Ensure the class has `[McpForUnityTool]` attribute and `HandleCommand` method
|
- 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:**
|
**Name conflicts:**
|
||||||
- Use explicit names in decorators/attributes to avoid 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