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.Collections.Generic;
using System.IO;
using System.Runtime.InteropServices;
using MCPForUnity.Editor.Models;
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)
string embeddedRoot = Path.GetDirectoryName(embeddedSrc) ?? embeddedSrc; // go up from src to UnityMcpServer
CopyDirectoryRecursive(embeddedRoot, destRoot);
// Write/refresh version file
try { File.WriteAllText(Path.Combine(destSrc, VersionFileName), embeddedVer ?? "unknown"); } catch { }
McpLog.Info($"Installed/updated server to {destRoot} (version {embeddedVer}).");
@ -410,6 +411,7 @@ namespace MCPForUnity.Editor.Helpers
}
private static readonly string[] _skipDirs = { ".venv", "__pycache__", ".pytest_cache", ".mypy_cache", ".git" };
private static void CopyDirectoryRecursive(string sourceDir, string destinationDir)
{
Directory.CreateDirectory(destinationDir);

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 IClientConfigurationService _clientService;
private static IPathResolverService _pathService;
private static IPythonToolRegistryService _pythonToolRegistryService;
private static ITestRunnerService _testRunnerService;
private static IToolSyncService _toolSyncService;
/// <summary>
/// Gets the bridge control service
/// </summary>
public static IBridgeControlService Bridge => _bridgeService ??= new BridgeControlService();
/// <summary>
/// Gets the client configuration service
/// </summary>
public static IClientConfigurationService Client => _clientService ??= new ClientConfigurationService();
/// <summary>
/// Gets the path resolver service
/// </summary>
public static IPathResolverService Paths => _pathService ??= new PathResolverService();
/// <summary>
/// Gets the Unity test runner service
/// </summary>
public static IPythonToolRegistryService PythonToolRegistry => _pythonToolRegistryService ??= new PythonToolRegistryService();
public static ITestRunnerService Tests => _testRunnerService ??= new TestRunnerService();
public static IToolSyncService ToolSync => _toolSyncService ??= new ToolSyncService();
/// <summary>
/// Registers a custom implementation for a service (useful for testing)
@ -45,8 +34,12 @@ namespace MCPForUnity.Editor.Services
_clientService = c;
else if (implementation is IPathResolverService p)
_pathService = p;
else if (implementation is IPythonToolRegistryService ptr)
_pythonToolRegistryService = ptr;
else if (implementation is ITestRunnerService t)
_testRunnerService = t;
else if (implementation is IToolSyncService ts)
_toolSyncService = ts;
}
/// <summary>
@ -57,12 +50,16 @@ namespace MCPForUnity.Editor.Services
(_bridgeService as IDisposable)?.Dispose();
(_clientService as IDisposable)?.Dispose();
(_pathService as IDisposable)?.Dispose();
(_pythonToolRegistryService as IDisposable)?.Dispose();
(_testRunnerService as IDisposable)?.Dispose();
(_toolSyncService as IDisposable)?.Dispose();
_bridgeService = null;
_clientService = null;
_pathService = null;
_pythonToolRegistryService = null;
_testRunnerService = null;
_toolSyncService = null;
}
}
}

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.
"""
import importlib
import logging
from pathlib import Path
import pkgutil
from mcp.server.fastmcp import FastMCP
from telemetry_decorator import telemetry_resource
from registry import get_registered_resources
from module_discovery import discover_modules
logger = logging.getLogger("mcp-for-unity-server")
@ -21,23 +20,15 @@ def register_all_resources(mcp: FastMCP):
"""
Auto-discover and register all resources in the resources/ directory.
Any .py file in this directory with @mcp_for_unity_resource decorated
Any .py file in this directory or subdirectories with @mcp_for_unity_resource decorated
functions will be automatically registered.
"""
logger.info("Auto-discovering MCP for Unity Server resources...")
# Dynamic import of all modules in this directory
resources_dir = Path(__file__).parent
for _, module_name, _ in pkgutil.iter_modules([str(resources_dir)]):
# Skip private modules and __init__
if module_name.startswith('_'):
continue
try:
importlib.import_module(f'.{module_name}', __package__)
except Exception as e:
logger.warning(
f"Failed to import resource module {module_name}: {e}")
# Discover and import all modules
list(discover_modules(resources_dir, __package__))
resources = get_registered_resources()

View File

@ -1,15 +1,14 @@
"""
MCP Tools package - Auto-discovers and registers all tools in this directory.
"""
import importlib
import logging
from pathlib import Path
import pkgutil
from mcp.server.fastmcp import FastMCP
from telemetry_decorator import telemetry_tool
from registry import get_registered_tools
from module_discovery import discover_modules
logger = logging.getLogger("mcp-for-unity-server")
@ -21,22 +20,15 @@ def register_all_tools(mcp: FastMCP):
"""
Auto-discover and register all tools in the tools/ directory.
Any .py file in this directory with @mcp_for_unity_tool decorated
Any .py file in this directory or subdirectories with @mcp_for_unity_tool decorated
functions will be automatically registered.
"""
logger.info("Auto-discovering MCP for Unity Server tools...")
# Dynamic import of all modules in this directory
tools_dir = Path(__file__).parent
for _, module_name, _ in pkgutil.iter_modules([str(tools_dir)]):
# Skip private modules and __init__
if module_name.startswith('_'):
continue
try:
importlib.import_module(f'.{module_name}', __package__)
except Exception as e:
logger.warning(f"Failed to import tool module {module_name}: {e}")
# Discover and import all modules
list(discover_modules(tools_dir, __package__))
tools = get_registered_tools()

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
MCP for Unity now supports auto-discovery of custom tools using decorators (Python) and attributes (C#). This allows you to easily extend the MCP server with your own tools without modifying core files.
MCP for Unity supports auto-discovery of custom tools using decorators (Python) and attributes (C#). This allows you to easily extend the MCP server with your own tools.
Be sure to review the developer README first:
| [English](README-DEV.md) | [简体中文](README-DEV-zh.md) |
|---------------------------|------------------------------|
## Python Side (MCP Server)
---
### Creating a Custom Tool
# Part 1: How to Use (Quick Start Guide)
1. **Create a new Python file** in `MCPForUnity/UnityMcpServer~/src/tools/` (or any location that gets imported)
This section shows you how to add custom tools to your Unity project.
2. **Use the `@mcp_for_unity_tool` decorator**:
## Step 1: Create a PythonToolsAsset
First, create a ScriptableObject to manage your Python tools:
1. In Unity, right-click in the Project window
2. Select **Assets > Create > MCP For Unity > Python Tools**
3. Name it (e.g., `MyPythonTools`)
![Create Python Tools Asset](screenshots/v6_2_create_python_tools_asset.png)
## Step 2: Create Your Python Tool File
Create a Python file **anywhere in your Unity project**. For example, `Assets/Editor/MyTools/my_custom_tool.py`:
```python
from typing import Annotated, Any
@ -44,39 +56,20 @@ async def my_custom_tool(
return response if isinstance(response, dict) else {"success": False, "message": str(response)}
```
3. **The tool is automatically registered!** The decorator:
- Auto-generates the tool name from the function name (e.g., `my_custom_tool`)
- Registers the tool with FastMCP during module import
## Step 3: Add Python File to Asset
4. **Rebuild the server** in the MCP for Unity window (in the Unity Editor) to apply the changes.
1. Select your `PythonToolsAsset` in the Project window
2. In the Inspector, expand **Python Files**
3. Drag your `.py` file into the list (or click **+** and select it)
### Decorator Options
![Python Tools Asset Inspector](screenshots/v6_2_python_tools_asset.png)
```python
@mcp_for_unity_tool(
name="custom_name", # Optional: the function name is used by default
description="Tool description", # Required: describe what the tool does
)
```
**Note:** If you can't see `.py` files in the object picker, go to **Window > MCP For Unity > Tool Sync > Reimport Python Files** to force Unity to recognize them as text assets.
You can use all options available in FastMCP's `mcp.tool` function decorator: <https://gofastmcp.com/servers/tools#tools>.
## Step 4: Create C# Handler
**Note:** All tools should have the `description` field. It's not strictly required, however, that parameter is the best place to define a description so that most MCP clients can read it. See [issue #289](https://github.com/CoplayDev/unity-mcp/issues/289).
Create a C# file anywhere in your Unity project (typically in `Editor/`):
### Auto-Discovery
Tools are automatically discovered when:
- The Python file is in the `tools/` directory
- The file is imported during server startup
- The decorator `@mcp_for_unity_tool` is used
## C# Side (Unity Editor)
### Creating a Custom Tool Handler
1. **Create a new C# file** anywhere in your Unity project (typically in `Editor/`)
2. **Add the `[McpForUnityTool]` attribute** and implement `HandleCommand`:
```csharp
using Newtonsoft.Json.Linq;
@ -84,7 +77,6 @@ using MCPForUnity.Editor.Helpers;
namespace MyProject.Editor.CustomTools
{
// The name argument is optional, it uses a snake_case version of the class name by default
[McpForUnityTool("my_custom_tool")]
public static class MyCustomTool
{
@ -114,30 +106,23 @@ namespace MyProject.Editor.CustomTools
}
```
3. **The tool is automatically registered!** Unity will discover it via reflection on startup.
## Step 5: Rebuild the MCP Server
### Attribute Options
1. Open the MCP for Unity window in the Unity Editor
2. Click **Rebuild Server** to apply your changes
3. Your tool is now available to MCP clients!
```csharp
// Explicit command name
[McpForUnityTool("my_custom_tool")]
public static class MyCustomTool { }
**What happens automatically:**
- ✅ Python files are synced to the MCP server on Unity startup
- ✅ Python files are synced when modified (you would need to rebuild the server)
- ✅ C# handlers are discovered via reflection
- ✅ Tools are registered with the MCP server
// Auto-generated from class name (MyCustomTool → my_custom_tool)
[McpForUnityTool]
public static class MyCustomTool { }
```
## Complete Example: Screenshot Tool
### Auto-Discovery
Here's a complete example showing how to create a screenshot capture tool.
Tools are automatically discovered when:
- The class has the `[McpForUnityTool]` attribute
- The class has a `public static HandleCommand(JObject)` method
- Unity loads the assembly containing the class
## Complete Example: Custom Screenshot Tool
### Python (`UnityMcpServer~/src/tools/screenshot_tool.py`)
### Python File (`Assets/Editor/ScreenShots/Python/screenshot_tool.py`)
```python
from typing import Annotated, Any
@ -167,7 +152,13 @@ async def capture_screenshot(
return response if isinstance(response, dict) else {"success": False, "message": str(response)}
```
### C# (`Editor/CaptureScreenshotTool.cs`)
### Add to PythonToolsAsset
1. Select your `PythonToolsAsset`
2. Add `screenshot_tool.py` to the **Python Files** list
3. The file will automatically sync to the MCP server
### C# Handler (`Assets/Editor/ScreenShots/CaptureScreenshotTool.cs`)
```csharp
using System.IO;
@ -243,6 +234,94 @@ namespace MyProject.Editor.Tools
}
```
### Rebuild and Test
1. Open the MCP for Unity window
2. Click **Rebuild Server**
3. Test your tool from your MCP client!
---
# Part 2: How It Works (Technical Details)
This section explains the technical implementation of the custom tools system.
## Python Side: Decorator System
### The `@mcp_for_unity_tool` Decorator
The decorator automatically registers your function as an MCP tool:
```python
@mcp_for_unity_tool(
name="custom_name", # Optional: function name used by default
description="Tool description", # Required: describe what the tool does
)
```
**How it works:**
- Auto-generates the tool name from the function name (e.g., `my_custom_tool`)
- Registers the tool with FastMCP during module import
- Supports all FastMCP `mcp.tool` decorator options: <https://gofastmcp.com/servers/tools#tools>
**Note:** All tools should have the `description` field. It's not strictly required, however, that parameter is the best place to define a description so that most MCP clients can read it. See [issue #289](https://github.com/CoplayDev/unity-mcp/issues/289).
### Auto-Discovery
Python tools are automatically discovered when:
- The Python file is added to a `PythonToolsAsset`
- The file is synced to `MCPForUnity/UnityMcpServer~/src/tools/custom/`
- The file is imported during server startup
- The decorator `@mcp_for_unity_tool` is used
### Sync System
The `PythonToolsAsset` system automatically syncs your Python files:
**When sync happens:**
- ✅ Unity starts up
- ✅ Python files are modified
- ✅ Python files are added/removed from the asset
**Manual controls:**
- **Sync Now:** Window > MCP For Unity > Tool Sync > Sync Python Tools
- **Toggle Auto-Sync:** Window > MCP For Unity > Tool Sync > Auto-Sync Python Tools
- **Reimport Python Files:** Window > MCP For Unity > Tool Sync > Reimport Python Files
**How it works:**
- Uses content hashing to detect changes (only syncs modified files)
- Files are copied to `MCPForUnity/UnityMcpServer~/src/tools/custom/`
- Stale files are automatically cleaned up
## C# Side: Attribute System
### The `[McpForUnityTool]` Attribute
The attribute marks your class as a tool handler:
```csharp
// Explicit command name
[McpForUnityTool("my_custom_tool")]
public static class MyCustomTool { }
// Auto-generated from class name (MyCustomTool → my_custom_tool)
[McpForUnityTool]
public static class MyCustomTool { }
```
### Auto-Discovery
C# handlers are automatically discovered when:
- The class has the `[McpForUnityTool]` attribute
- The class has a `public static HandleCommand(JObject)` method
- Unity loads the assembly containing the class
**How it works:**
- Unity scans all assemblies on startup
- Finds classes with `[McpForUnityTool]` attribute
- Registers them in the command registry
- Routes MCP commands to the appropriate handler
## Best Practices
### Python
@ -274,8 +353,26 @@ namespace MyProject.Editor.Tools
## Troubleshooting
**Tool not appearing:**
- Python: Ensure the file is in `tools/` directory and imports the decorator
- C#: Ensure the class has `[McpForUnityTool]` attribute and `HandleCommand` method
- **Python:**
- Ensure the `.py` file is added to a `PythonToolsAsset`
- Check Unity Console for sync messages: "Python tools synced: X copied"
- Verify file was synced to `UnityMcpServer~/src/tools/custom/`
- Try manual sync: Window > MCP For Unity > Tool Sync > Sync Python Tools
- Rebuild the server in the MCP for Unity window
- **C#:**
- Ensure the class has `[McpForUnityTool]` attribute
- Ensure the class has a `public static HandleCommand(JObject)` method
- Check Unity Console for: "MCP-FOR-UNITY: Auto-discovered X tools"
**Python files not showing in Inspector:**
- Go to **Window > MCP For Unity > Tool Sync > Reimport Python Files**
- This forces Unity to recognize `.py` files as TextAssets
- Check that `.py.meta` files show `ScriptedImporter` (not `DefaultImporter`)
**Sync not working:**
- Check if auto-sync is enabled: Window > MCP For Unity > Tool Sync > Auto-Sync Python Tools
- Look for errors in Unity Console
- Verify `PythonToolsAsset` has the correct files added
**Name conflicts:**
- Use explicit names in decorators/attributes to avoid conflicts

Binary file not shown.

After

Width:  |  Height:  |  Size: 330 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 402 KiB