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
Marcus Sanatan 2025-10-18 00:18:25 -04:00 committed by GitHub
parent 4a0e6336b2
commit 85cc93f33c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
32 changed files with 1332 additions and 96 deletions

View File

@ -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

View File

@ -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;
}
}

View File

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 1ad9865b38bcc4efe85d4970c6d3a997
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -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;
}
}
}

View File

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 4bdcf382960c842aab0a08c90411ab43
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -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);

View File

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: b104663d2f6c648e1b99633082385db2
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -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);
}
}
}

View File

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: d68ef794590944f1ea7ee102c91887c7
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -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);
}
}

View File

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: a2487319df5cc47baa2c635b911038c5
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -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);
}
}

View File

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: b9627dbaa92d24783a9f20e42efcea18
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -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;
} }
} }
} }

View File

@ -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();
}
}
}
}

View File

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 2da2869749c764f16a45e010eefbd679
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -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}");
}
}
}
}

View File

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 9ad084cf3b6c04174b9202bf63137bae
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -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}")

View File

@ -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()

View File

@ -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()

View File

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 7ceb57590b405440da51ee3ec8c7daa5
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -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");
}
}
}

View File

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: c3d4e5f678901234567890123456abcd
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: a7b66499ec8924852a539d5cc4378c0d
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -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");
}
}
}

View File

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: fb9be9b99beba4112a7e3182df1d1d10
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -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");
}
}
}

View File

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: b2c3d4e5f67890123456789012345abc
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -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`)
![Create Python Tools Asset](screenshots/v6_2_create_python_tools_asset.png)
## Step 2: Create Your Python Tool File
Create a Python file **anywhere in your Unity project**. For example, `Assets/Editor/MyTools/my_custom_tool.py`:
```python ```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 Tools Asset Inspector](screenshots/v6_2_python_tools_asset.png)
```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