Merge branch 'main' of https://github.com/CoplayDev/unity-mcp
commit
be7ade8020
|
|
@ -1,7 +1,6 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Runtime.InteropServices;
|
||||
using MCPForUnity.Editor.Models;
|
||||
|
||||
namespace MCPForUnity.Editor.Data
|
||||
|
|
|
|||
|
|
@ -0,0 +1,107 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using UnityEngine;
|
||||
|
||||
namespace MCPForUnity.Editor.Data
|
||||
{
|
||||
/// <summary>
|
||||
/// Registry of Python tool files to sync to the MCP server.
|
||||
/// Add your Python files here - they can be stored anywhere in your project.
|
||||
/// </summary>
|
||||
[CreateAssetMenu(fileName = "PythonTools", menuName = "MCP For Unity/Python Tools")]
|
||||
public class PythonToolsAsset : ScriptableObject
|
||||
{
|
||||
[Tooltip("Add Python files (.py) to sync to the MCP server. Files can be located anywhere in your project.")]
|
||||
public List<TextAsset> pythonFiles = new List<TextAsset>();
|
||||
|
||||
[Header("Sync Options")]
|
||||
[Tooltip("Use content hashing to detect changes (recommended). If false, always copies on startup.")]
|
||||
public bool useContentHashing = true;
|
||||
|
||||
[Header("Sync State (Read-only)")]
|
||||
[Tooltip("Internal tracking - do not modify")]
|
||||
public List<PythonFileState> fileStates = new List<PythonFileState>();
|
||||
|
||||
/// <summary>
|
||||
/// Gets all valid Python files (filters out null/missing references)
|
||||
/// </summary>
|
||||
public IEnumerable<TextAsset> GetValidFiles()
|
||||
{
|
||||
return pythonFiles.Where(f => f != null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if a file needs syncing
|
||||
/// </summary>
|
||||
public bool NeedsSync(TextAsset file, string currentHash)
|
||||
{
|
||||
if (!useContentHashing) return true; // Always sync if hashing disabled
|
||||
|
||||
var state = fileStates.FirstOrDefault(s => s.assetGuid == GetAssetGuid(file));
|
||||
return state == null || state.contentHash != currentHash;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records that a file was synced
|
||||
/// </summary>
|
||||
public void RecordSync(TextAsset file, string hash)
|
||||
{
|
||||
string guid = GetAssetGuid(file);
|
||||
var state = fileStates.FirstOrDefault(s => s.assetGuid == guid);
|
||||
|
||||
if (state == null)
|
||||
{
|
||||
state = new PythonFileState { assetGuid = guid };
|
||||
fileStates.Add(state);
|
||||
}
|
||||
|
||||
state.contentHash = hash;
|
||||
state.lastSyncTime = DateTime.UtcNow;
|
||||
state.fileName = file.name;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes state entries for files no longer in the list
|
||||
/// </summary>
|
||||
public void CleanupStaleStates()
|
||||
{
|
||||
var validGuids = new HashSet<string>(GetValidFiles().Select(GetAssetGuid));
|
||||
fileStates.RemoveAll(s => !validGuids.Contains(s.assetGuid));
|
||||
}
|
||||
|
||||
private string GetAssetGuid(TextAsset asset)
|
||||
{
|
||||
return UnityEditor.AssetDatabase.AssetPathToGUID(UnityEditor.AssetDatabase.GetAssetPath(asset));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Called when the asset is modified in the Inspector
|
||||
/// Triggers sync to handle file additions/removals
|
||||
/// </summary>
|
||||
private void OnValidate()
|
||||
{
|
||||
// Cleanup stale states immediately
|
||||
CleanupStaleStates();
|
||||
|
||||
// Trigger sync after a delay to handle file removals
|
||||
// Delay ensures the asset is saved before sync runs
|
||||
UnityEditor.EditorApplication.delayCall += () =>
|
||||
{
|
||||
if (this != null) // Check if asset still exists
|
||||
{
|
||||
MCPForUnity.Editor.Helpers.PythonToolSyncProcessor.SyncAllTools();
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public class PythonFileState
|
||||
{
|
||||
public string assetGuid;
|
||||
public string fileName;
|
||||
public string contentHash;
|
||||
public DateTime lastSyncTime;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 1ad9865b38bcc4efe85d4970c6d3a997
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
@ -2,10 +2,7 @@ using System;
|
|||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using MCPForUnity.External.Tommy;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace MCPForUnity.Editor.Helpers
|
||||
{
|
||||
|
|
@ -42,108 +39,107 @@ namespace MCPForUnity.Editor.Helpers
|
|||
|
||||
public static string BuildCodexServerBlock(string uvPath, string serverSrc)
|
||||
{
|
||||
string argsArray = FormatTomlStringArray(new[] { "run", "--directory", serverSrc, "server.py" });
|
||||
return $"[mcp_servers.unityMCP]{Environment.NewLine}" +
|
||||
$"command = \"{EscapeTomlString(uvPath)}\"{Environment.NewLine}" +
|
||||
$"args = {argsArray}";
|
||||
var table = new TomlTable();
|
||||
var mcpServers = new TomlTable();
|
||||
|
||||
mcpServers["unityMCP"] = CreateUnityMcpTable(uvPath, serverSrc);
|
||||
table["mcp_servers"] = mcpServers;
|
||||
|
||||
using var writer = new StringWriter();
|
||||
table.WriteTo(writer);
|
||||
return writer.ToString();
|
||||
}
|
||||
|
||||
public static string UpsertCodexServerBlock(string existingToml, string newBlock)
|
||||
public static string UpsertCodexServerBlock(string existingToml, string uvPath, string serverSrc)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(existingToml))
|
||||
// Parse existing TOML or create new root table
|
||||
var root = TryParseToml(existingToml) ?? new TomlTable();
|
||||
|
||||
// Ensure mcp_servers table exists
|
||||
if (!root.TryGetNode("mcp_servers", out var mcpServersNode) || !(mcpServersNode is TomlTable))
|
||||
{
|
||||
return newBlock.TrimEnd() + Environment.NewLine;
|
||||
root["mcp_servers"] = new TomlTable();
|
||||
}
|
||||
var mcpServers = root["mcp_servers"] as TomlTable;
|
||||
|
||||
StringBuilder sb = new StringBuilder();
|
||||
using StringReader reader = new StringReader(existingToml);
|
||||
string line;
|
||||
bool inTarget = false;
|
||||
bool replaced = false;
|
||||
while ((line = reader.ReadLine()) != null)
|
||||
{
|
||||
string trimmed = line.Trim();
|
||||
bool isSection = trimmed.StartsWith("[") && trimmed.EndsWith("]") && !trimmed.StartsWith("[[");
|
||||
if (isSection)
|
||||
{
|
||||
bool isTarget = string.Equals(trimmed, "[mcp_servers.unityMCP]", StringComparison.OrdinalIgnoreCase);
|
||||
if (isTarget)
|
||||
{
|
||||
if (!replaced)
|
||||
{
|
||||
if (sb.Length > 0 && sb[^1] != '\n') sb.AppendLine();
|
||||
sb.AppendLine(newBlock.TrimEnd());
|
||||
replaced = true;
|
||||
}
|
||||
inTarget = true;
|
||||
continue;
|
||||
}
|
||||
// Create or update unityMCP table
|
||||
mcpServers["unityMCP"] = CreateUnityMcpTable(uvPath, serverSrc);
|
||||
|
||||
if (inTarget)
|
||||
{
|
||||
inTarget = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (inTarget)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
sb.AppendLine(line);
|
||||
}
|
||||
|
||||
if (!replaced)
|
||||
{
|
||||
if (sb.Length > 0 && sb[^1] != '\n') sb.AppendLine();
|
||||
sb.AppendLine(newBlock.TrimEnd());
|
||||
}
|
||||
|
||||
return sb.ToString().TrimEnd() + Environment.NewLine;
|
||||
// Serialize back to TOML
|
||||
using var writer = new StringWriter();
|
||||
root.WriteTo(writer);
|
||||
return writer.ToString();
|
||||
}
|
||||
|
||||
public static bool TryParseCodexServer(string toml, out string command, out string[] args)
|
||||
{
|
||||
command = null;
|
||||
args = null;
|
||||
if (string.IsNullOrWhiteSpace(toml)) return false;
|
||||
|
||||
var root = TryParseToml(toml);
|
||||
if (root == null) return false;
|
||||
|
||||
if (!TryGetTable(root, "mcp_servers", out var servers)
|
||||
&& !TryGetTable(root, "mcpServers", out servers))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!TryGetTable(servers, "unityMCP", out var unity))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
command = GetTomlString(unity, "command");
|
||||
args = GetTomlStringArray(unity, "args");
|
||||
|
||||
return !string.IsNullOrEmpty(command) && args != null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Safely parses TOML string, returning null on failure
|
||||
/// </summary>
|
||||
private static TomlTable TryParseToml(string toml)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(toml)) return null;
|
||||
|
||||
try
|
||||
{
|
||||
using var reader = new StringReader(toml);
|
||||
TomlTable root = TOML.Parse(reader);
|
||||
if (root == null) return false;
|
||||
|
||||
if (!TryGetTable(root, "mcp_servers", out var servers)
|
||||
&& !TryGetTable(root, "mcpServers", out servers))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!TryGetTable(servers, "unityMCP", out var unity))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
command = GetTomlString(unity, "command");
|
||||
args = GetTomlStringArray(unity, "args");
|
||||
|
||||
return !string.IsNullOrEmpty(command) && args != null;
|
||||
return TOML.Parse(reader);
|
||||
}
|
||||
catch (TomlParseException)
|
||||
{
|
||||
return false;
|
||||
return null;
|
||||
}
|
||||
catch (TomlSyntaxException)
|
||||
{
|
||||
return false;
|
||||
return null;
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
return false;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a TomlTable for the unityMCP server configuration
|
||||
/// </summary>
|
||||
private static TomlTable CreateUnityMcpTable(string uvPath, string serverSrc)
|
||||
{
|
||||
var unityMCP = new TomlTable();
|
||||
unityMCP["command"] = new TomlString { Value = uvPath };
|
||||
|
||||
var argsArray = new TomlArray();
|
||||
argsArray.Add(new TomlString { Value = "run" });
|
||||
argsArray.Add(new TomlString { Value = "--directory" });
|
||||
argsArray.Add(new TomlString { Value = serverSrc });
|
||||
argsArray.Add(new TomlString { Value = "server.py" });
|
||||
unityMCP["args"] = argsArray;
|
||||
|
||||
return unityMCP;
|
||||
}
|
||||
|
||||
private static bool TryGetTable(TomlTable parent, string key, out TomlTable table)
|
||||
{
|
||||
table = null;
|
||||
|
|
@ -211,33 +207,5 @@ namespace MCPForUnity.Editor.Helpers
|
|||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string FormatTomlStringArray(IEnumerable<string> values)
|
||||
{
|
||||
if (values == null) return "[]";
|
||||
StringBuilder sb = new StringBuilder();
|
||||
sb.Append('[');
|
||||
bool first = true;
|
||||
foreach (string value in values)
|
||||
{
|
||||
if (!first)
|
||||
{
|
||||
sb.Append(", ");
|
||||
}
|
||||
sb.Append('"').Append(EscapeTomlString(value ?? string.Empty)).Append('"');
|
||||
first = false;
|
||||
}
|
||||
sb.Append(']');
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static string EscapeTomlString(string value)
|
||||
{
|
||||
if (string.IsNullOrEmpty(value)) return string.Empty;
|
||||
return value
|
||||
.Replace("\\", "\\\\")
|
||||
.Replace("\"", "\\\"");
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -205,8 +205,7 @@ namespace MCPForUnity.Editor.Helpers
|
|||
return "Configured successfully";
|
||||
}
|
||||
|
||||
string codexBlock = CodexConfigHelper.BuildCodexServerBlock(uvPath, serverSrc);
|
||||
string updatedToml = CodexConfigHelper.UpsertCodexServerBlock(existingToml, codexBlock);
|
||||
string updatedToml = CodexConfigHelper.UpsertCodexServerBlock(existingToml, uvPath, serverSrc);
|
||||
|
||||
McpConfigFileHelper.WriteAtomicFile(configPath, updatedToml);
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,192 @@
|
|||
using System.IO;
|
||||
using System.Linq;
|
||||
using MCPForUnity.Editor.Data;
|
||||
using MCPForUnity.Editor.Services;
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
|
||||
namespace MCPForUnity.Editor.Helpers
|
||||
{
|
||||
/// <summary>
|
||||
/// Automatically syncs Python tools to the MCP server when:
|
||||
/// - PythonToolsAsset is modified
|
||||
/// - Python files are imported/reimported
|
||||
/// - Unity starts up
|
||||
/// </summary>
|
||||
[InitializeOnLoad]
|
||||
public class PythonToolSyncProcessor : AssetPostprocessor
|
||||
{
|
||||
private const string SyncEnabledKey = "MCPForUnity.AutoSyncEnabled";
|
||||
private static bool _isSyncing = false;
|
||||
|
||||
static PythonToolSyncProcessor()
|
||||
{
|
||||
// Sync on Unity startup
|
||||
EditorApplication.delayCall += () =>
|
||||
{
|
||||
if (IsAutoSyncEnabled())
|
||||
{
|
||||
SyncAllTools();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Called after any assets are imported, deleted, or moved
|
||||
/// </summary>
|
||||
private static void OnPostprocessAllAssets(
|
||||
string[] importedAssets,
|
||||
string[] deletedAssets,
|
||||
string[] movedAssets,
|
||||
string[] movedFromAssetPaths)
|
||||
{
|
||||
// Prevent infinite loop - don't process if we're currently syncing
|
||||
if (_isSyncing || !IsAutoSyncEnabled())
|
||||
return;
|
||||
|
||||
bool needsSync = false;
|
||||
|
||||
// Only check for .py file changes, not PythonToolsAsset changes
|
||||
// (PythonToolsAsset changes are internal state updates from syncing)
|
||||
foreach (string path in importedAssets.Concat(movedAssets))
|
||||
{
|
||||
// Check if any .py files were modified
|
||||
if (path.EndsWith(".py"))
|
||||
{
|
||||
needsSync = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if any .py files were deleted
|
||||
if (!needsSync && deletedAssets.Any(path => path.EndsWith(".py")))
|
||||
{
|
||||
needsSync = true;
|
||||
}
|
||||
|
||||
if (needsSync)
|
||||
{
|
||||
SyncAllTools();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Syncs all Python tools from all PythonToolsAsset instances to the MCP server
|
||||
/// </summary>
|
||||
public static void SyncAllTools()
|
||||
{
|
||||
// Prevent re-entrant calls
|
||||
if (_isSyncing)
|
||||
{
|
||||
McpLog.Warn("Sync already in progress, skipping...");
|
||||
return;
|
||||
}
|
||||
|
||||
_isSyncing = true;
|
||||
try
|
||||
{
|
||||
if (!ServerPathResolver.TryFindEmbeddedServerSource(out string srcPath))
|
||||
{
|
||||
McpLog.Warn("Cannot sync Python tools: MCP server source not found");
|
||||
return;
|
||||
}
|
||||
|
||||
string toolsDir = Path.Combine(srcPath, "tools", "custom");
|
||||
|
||||
var result = MCPServiceLocator.ToolSync.SyncProjectTools(toolsDir);
|
||||
|
||||
if (result.Success)
|
||||
{
|
||||
if (result.CopiedCount > 0 || result.SkippedCount > 0)
|
||||
{
|
||||
McpLog.Info($"Python tools synced: {result.CopiedCount} copied, {result.SkippedCount} skipped");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
McpLog.Error($"Python tool sync failed with {result.ErrorCount} errors");
|
||||
foreach (var msg in result.Messages)
|
||||
{
|
||||
McpLog.Error($" - {msg}");
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (System.Exception ex)
|
||||
{
|
||||
McpLog.Error($"Python tool sync exception: {ex.Message}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
_isSyncing = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if auto-sync is enabled (default: true)
|
||||
/// </summary>
|
||||
public static bool IsAutoSyncEnabled()
|
||||
{
|
||||
return EditorPrefs.GetBool(SyncEnabledKey, true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Enables or disables auto-sync
|
||||
/// </summary>
|
||||
public static void SetAutoSyncEnabled(bool enabled)
|
||||
{
|
||||
EditorPrefs.SetBool(SyncEnabledKey, enabled);
|
||||
McpLog.Info($"Python tool auto-sync {(enabled ? "enabled" : "disabled")}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Menu item to reimport all Python files in the project
|
||||
/// </summary>
|
||||
[MenuItem("Window/MCP For Unity/Tool Sync/Reimport Python Files", priority = 99)]
|
||||
public static void ReimportPythonFiles()
|
||||
{
|
||||
// Find all Python files (imported as TextAssets by PythonFileImporter)
|
||||
var pythonGuids = AssetDatabase.FindAssets("t:TextAsset", new[] { "Assets" })
|
||||
.Select(AssetDatabase.GUIDToAssetPath)
|
||||
.Where(path => path.EndsWith(".py", System.StringComparison.OrdinalIgnoreCase))
|
||||
.ToArray();
|
||||
|
||||
foreach (string path in pythonGuids)
|
||||
{
|
||||
AssetDatabase.ImportAsset(path, ImportAssetOptions.ForceUpdate);
|
||||
}
|
||||
|
||||
int count = pythonGuids.Length;
|
||||
McpLog.Info($"Reimported {count} Python files");
|
||||
AssetDatabase.Refresh();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Menu item to manually trigger sync
|
||||
/// </summary>
|
||||
[MenuItem("Window/MCP For Unity/Tool Sync/Sync Python Tools", priority = 100)]
|
||||
public static void ManualSync()
|
||||
{
|
||||
McpLog.Info("Manually syncing Python tools...");
|
||||
SyncAllTools();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Menu item to toggle auto-sync
|
||||
/// </summary>
|
||||
[MenuItem("Window/MCP For Unity/Tool Sync/Auto-Sync Python Tools", priority = 101)]
|
||||
public static void ToggleAutoSync()
|
||||
{
|
||||
SetAutoSyncEnabled(!IsAutoSyncEnabled());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validate menu item (shows checkmark when enabled)
|
||||
/// </summary>
|
||||
[MenuItem("Window/MCP For Unity/Tool Sync/Auto-Sync Python Tools", true, priority = 101)]
|
||||
public static bool ToggleAutoSyncValidate()
|
||||
{
|
||||
Menu.SetChecked("Window/MCP For Unity/Tool Sync/Auto-Sync Python Tools", IsAutoSyncEnabled());
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 4bdcf382960c842aab0a08c90411ab43
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
@ -65,6 +65,7 @@ namespace MCPForUnity.Editor.Helpers
|
|||
// Copy the entire UnityMcpServer folder (parent of src)
|
||||
string embeddedRoot = Path.GetDirectoryName(embeddedSrc) ?? embeddedSrc; // go up from src to UnityMcpServer
|
||||
CopyDirectoryRecursive(embeddedRoot, destRoot);
|
||||
|
||||
// Write/refresh version file
|
||||
try { File.WriteAllText(Path.Combine(destSrc, VersionFileName), embeddedVer ?? "unknown"); } catch { }
|
||||
McpLog.Info($"Installed/updated server to {destRoot} (version {embeddedVer}).");
|
||||
|
|
@ -410,6 +411,7 @@ namespace MCPForUnity.Editor.Helpers
|
|||
}
|
||||
|
||||
private static readonly string[] _skipDirs = { ".venv", "__pycache__", ".pytest_cache", ".mypy_cache", ".git" };
|
||||
|
||||
private static void CopyDirectoryRecursive(string sourceDir, string destinationDir)
|
||||
{
|
||||
Directory.CreateDirectory(destinationDir);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,8 @@
|
|||
fileFormatVersion: 2
|
||||
guid: b104663d2f6c648e1b99633082385db2
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
using UnityEngine;
|
||||
using UnityEditor.AssetImporters;
|
||||
using System.IO;
|
||||
|
||||
namespace MCPForUnity.Editor.Importers
|
||||
{
|
||||
/// <summary>
|
||||
/// Custom importer that allows Unity to recognize .py files as TextAssets.
|
||||
/// This enables Python files to be selected in the Inspector and used like any other text asset.
|
||||
/// </summary>
|
||||
[ScriptedImporter(1, "py")]
|
||||
public class PythonFileImporter : ScriptedImporter
|
||||
{
|
||||
public override void OnImportAsset(AssetImportContext ctx)
|
||||
{
|
||||
var textAsset = new TextAsset(File.ReadAllText(ctx.assetPath));
|
||||
ctx.AddObjectToAsset("main obj", textAsset);
|
||||
ctx.SetMainObject(textAsset);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
fileFormatVersion: 2
|
||||
guid: d68ef794590944f1ea7ee102c91887c7
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
@ -0,0 +1,60 @@
|
|||
namespace MCPForUnity.Editor.Services
|
||||
{
|
||||
/// <summary>
|
||||
/// Service for checking package updates and version information
|
||||
/// </summary>
|
||||
public interface IPackageUpdateService
|
||||
{
|
||||
/// <summary>
|
||||
/// Checks if a newer version of the package is available
|
||||
/// </summary>
|
||||
/// <param name="currentVersion">The current package version</param>
|
||||
/// <returns>Update check result containing availability and latest version info</returns>
|
||||
UpdateCheckResult CheckForUpdate(string currentVersion);
|
||||
|
||||
/// <summary>
|
||||
/// Compares two version strings to determine if the first is newer than the second
|
||||
/// </summary>
|
||||
/// <param name="version1">First version string</param>
|
||||
/// <param name="version2">Second version string</param>
|
||||
/// <returns>True if version1 is newer than version2</returns>
|
||||
bool IsNewerVersion(string version1, string version2);
|
||||
|
||||
/// <summary>
|
||||
/// Determines if the package was installed via Git or Asset Store
|
||||
/// </summary>
|
||||
/// <returns>True if installed via Git, false if Asset Store or unknown</returns>
|
||||
bool IsGitInstallation();
|
||||
|
||||
/// <summary>
|
||||
/// Clears the cached update check data, forcing a fresh check on next request
|
||||
/// </summary>
|
||||
void ClearCache();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of an update check operation
|
||||
/// </summary>
|
||||
public class UpdateCheckResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether an update is available
|
||||
/// </summary>
|
||||
public bool UpdateAvailable { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The latest version available (null if check failed or no update)
|
||||
/// </summary>
|
||||
public string LatestVersion { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether the check was successful (false if network error, etc.)
|
||||
/// </summary>
|
||||
public bool CheckSucceeded { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional message about the check result
|
||||
/// </summary>
|
||||
public string Message { get; set; }
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
fileFormatVersion: 2
|
||||
guid: e94ae28f193184e4fb5068f62f4f00c6
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
using MCPForUnity.Editor.Data;
|
||||
|
||||
namespace MCPForUnity.Editor.Services
|
||||
{
|
||||
public interface IPythonToolRegistryService
|
||||
{
|
||||
IEnumerable<PythonToolsAsset> GetAllRegistries();
|
||||
bool NeedsSync(PythonToolsAsset registry, TextAsset file);
|
||||
void RecordSync(PythonToolsAsset registry, TextAsset file);
|
||||
string ComputeHash(TextAsset file);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
fileFormatVersion: 2
|
||||
guid: a2487319df5cc47baa2c635b911038c5
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
using System.Collections.Generic;
|
||||
|
||||
namespace MCPForUnity.Editor.Services
|
||||
{
|
||||
public class ToolSyncResult
|
||||
{
|
||||
public int CopiedCount { get; set; }
|
||||
public int SkippedCount { get; set; }
|
||||
public int ErrorCount { get; set; }
|
||||
public List<string> Messages { get; set; } = new List<string>();
|
||||
public bool Success => ErrorCount == 0;
|
||||
}
|
||||
|
||||
public interface IToolSyncService
|
||||
{
|
||||
ToolSyncResult SyncProjectTools(string destToolsDir);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
fileFormatVersion: 2
|
||||
guid: b9627dbaa92d24783a9f20e42efcea18
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
@ -10,27 +10,18 @@ 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;
|
||||
private static IPackageUpdateService _packageUpdateService;
|
||||
|
||||
/// <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();
|
||||
public static IPackageUpdateService Updates => _packageUpdateService ??= new PackageUpdateService();
|
||||
|
||||
/// <summary>
|
||||
/// Registers a custom implementation for a service (useful for testing)
|
||||
|
|
@ -45,8 +36,14 @@ 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;
|
||||
else if (implementation is IPackageUpdateService pu)
|
||||
_packageUpdateService = pu;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -57,12 +54,18 @@ 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();
|
||||
(_packageUpdateService as IDisposable)?.Dispose();
|
||||
|
||||
_bridgeService = null;
|
||||
_clientService = null;
|
||||
_pathService = null;
|
||||
_pythonToolRegistryService = null;
|
||||
_testRunnerService = null;
|
||||
_toolSyncService = null;
|
||||
_packageUpdateService = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,161 @@
|
|||
using System;
|
||||
using System.Net;
|
||||
using MCPForUnity.Editor.Helpers;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using UnityEditor;
|
||||
|
||||
namespace MCPForUnity.Editor.Services
|
||||
{
|
||||
/// <summary>
|
||||
/// Service for checking package updates from GitHub
|
||||
/// </summary>
|
||||
public class PackageUpdateService : IPackageUpdateService
|
||||
{
|
||||
private const string LastCheckDateKey = "MCPForUnity.LastUpdateCheck";
|
||||
private const string CachedVersionKey = "MCPForUnity.LatestKnownVersion";
|
||||
private const string PackageJsonUrl = "https://raw.githubusercontent.com/CoplayDev/unity-mcp/main/MCPForUnity/package.json";
|
||||
|
||||
/// <inheritdoc/>
|
||||
public UpdateCheckResult CheckForUpdate(string currentVersion)
|
||||
{
|
||||
// Check cache first - only check once per day
|
||||
string lastCheckDate = EditorPrefs.GetString(LastCheckDateKey, "");
|
||||
string cachedLatestVersion = EditorPrefs.GetString(CachedVersionKey, "");
|
||||
|
||||
if (lastCheckDate == DateTime.Now.ToString("yyyy-MM-dd") && !string.IsNullOrEmpty(cachedLatestVersion))
|
||||
{
|
||||
return new UpdateCheckResult
|
||||
{
|
||||
CheckSucceeded = true,
|
||||
LatestVersion = cachedLatestVersion,
|
||||
UpdateAvailable = IsNewerVersion(cachedLatestVersion, currentVersion),
|
||||
Message = "Using cached version check"
|
||||
};
|
||||
}
|
||||
|
||||
// Don't check for Asset Store installations
|
||||
if (!IsGitInstallation())
|
||||
{
|
||||
return new UpdateCheckResult
|
||||
{
|
||||
CheckSucceeded = false,
|
||||
UpdateAvailable = false,
|
||||
Message = "Asset Store installations are updated via Unity Asset Store"
|
||||
};
|
||||
}
|
||||
|
||||
// Fetch latest version from GitHub
|
||||
string latestVersion = FetchLatestVersionFromGitHub();
|
||||
|
||||
if (!string.IsNullOrEmpty(latestVersion))
|
||||
{
|
||||
// Cache the result
|
||||
EditorPrefs.SetString(LastCheckDateKey, DateTime.Now.ToString("yyyy-MM-dd"));
|
||||
EditorPrefs.SetString(CachedVersionKey, latestVersion);
|
||||
|
||||
return new UpdateCheckResult
|
||||
{
|
||||
CheckSucceeded = true,
|
||||
LatestVersion = latestVersion,
|
||||
UpdateAvailable = IsNewerVersion(latestVersion, currentVersion),
|
||||
Message = "Successfully checked for updates"
|
||||
};
|
||||
}
|
||||
|
||||
return new UpdateCheckResult
|
||||
{
|
||||
CheckSucceeded = false,
|
||||
UpdateAvailable = false,
|
||||
Message = "Failed to check for updates (network issue or offline)"
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public bool IsNewerVersion(string version1, string version2)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Remove any "v" prefix
|
||||
version1 = version1.TrimStart('v', 'V');
|
||||
version2 = version2.TrimStart('v', 'V');
|
||||
|
||||
var version1Parts = version1.Split('.');
|
||||
var version2Parts = version2.Split('.');
|
||||
|
||||
for (int i = 0; i < Math.Min(version1Parts.Length, version2Parts.Length); i++)
|
||||
{
|
||||
if (int.TryParse(version1Parts[i], out int v1Num) &&
|
||||
int.TryParse(version2Parts[i], out int v2Num))
|
||||
{
|
||||
if (v1Num > v2Num) return true;
|
||||
if (v1Num < v2Num) return false;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public bool IsGitInstallation()
|
||||
{
|
||||
// Git packages are installed via Package Manager and have a package.json in Packages/
|
||||
// Asset Store packages are in Assets/
|
||||
string packageRoot = AssetPathUtility.GetMcpPackageRootPath();
|
||||
|
||||
if (string.IsNullOrEmpty(packageRoot))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// If the package is in Packages/ it's a PM install (likely Git)
|
||||
// If it's in Assets/ it's an Asset Store install
|
||||
return packageRoot.StartsWith("Packages/", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void ClearCache()
|
||||
{
|
||||
EditorPrefs.DeleteKey(LastCheckDateKey);
|
||||
EditorPrefs.DeleteKey(CachedVersionKey);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fetches the latest version from GitHub's main branch package.json
|
||||
/// </summary>
|
||||
private string FetchLatestVersionFromGitHub()
|
||||
{
|
||||
try
|
||||
{
|
||||
// GitHub API endpoint (Option 1 - has rate limits):
|
||||
// https://api.github.com/repos/CoplayDev/unity-mcp/releases/latest
|
||||
//
|
||||
// We use Option 2 (package.json directly) because:
|
||||
// - No API rate limits (GitHub serves raw files freely)
|
||||
// - Simpler - just parse JSON for version field
|
||||
// - More reliable - doesn't require releases to be published
|
||||
// - Direct source of truth from the main branch
|
||||
|
||||
using (var client = new WebClient())
|
||||
{
|
||||
client.Headers.Add("User-Agent", "Unity-MCPForUnity-UpdateChecker");
|
||||
string jsonContent = client.DownloadString(PackageJsonUrl);
|
||||
|
||||
var packageJson = JObject.Parse(jsonContent);
|
||||
string version = packageJson["version"]?.ToString();
|
||||
|
||||
return string.IsNullOrEmpty(version) ? null : version;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Silent fail - don't interrupt the user if network is unavailable
|
||||
McpLog.Info($"Update check failed (this is normal if offline): {ex.Message}");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 7c3c2304b14e9485ca54182fad73b035
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Security.Cryptography;
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
using MCPForUnity.Editor.Data;
|
||||
|
||||
namespace MCPForUnity.Editor.Services
|
||||
{
|
||||
public class PythonToolRegistryService : IPythonToolRegistryService
|
||||
{
|
||||
public IEnumerable<PythonToolsAsset> GetAllRegistries()
|
||||
{
|
||||
// Find all PythonToolsAsset instances in the project
|
||||
string[] guids = AssetDatabase.FindAssets("t:PythonToolsAsset");
|
||||
foreach (string guid in guids)
|
||||
{
|
||||
string path = AssetDatabase.GUIDToAssetPath(guid);
|
||||
var asset = AssetDatabase.LoadAssetAtPath<PythonToolsAsset>(path);
|
||||
if (asset != null)
|
||||
yield return asset;
|
||||
}
|
||||
}
|
||||
|
||||
public bool NeedsSync(PythonToolsAsset registry, TextAsset file)
|
||||
{
|
||||
if (!registry.useContentHashing) return true;
|
||||
|
||||
string currentHash = ComputeHash(file);
|
||||
return registry.NeedsSync(file, currentHash);
|
||||
}
|
||||
|
||||
public void RecordSync(PythonToolsAsset registry, TextAsset file)
|
||||
{
|
||||
string hash = ComputeHash(file);
|
||||
registry.RecordSync(file, hash);
|
||||
EditorUtility.SetDirty(registry);
|
||||
}
|
||||
|
||||
public string ComputeHash(TextAsset file)
|
||||
{
|
||||
if (file == null || string.IsNullOrEmpty(file.text))
|
||||
return string.Empty;
|
||||
|
||||
using (var sha256 = SHA256.Create())
|
||||
{
|
||||
byte[] bytes = System.Text.Encoding.UTF8.GetBytes(file.text);
|
||||
byte[] hash = sha256.ComputeHash(bytes);
|
||||
return BitConverter.ToString(hash).Replace("-", "").ToLower();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 2da2869749c764f16a45e010eefbd679
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
@ -0,0 +1,134 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using MCPForUnity.Editor.Helpers;
|
||||
using UnityEditor;
|
||||
|
||||
namespace MCPForUnity.Editor.Services
|
||||
{
|
||||
public class ToolSyncService : IToolSyncService
|
||||
{
|
||||
private readonly IPythonToolRegistryService _registryService;
|
||||
|
||||
public ToolSyncService(IPythonToolRegistryService registryService = null)
|
||||
{
|
||||
_registryService = registryService ?? MCPServiceLocator.PythonToolRegistry;
|
||||
}
|
||||
|
||||
public ToolSyncResult SyncProjectTools(string destToolsDir)
|
||||
{
|
||||
var result = new ToolSyncResult();
|
||||
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(destToolsDir);
|
||||
|
||||
// Get all PythonToolsAsset instances in the project
|
||||
var registries = _registryService.GetAllRegistries().ToList();
|
||||
|
||||
if (!registries.Any())
|
||||
{
|
||||
McpLog.Info("No PythonToolsAsset found. Create one via Assets > Create > MCP For Unity > Python Tools");
|
||||
return result;
|
||||
}
|
||||
|
||||
var syncedFiles = new HashSet<string>();
|
||||
|
||||
// Batch all asset modifications together to minimize reimports
|
||||
AssetDatabase.StartAssetEditing();
|
||||
try
|
||||
{
|
||||
foreach (var registry in registries)
|
||||
{
|
||||
foreach (var file in registry.GetValidFiles())
|
||||
{
|
||||
try
|
||||
{
|
||||
// Check if needs syncing (hash-based or always)
|
||||
if (_registryService.NeedsSync(registry, file))
|
||||
{
|
||||
string destPath = Path.Combine(destToolsDir, file.name + ".py");
|
||||
|
||||
// Write the Python file content
|
||||
File.WriteAllText(destPath, file.text);
|
||||
|
||||
// Record sync
|
||||
_registryService.RecordSync(registry, file);
|
||||
|
||||
result.CopiedCount++;
|
||||
syncedFiles.Add(destPath);
|
||||
McpLog.Info($"Synced Python tool: {file.name}.py");
|
||||
}
|
||||
else
|
||||
{
|
||||
string destPath = Path.Combine(destToolsDir, file.name + ".py");
|
||||
syncedFiles.Add(destPath);
|
||||
result.SkippedCount++;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
result.ErrorCount++;
|
||||
result.Messages.Add($"Failed to sync {file.name}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
// Cleanup stale states in registry
|
||||
registry.CleanupStaleStates();
|
||||
EditorUtility.SetDirty(registry);
|
||||
}
|
||||
|
||||
// Cleanup stale Python files in destination
|
||||
CleanupStaleFiles(destToolsDir, syncedFiles);
|
||||
}
|
||||
finally
|
||||
{
|
||||
// End batch editing - this triggers a single asset refresh
|
||||
AssetDatabase.StopAssetEditing();
|
||||
}
|
||||
|
||||
// Save all modified registries
|
||||
AssetDatabase.SaveAssets();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
result.ErrorCount++;
|
||||
result.Messages.Add($"Sync failed: {ex.Message}");
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private void CleanupStaleFiles(string destToolsDir, HashSet<string> currentFiles)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!Directory.Exists(destToolsDir)) return;
|
||||
|
||||
// Find all .py files in destination that aren't in our current set
|
||||
var existingFiles = Directory.GetFiles(destToolsDir, "*.py");
|
||||
|
||||
foreach (var file in existingFiles)
|
||||
{
|
||||
if (!currentFiles.Contains(file))
|
||||
{
|
||||
try
|
||||
{
|
||||
File.Delete(file);
|
||||
McpLog.Info($"Cleaned up stale tool: {Path.GetFileName(file)}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
McpLog.Warn($"Failed to cleanup {file}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
McpLog.Warn($"Failed to cleanup stale files: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 9ad084cf3b6c04174b9202bf63137bae
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
@ -242,7 +242,7 @@ namespace MCPForUnity.Editor.Windows
|
|||
private void InitializeUI()
|
||||
{
|
||||
// Settings Section
|
||||
versionLabel.text = AssetPathUtility.GetPackageVersion();
|
||||
UpdateVersionLabel();
|
||||
debugLogsToggle.value = EditorPrefs.GetBool("MCPForUnity.DebugLogs", false);
|
||||
|
||||
validationLevelField.Init(ValidationLevel.Standard);
|
||||
|
|
@ -833,5 +833,28 @@ namespace MCPForUnity.Editor.Windows
|
|||
EditorGUIUtility.systemCopyBuffer = configJsonField.value;
|
||||
McpLog.Info("Configuration copied to clipboard");
|
||||
}
|
||||
|
||||
private void UpdateVersionLabel()
|
||||
{
|
||||
string currentVersion = AssetPathUtility.GetPackageVersion();
|
||||
versionLabel.text = $"v{currentVersion}";
|
||||
|
||||
// Check for updates using the service
|
||||
var updateCheck = MCPServiceLocator.Updates.CheckForUpdate(currentVersion);
|
||||
|
||||
if (updateCheck.UpdateAvailable && !string.IsNullOrEmpty(updateCheck.LatestVersion))
|
||||
{
|
||||
// Update available - enhance the label
|
||||
versionLabel.text = $"\u2191 v{currentVersion} (Update available: v{updateCheck.LatestVersion})";
|
||||
versionLabel.style.color = new Color(1f, 0.7f, 0f); // Orange
|
||||
versionLabel.tooltip = $"Version {updateCheck.LatestVersion} is available. Update via Package Manager.\n\nGit URL: https://github.com/CoplayDev/unity-mcp.git?path=/MCPForUnity";
|
||||
}
|
||||
else
|
||||
{
|
||||
versionLabel.style.color = StyleKeyword.Null; // Default color
|
||||
versionLabel.tooltip = $"Current version: {currentVersion}";
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,55 @@
|
|||
"""
|
||||
Shared module discovery utilities for auto-registering tools and resources.
|
||||
"""
|
||||
import importlib
|
||||
import logging
|
||||
from pathlib import Path
|
||||
import pkgutil
|
||||
from typing import Generator
|
||||
|
||||
logger = logging.getLogger("mcp-for-unity-server")
|
||||
|
||||
|
||||
def discover_modules(base_dir: Path, package_name: str) -> Generator[str, None, None]:
|
||||
"""
|
||||
Discover and import all Python modules in a directory and its subdirectories.
|
||||
|
||||
Args:
|
||||
base_dir: The base directory to search for modules
|
||||
package_name: The package name to use for relative imports (e.g., 'tools' or 'resources')
|
||||
|
||||
Yields:
|
||||
Full module names that were successfully imported
|
||||
"""
|
||||
# Discover modules in the top level
|
||||
for _, module_name, _ in pkgutil.iter_modules([str(base_dir)]):
|
||||
# Skip private modules and __init__
|
||||
if module_name.startswith('_'):
|
||||
continue
|
||||
|
||||
try:
|
||||
full_module_name = f'.{module_name}'
|
||||
importlib.import_module(full_module_name, package_name)
|
||||
yield full_module_name
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to import module {module_name}: {e}")
|
||||
|
||||
# Discover modules in subdirectories (one level deep)
|
||||
for subdir in base_dir.iterdir():
|
||||
if not subdir.is_dir() or subdir.name.startswith('_') or subdir.name.startswith('.'):
|
||||
continue
|
||||
|
||||
# Check if subdirectory contains Python modules
|
||||
for _, module_name, _ in pkgutil.iter_modules([str(subdir)]):
|
||||
# Skip private modules and __init__
|
||||
if module_name.startswith('_'):
|
||||
continue
|
||||
|
||||
try:
|
||||
# Import as package.subdirname.modulename
|
||||
full_module_name = f'.{subdir.name}.{module_name}'
|
||||
importlib.import_module(full_module_name, package_name)
|
||||
yield full_module_name
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Failed to import module {subdir.name}.{module_name}: {e}")
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
[project]
|
||||
name = "MCPForUnityServer"
|
||||
version = "6.1.0"
|
||||
version = "6.2.0"
|
||||
description = "MCP for Unity Server: A Unity package for Unity Editor integration via the Model Context Protocol (MCP)."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
|
|
|
|||
|
|
@ -1,15 +1,14 @@
|
|||
"""
|
||||
MCP Resources package - Auto-discovers and registers all resources in this directory.
|
||||
"""
|
||||
import importlib
|
||||
import logging
|
||||
from pathlib import Path
|
||||
import pkgutil
|
||||
|
||||
from mcp.server.fastmcp import FastMCP
|
||||
from telemetry_decorator import telemetry_resource
|
||||
|
||||
from registry import get_registered_resources
|
||||
from module_discovery import discover_modules
|
||||
|
||||
logger = logging.getLogger("mcp-for-unity-server")
|
||||
|
||||
|
|
@ -21,23 +20,15 @@ def register_all_resources(mcp: FastMCP):
|
|||
"""
|
||||
Auto-discover and register all resources in the resources/ directory.
|
||||
|
||||
Any .py file in this directory with @mcp_for_unity_resource decorated
|
||||
Any .py file in this directory or subdirectories with @mcp_for_unity_resource decorated
|
||||
functions will be automatically registered.
|
||||
"""
|
||||
logger.info("Auto-discovering MCP for Unity Server resources...")
|
||||
# Dynamic import of all modules in this directory
|
||||
resources_dir = Path(__file__).parent
|
||||
|
||||
for _, module_name, _ in pkgutil.iter_modules([str(resources_dir)]):
|
||||
# Skip private modules and __init__
|
||||
if module_name.startswith('_'):
|
||||
continue
|
||||
|
||||
try:
|
||||
importlib.import_module(f'.{module_name}', __package__)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Failed to import resource module {module_name}: {e}")
|
||||
# Discover and import all modules
|
||||
list(discover_modules(resources_dir, __package__))
|
||||
|
||||
resources = get_registered_resources()
|
||||
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
6.1.0
|
||||
6.2.0
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "com.coplaydev.unity-mcp",
|
||||
"version": "6.1.0",
|
||||
"version": "6.2.0",
|
||||
"displayName": "MCP for Unity",
|
||||
"description": "A bridge that connects AI assistants to Unity via the MCP (Model Context Protocol). Allows AI clients like Claude Code, Cursor, and VSCode to directly control your Unity Editor for enhanced development workflows.\n\nFeatures automated setup wizard, cross-platform support, and seamless integration with popular AI development tools.\n\nJoin Our Discord: https://discord.gg/y4p8KfzrN4",
|
||||
"unity": "2021.3",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,8 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 7ceb57590b405440da51ee3ec8c7daa5
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
@ -0,0 +1,180 @@
|
|||
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_KeepsStatesForCurrentFiles()
|
||||
{
|
||||
var file1 = new TextAsset("print('test1')");
|
||||
|
||||
_asset.pythonFiles.Add(file1);
|
||||
_asset.RecordSync(file1, "hash1");
|
||||
|
||||
_asset.CleanupStaleStates();
|
||||
|
||||
Assert.AreEqual(1, _asset.fileStates.Count, "Should keep state for current file");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void CleanupStaleStates_HandlesEmptyFilesList()
|
||||
{
|
||||
// Add some states without corresponding files
|
||||
_asset.fileStates.Add(new PythonFileState
|
||||
{
|
||||
assetGuid = "fake_guid_1",
|
||||
contentHash = "hash1",
|
||||
fileName = "test1.py",
|
||||
lastSyncTime = DateTime.UtcNow
|
||||
});
|
||||
|
||||
_asset.CleanupStaleStates();
|
||||
|
||||
Assert.IsEmpty(_asset.fileStates, "Should remove all states when no files exist");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
fileFormatVersion: 2
|
||||
guid: c3d4e5f678901234567890123456abcd
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
fileFormatVersion: 2
|
||||
guid: a7b66499ec8924852a539d5cc4378c0d
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
@ -0,0 +1,295 @@
|
|||
using System;
|
||||
using NUnit.Framework;
|
||||
using UnityEditor;
|
||||
using MCPForUnity.Editor.Services;
|
||||
|
||||
namespace MCPForUnityTests.Editor.Services
|
||||
{
|
||||
public class PackageUpdateServiceTests
|
||||
{
|
||||
private PackageUpdateService _service;
|
||||
private const string TestLastCheckDateKey = "MCPForUnity.LastUpdateCheck";
|
||||
private const string TestCachedVersionKey = "MCPForUnity.LatestKnownVersion";
|
||||
|
||||
[SetUp]
|
||||
public void SetUp()
|
||||
{
|
||||
_service = new PackageUpdateService();
|
||||
|
||||
// Clean up any existing test data
|
||||
CleanupEditorPrefs();
|
||||
}
|
||||
|
||||
[TearDown]
|
||||
public void TearDown()
|
||||
{
|
||||
// Clean up test data
|
||||
CleanupEditorPrefs();
|
||||
}
|
||||
|
||||
private void CleanupEditorPrefs()
|
||||
{
|
||||
if (EditorPrefs.HasKey(TestLastCheckDateKey))
|
||||
{
|
||||
EditorPrefs.DeleteKey(TestLastCheckDateKey);
|
||||
}
|
||||
if (EditorPrefs.HasKey(TestCachedVersionKey))
|
||||
{
|
||||
EditorPrefs.DeleteKey(TestCachedVersionKey);
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void IsNewerVersion_ReturnsTrue_WhenMajorVersionIsNewer()
|
||||
{
|
||||
bool result = _service.IsNewerVersion("2.0.0", "1.0.0");
|
||||
Assert.IsTrue(result, "2.0.0 should be newer than 1.0.0");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void IsNewerVersion_ReturnsTrue_WhenMinorVersionIsNewer()
|
||||
{
|
||||
bool result = _service.IsNewerVersion("1.2.0", "1.1.0");
|
||||
Assert.IsTrue(result, "1.2.0 should be newer than 1.1.0");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void IsNewerVersion_ReturnsTrue_WhenPatchVersionIsNewer()
|
||||
{
|
||||
bool result = _service.IsNewerVersion("1.0.2", "1.0.1");
|
||||
Assert.IsTrue(result, "1.0.2 should be newer than 1.0.1");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void IsNewerVersion_ReturnsFalse_WhenVersionsAreEqual()
|
||||
{
|
||||
bool result = _service.IsNewerVersion("1.0.0", "1.0.0");
|
||||
Assert.IsFalse(result, "Same versions should return false");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void IsNewerVersion_ReturnsFalse_WhenVersionIsOlder()
|
||||
{
|
||||
bool result = _service.IsNewerVersion("1.0.0", "2.0.0");
|
||||
Assert.IsFalse(result, "1.0.0 should not be newer than 2.0.0");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void IsNewerVersion_HandlesVersionPrefix_v()
|
||||
{
|
||||
bool result = _service.IsNewerVersion("v2.0.0", "v1.0.0");
|
||||
Assert.IsTrue(result, "Should handle 'v' prefix correctly");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void IsNewerVersion_HandlesVersionPrefix_V()
|
||||
{
|
||||
bool result = _service.IsNewerVersion("V2.0.0", "V1.0.0");
|
||||
Assert.IsTrue(result, "Should handle 'V' prefix correctly");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void IsNewerVersion_HandlesMixedPrefixes()
|
||||
{
|
||||
bool result = _service.IsNewerVersion("v2.0.0", "1.0.0");
|
||||
Assert.IsTrue(result, "Should handle mixed prefixes correctly");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void IsNewerVersion_ComparesCorrectly_WhenMajorDiffers()
|
||||
{
|
||||
bool result1 = _service.IsNewerVersion("10.0.0", "9.0.0");
|
||||
bool result2 = _service.IsNewerVersion("2.0.0", "10.0.0");
|
||||
|
||||
Assert.IsTrue(result1, "10.0.0 should be newer than 9.0.0");
|
||||
Assert.IsFalse(result2, "2.0.0 should not be newer than 10.0.0");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void IsNewerVersion_ReturnsFalse_OnInvalidVersionFormat()
|
||||
{
|
||||
// Service should handle errors gracefully
|
||||
bool result = _service.IsNewerVersion("invalid", "1.0.0");
|
||||
Assert.IsFalse(result, "Should return false for invalid version format");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void CheckForUpdate_ReturnsCachedVersion_WhenCacheIsValid()
|
||||
{
|
||||
// Arrange: Set up valid cache
|
||||
string today = DateTime.Now.ToString("yyyy-MM-dd");
|
||||
string cachedVersion = "5.5.5";
|
||||
EditorPrefs.SetString(TestLastCheckDateKey, today);
|
||||
EditorPrefs.SetString(TestCachedVersionKey, cachedVersion);
|
||||
|
||||
// Act
|
||||
var result = _service.CheckForUpdate("5.0.0");
|
||||
|
||||
// Assert
|
||||
Assert.IsTrue(result.CheckSucceeded, "Check should succeed with valid cache");
|
||||
Assert.AreEqual(cachedVersion, result.LatestVersion, "Should return cached version");
|
||||
Assert.IsTrue(result.UpdateAvailable, "Update should be available (5.5.5 > 5.0.0)");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void CheckForUpdate_DetectsUpdateAvailable_WhenNewerVersionCached()
|
||||
{
|
||||
// Arrange
|
||||
string today = DateTime.Now.ToString("yyyy-MM-dd");
|
||||
EditorPrefs.SetString(TestLastCheckDateKey, today);
|
||||
EditorPrefs.SetString(TestCachedVersionKey, "6.0.0");
|
||||
|
||||
// Act
|
||||
var result = _service.CheckForUpdate("5.0.0");
|
||||
|
||||
// Assert
|
||||
Assert.IsTrue(result.UpdateAvailable, "Should detect update is available");
|
||||
Assert.AreEqual("6.0.0", result.LatestVersion);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void CheckForUpdate_DetectsNoUpdate_WhenVersionsMatch()
|
||||
{
|
||||
// Arrange
|
||||
string today = DateTime.Now.ToString("yyyy-MM-dd");
|
||||
EditorPrefs.SetString(TestLastCheckDateKey, today);
|
||||
EditorPrefs.SetString(TestCachedVersionKey, "5.0.0");
|
||||
|
||||
// Act
|
||||
var result = _service.CheckForUpdate("5.0.0");
|
||||
|
||||
// Assert
|
||||
Assert.IsFalse(result.UpdateAvailable, "Should detect no update needed");
|
||||
Assert.AreEqual("5.0.0", result.LatestVersion);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void CheckForUpdate_DetectsNoUpdate_WhenCurrentVersionIsNewer()
|
||||
{
|
||||
// Arrange
|
||||
string today = DateTime.Now.ToString("yyyy-MM-dd");
|
||||
EditorPrefs.SetString(TestLastCheckDateKey, today);
|
||||
EditorPrefs.SetString(TestCachedVersionKey, "5.0.0");
|
||||
|
||||
// Act
|
||||
var result = _service.CheckForUpdate("6.0.0");
|
||||
|
||||
// Assert
|
||||
Assert.IsFalse(result.UpdateAvailable, "Should detect no update when current is newer");
|
||||
Assert.AreEqual("5.0.0", result.LatestVersion);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void CheckForUpdate_IgnoresExpiredCache_AndAttemptsFreshFetch()
|
||||
{
|
||||
// Arrange: Set cache from yesterday (expired)
|
||||
string yesterday = DateTime.Now.AddDays(-1).ToString("yyyy-MM-dd");
|
||||
string cachedVersion = "4.0.0";
|
||||
EditorPrefs.SetString(TestLastCheckDateKey, yesterday);
|
||||
EditorPrefs.SetString(TestCachedVersionKey, cachedVersion);
|
||||
|
||||
// Act
|
||||
var result = _service.CheckForUpdate("5.0.0");
|
||||
|
||||
// Assert
|
||||
Assert.IsNotNull(result, "Should return a result");
|
||||
|
||||
// If the check succeeded (network available), verify it didn't use the expired cache
|
||||
if (result.CheckSucceeded)
|
||||
{
|
||||
Assert.AreNotEqual(cachedVersion, result.LatestVersion,
|
||||
"Should not return expired cached version when fresh fetch succeeds");
|
||||
Assert.IsNotNull(result.LatestVersion, "Should have fetched a new version");
|
||||
}
|
||||
else
|
||||
{
|
||||
// If offline, check should fail (not succeed with cached data)
|
||||
Assert.IsFalse(result.UpdateAvailable,
|
||||
"Should not report update available when fetch fails and cache is expired");
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void CheckForUpdate_ReturnsAssetStoreMessage_ForNonGitInstallations()
|
||||
{
|
||||
// Note: This test verifies the service behavior when IsGitInstallation() returns false.
|
||||
// Since the actual result depends on package installation method, we create a mock
|
||||
// implementation to test this specific code path.
|
||||
|
||||
var mockService = new MockAssetStorePackageUpdateService();
|
||||
|
||||
// Act
|
||||
var result = mockService.CheckForUpdate("5.0.0");
|
||||
|
||||
// Assert
|
||||
Assert.IsFalse(result.CheckSucceeded, "Check should not succeed for Asset Store installs");
|
||||
Assert.IsFalse(result.UpdateAvailable, "No update should be reported for Asset Store installs");
|
||||
Assert.AreEqual("Asset Store installations are updated via Unity Asset Store", result.Message,
|
||||
"Should return Asset Store update message");
|
||||
Assert.IsNull(result.LatestVersion, "Latest version should be null for Asset Store installs");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ClearCache_RemovesAllCachedData()
|
||||
{
|
||||
// Arrange: Set up cache
|
||||
EditorPrefs.SetString(TestLastCheckDateKey, DateTime.Now.ToString("yyyy-MM-dd"));
|
||||
EditorPrefs.SetString(TestCachedVersionKey, "5.0.0");
|
||||
|
||||
// Verify cache exists
|
||||
Assert.IsTrue(EditorPrefs.HasKey(TestLastCheckDateKey), "Cache should exist before clearing");
|
||||
Assert.IsTrue(EditorPrefs.HasKey(TestCachedVersionKey), "Cache should exist before clearing");
|
||||
|
||||
// Act
|
||||
_service.ClearCache();
|
||||
|
||||
// Assert
|
||||
Assert.IsFalse(EditorPrefs.HasKey(TestLastCheckDateKey), "Date cache should be cleared");
|
||||
Assert.IsFalse(EditorPrefs.HasKey(TestCachedVersionKey), "Version cache should be cleared");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ClearCache_DoesNotThrow_WhenNoCacheExists()
|
||||
{
|
||||
// Ensure no cache exists
|
||||
CleanupEditorPrefs();
|
||||
|
||||
// Act & Assert - should not throw
|
||||
Assert.DoesNotThrow(() => _service.ClearCache(), "Should not throw when clearing non-existent cache");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Mock implementation of IPackageUpdateService that simulates Asset Store installation behavior
|
||||
/// </summary>
|
||||
internal class MockAssetStorePackageUpdateService : IPackageUpdateService
|
||||
{
|
||||
public UpdateCheckResult CheckForUpdate(string currentVersion)
|
||||
{
|
||||
// Simulate Asset Store installation (IsGitInstallation returns false)
|
||||
return new UpdateCheckResult
|
||||
{
|
||||
CheckSucceeded = false,
|
||||
UpdateAvailable = false,
|
||||
Message = "Asset Store installations are updated via Unity Asset Store"
|
||||
};
|
||||
}
|
||||
|
||||
public bool IsNewerVersion(string version1, string version2)
|
||||
{
|
||||
// Not used in the Asset Store test, but required by interface
|
||||
return false;
|
||||
}
|
||||
|
||||
public bool IsGitInstallation()
|
||||
{
|
||||
// Simulate non-Git installation (Asset Store)
|
||||
return false;
|
||||
}
|
||||
|
||||
public void ClearCache()
|
||||
{
|
||||
// Not used in the Asset Store test, but required by interface
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 676c3849f71a84b17b14d813774d3f74
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
@ -0,0 +1,135 @@
|
|||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using NUnit.Framework;
|
||||
using UnityEngine;
|
||||
using MCPForUnity.Editor.Data;
|
||||
using MCPForUnity.Editor.Services;
|
||||
|
||||
namespace MCPForUnityTests.Editor.Services
|
||||
{
|
||||
public class PythonToolRegistryServiceTests
|
||||
{
|
||||
private PythonToolRegistryService _service;
|
||||
|
||||
[SetUp]
|
||||
public void SetUp()
|
||||
{
|
||||
_service = new PythonToolRegistryService();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void GetAllRegistries_ReturnsEmptyList_WhenNoPythonToolsAssetsExist()
|
||||
{
|
||||
var registries = _service.GetAllRegistries().ToList();
|
||||
|
||||
// Note: This might find assets in the test project, so we just verify it doesn't throw
|
||||
Assert.IsNotNull(registries, "Should return a non-null list");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void NeedsSync_ReturnsTrue_WhenHashingDisabled()
|
||||
{
|
||||
var asset = ScriptableObject.CreateInstance<PythonToolsAsset>();
|
||||
asset.useContentHashing = false;
|
||||
|
||||
var textAsset = new TextAsset("print('test')");
|
||||
|
||||
bool needsSync = _service.NeedsSync(asset, textAsset);
|
||||
|
||||
Assert.IsTrue(needsSync, "Should always need sync when hashing is disabled");
|
||||
|
||||
Object.DestroyImmediate(asset);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void NeedsSync_ReturnsTrue_WhenFileNotPreviouslySynced()
|
||||
{
|
||||
var asset = ScriptableObject.CreateInstance<PythonToolsAsset>();
|
||||
asset.useContentHashing = true;
|
||||
|
||||
var textAsset = new TextAsset("print('test')");
|
||||
|
||||
bool needsSync = _service.NeedsSync(asset, textAsset);
|
||||
|
||||
Assert.IsTrue(needsSync, "Should need sync for new file");
|
||||
|
||||
Object.DestroyImmediate(asset);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void NeedsSync_ReturnsFalse_WhenHashMatches()
|
||||
{
|
||||
var asset = ScriptableObject.CreateInstance<PythonToolsAsset>();
|
||||
asset.useContentHashing = true;
|
||||
|
||||
var textAsset = new TextAsset("print('test')");
|
||||
|
||||
// First sync
|
||||
_service.RecordSync(asset, textAsset);
|
||||
|
||||
// Check if needs sync again
|
||||
bool needsSync = _service.NeedsSync(asset, textAsset);
|
||||
|
||||
Assert.IsFalse(needsSync, "Should not need sync when hash matches");
|
||||
|
||||
Object.DestroyImmediate(asset);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void RecordSync_StoresFileState()
|
||||
{
|
||||
var asset = ScriptableObject.CreateInstance<PythonToolsAsset>();
|
||||
var textAsset = new TextAsset("print('test')");
|
||||
|
||||
_service.RecordSync(asset, textAsset);
|
||||
|
||||
Assert.AreEqual(1, asset.fileStates.Count, "Should have one file state recorded");
|
||||
Assert.IsNotNull(asset.fileStates[0].contentHash, "Hash should be stored");
|
||||
Assert.IsNotNull(asset.fileStates[0].assetGuid, "GUID should be stored");
|
||||
|
||||
Object.DestroyImmediate(asset);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void RecordSync_UpdatesExistingState_WhenFileAlreadyRecorded()
|
||||
{
|
||||
var asset = ScriptableObject.CreateInstance<PythonToolsAsset>();
|
||||
var textAsset = new TextAsset("print('test')");
|
||||
|
||||
// Record twice
|
||||
_service.RecordSync(asset, textAsset);
|
||||
var firstHash = asset.fileStates[0].contentHash;
|
||||
|
||||
_service.RecordSync(asset, textAsset);
|
||||
|
||||
Assert.AreEqual(1, asset.fileStates.Count, "Should still have only one state");
|
||||
Assert.AreEqual(firstHash, asset.fileStates[0].contentHash, "Hash should remain the same");
|
||||
|
||||
Object.DestroyImmediate(asset);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ComputeHash_ReturnsSameHash_ForSameContent()
|
||||
{
|
||||
var textAsset1 = new TextAsset("print('hello')");
|
||||
var textAsset2 = new TextAsset("print('hello')");
|
||||
|
||||
string hash1 = _service.ComputeHash(textAsset1);
|
||||
string hash2 = _service.ComputeHash(textAsset2);
|
||||
|
||||
Assert.AreEqual(hash1, hash2, "Same content should produce same hash");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ComputeHash_ReturnsDifferentHash_ForDifferentContent()
|
||||
{
|
||||
var textAsset1 = new TextAsset("print('hello')");
|
||||
var textAsset2 = new TextAsset("print('world')");
|
||||
|
||||
string hash1 = _service.ComputeHash(textAsset1);
|
||||
string hash2 = _service.ComputeHash(textAsset2);
|
||||
|
||||
Assert.AreNotEqual(hash1, hash2, "Different content should produce different hash");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
fileFormatVersion: 2
|
||||
guid: fb9be9b99beba4112a7e3182df1d1d10
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
@ -0,0 +1,72 @@
|
|||
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_ReportsCorrectCounts()
|
||||
{
|
||||
var result = _service.SyncProjectTools(_testToolsDir);
|
||||
|
||||
Assert.IsTrue(result.CopiedCount >= 0, "Copied count should be non-negative");
|
||||
Assert.IsTrue(result.SkippedCount >= 0, "Skipped count should be non-negative");
|
||||
Assert.IsTrue(result.ErrorCount >= 0, "Error count should be non-negative");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
fileFormatVersion: 2
|
||||
guid: b2c3d4e5f67890123456789012345abc
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
@ -71,6 +71,9 @@ namespace MCPForUnity.Editor.Windows
|
|||
// Load validation level setting
|
||||
LoadValidationLevelSetting();
|
||||
|
||||
// Show one-time migration dialog
|
||||
ShowMigrationDialogIfNeeded();
|
||||
|
||||
// First-run auto-setup only if Claude CLI is available
|
||||
if (autoRegisterEnabled && !string.IsNullOrEmpty(ExecPath.ResolveClaude()))
|
||||
{
|
||||
|
|
@ -170,6 +173,9 @@ namespace MCPForUnity.Editor.Windows
|
|||
{
|
||||
scrollPosition = EditorGUILayout.BeginScrollView(scrollPosition);
|
||||
|
||||
// Migration warning banner (non-dismissible)
|
||||
DrawMigrationWarningBanner();
|
||||
|
||||
// Header
|
||||
DrawHeader();
|
||||
|
||||
|
|
@ -1573,6 +1579,65 @@ namespace MCPForUnity.Editor.Windows
|
|||
}
|
||||
}
|
||||
|
||||
private void ShowMigrationDialogIfNeeded()
|
||||
{
|
||||
const string dialogShownKey = "MCPForUnity.LegacyMigrationDialogShown";
|
||||
if (EditorPrefs.GetBool(dialogShownKey, false))
|
||||
{
|
||||
return; // Already shown
|
||||
}
|
||||
|
||||
int result = EditorUtility.DisplayDialogComplex(
|
||||
"Migration Required",
|
||||
"This is the legacy UnityMcpBridge package.\n\n" +
|
||||
"Please migrate to the new MCPForUnity package to receive updates and support.\n\n" +
|
||||
"Migration takes just a few minutes.",
|
||||
"View Migration Guide",
|
||||
"Remind Me Later",
|
||||
"I'll Migrate Later"
|
||||
);
|
||||
|
||||
if (result == 0) // View Migration Guide
|
||||
{
|
||||
Application.OpenURL("https://github.com/CoplayDev/unity-mcp/blob/main/docs/v5_MIGRATION.md");
|
||||
EditorPrefs.SetBool(dialogShownKey, true);
|
||||
}
|
||||
else if (result == 2) // I'll Migrate Later
|
||||
{
|
||||
EditorPrefs.SetBool(dialogShownKey, true);
|
||||
}
|
||||
// result == 1 (Remind Me Later) - don't set the flag, show again next time
|
||||
}
|
||||
|
||||
private void DrawMigrationWarningBanner()
|
||||
{
|
||||
// Warning banner - not dismissible, always visible
|
||||
EditorGUILayout.Space(5);
|
||||
Rect bannerRect = EditorGUILayout.GetControlRect(false, 50);
|
||||
EditorGUI.DrawRect(bannerRect, new Color(1f, 0.6f, 0f, 0.3f)); // Orange background
|
||||
|
||||
GUIStyle warningStyle = new GUIStyle(EditorStyles.boldLabel)
|
||||
{
|
||||
fontSize = 13,
|
||||
alignment = TextAnchor.MiddleLeft,
|
||||
richText = true
|
||||
};
|
||||
|
||||
// Use Unicode warning triangle (same as used elsewhere in codebase at line 647, 652)
|
||||
string warningText = "\u26A0 <color=#FF8C00>LEGACY PACKAGE:</color> Please migrate to MCPForUnity for updates and support.";
|
||||
|
||||
Rect textRect = new Rect(bannerRect.x + 15, bannerRect.y + 8, bannerRect.width - 180, bannerRect.height - 16);
|
||||
GUI.Label(textRect, warningText, warningStyle);
|
||||
|
||||
// Button on the right
|
||||
Rect buttonRect = new Rect(bannerRect.xMax - 160, bannerRect.y + 10, 145, 30);
|
||||
if (GUI.Button(buttonRect, "View Migration Guide"))
|
||||
{
|
||||
Application.OpenURL("https://github.com/CoplayDev/unity-mcp/blob/main/docs/v5_MIGRATION.md");
|
||||
}
|
||||
EditorGUILayout.Space(5);
|
||||
}
|
||||
|
||||
private bool IsPythonDetected()
|
||||
{
|
||||
try
|
||||
|
|
|
|||
|
|
@ -1,19 +1,31 @@
|
|||
# Adding Custom Tools to MCP for Unity
|
||||
|
||||
MCP for Unity now supports auto-discovery of custom tools using decorators (Python) and attributes (C#). This allows you to easily extend the MCP server with your own tools without modifying core files.
|
||||
MCP for Unity supports auto-discovery of custom tools using decorators (Python) and attributes (C#). This allows you to easily extend the MCP server with your own tools.
|
||||
|
||||
Be sure to review the developer README first:
|
||||
|
||||
| [English](README-DEV.md) | [简体中文](README-DEV-zh.md) |
|
||||
|---------------------------|------------------------------|
|
||||
|
||||
## Python Side (MCP Server)
|
||||
---
|
||||
|
||||
### Creating a Custom Tool
|
||||
# Part 1: How to Use (Quick Start Guide)
|
||||
|
||||
1. **Create a new Python file** in `MCPForUnity/UnityMcpServer~/src/tools/` (or any location that gets imported)
|
||||
This section shows you how to add custom tools to your Unity project.
|
||||
|
||||
2. **Use the `@mcp_for_unity_tool` decorator**:
|
||||
## Step 1: Create a PythonToolsAsset
|
||||
|
||||
First, create a ScriptableObject to manage your Python tools:
|
||||
|
||||
1. In Unity, right-click in the Project window
|
||||
2. Select **Assets > Create > MCP For Unity > Python Tools**
|
||||
3. Name it (e.g., `MyPythonTools`)
|
||||
|
||||

|
||||
|
||||
## Step 2: Create Your Python Tool File
|
||||
|
||||
Create a Python file **anywhere in your Unity project**. For example, `Assets/Editor/MyTools/my_custom_tool.py`:
|
||||
|
||||
```python
|
||||
from typing import Annotated, Any
|
||||
|
|
@ -44,39 +56,20 @@ async def my_custom_tool(
|
|||
return response if isinstance(response, dict) else {"success": False, "message": str(response)}
|
||||
```
|
||||
|
||||
3. **The tool is automatically registered!** The decorator:
|
||||
- Auto-generates the tool name from the function name (e.g., `my_custom_tool`)
|
||||
- Registers the tool with FastMCP during module import
|
||||
## Step 3: Add Python File to Asset
|
||||
|
||||
4. **Rebuild the server** in the MCP for Unity window (in the Unity Editor) to apply the changes.
|
||||
1. Select your `PythonToolsAsset` in the Project window
|
||||
2. In the Inspector, expand **Python Files**
|
||||
3. Drag your `.py` file into the list (or click **+** and select it)
|
||||
|
||||
### Decorator Options
|
||||

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