diff --git a/MCPForUnity/Editor/Helpers/McpLog.cs b/MCPForUnity/Editor/Helpers/McpLog.cs
index 85abdb79..8d31c556 100644
--- a/MCPForUnity/Editor/Helpers/McpLog.cs
+++ b/MCPForUnity/Editor/Helpers/McpLog.cs
@@ -5,7 +5,9 @@ namespace MCPForUnity.Editor.Helpers
{
internal static class McpLog
{
- private const string Prefix = "MCP-FOR-UNITY:";
+ private const string LogPrefix = "MCP-FOR-UNITY:";
+ private const string WarnPrefix = "MCP-FOR-UNITY:";
+ private const string ErrorPrefix = "MCP-FOR-UNITY:";
private static bool IsDebugEnabled()
{
@@ -15,17 +17,17 @@ private static bool IsDebugEnabled()
public static void Info(string message, bool always = true)
{
if (!always && !IsDebugEnabled()) return;
- Debug.Log($"{Prefix} {message}");
+ Debug.Log($"{LogPrefix} {message}");
}
public static void Warn(string message)
{
- Debug.LogWarning($"{Prefix} {message}");
+ Debug.LogWarning($"{WarnPrefix} {message}");
}
public static void Error(string message)
{
- Debug.LogError($"{Prefix} {message}");
+ Debug.LogError($"{ErrorPrefix} {message}");
}
}
}
diff --git a/MCPForUnity/Editor/MCPForUnityBridge.cs b/MCPForUnity/Editor/MCPForUnityBridge.cs
index dcc469b0..5fb9f694 100644
--- a/MCPForUnity/Editor/MCPForUnityBridge.cs
+++ b/MCPForUnity/Editor/MCPForUnityBridge.cs
@@ -14,11 +14,30 @@
using MCPForUnity.Editor.Helpers;
using MCPForUnity.Editor.Models;
using MCPForUnity.Editor.Tools;
-using MCPForUnity.Editor.Tools.MenuItems;
using MCPForUnity.Editor.Tools.Prefabs;
namespace MCPForUnity.Editor
{
+
+ ///
+ /// Outbound message structure for the writer thread
+ ///
+ class Outbound
+ {
+ public byte[] Payload;
+ public string Tag;
+ public int? ReqId;
+ }
+
+ ///
+ /// Queued command structure for main thread processing
+ ///
+ class QueuedCommand
+ {
+ public string CommandJson;
+ public TaskCompletionSource Tcs;
+ public bool IsExecuting;
+ }
[InitializeOnLoad]
public static partial class MCPForUnityBridge
{
@@ -28,13 +47,6 @@ public static partial class MCPForUnityBridge
private static readonly object startStopLock = new();
private static readonly object clientsLock = new();
private static readonly System.Collections.Generic.HashSet activeClients = new();
- // Single-writer outbox for framed responses
- private class Outbound
- {
- public byte[] Payload;
- public string Tag;
- public int? ReqId;
- }
private static readonly BlockingCollection _outbox = new(new ConcurrentQueue());
private static CancellationTokenSource cts;
private static Task listenerTask;
@@ -45,10 +57,7 @@ private class Outbound
private static double nextStartAt = 0.0f;
private static double nextHeartbeatAt = 0.0f;
private static int heartbeatSeq = 0;
- private static Dictionary<
- string,
- (string commandJson, TaskCompletionSource tcs)
- > commandQueue = new();
+ private static Dictionary commandQueue = new();
private static int mainThreadId;
private static int currentUnityPort = 6400; // Dynamic port, starts with default
private static bool isAutoConnectMode = false;
@@ -96,7 +105,7 @@ public static void StartAutoConnect()
}
catch (Exception ex)
{
- Debug.LogError($"Auto-connect failed: {ex.Message}");
+ McpLog.Error($"Auto-connect failed: {ex.Message}");
// Record telemetry for connection failure
TelemetryHelper.RecordBridgeConnection(false, ex.Message);
@@ -297,7 +306,7 @@ public static void Start()
{
if (IsDebugEnabled())
{
- Debug.Log($"MCP-FOR-UNITY: MCPForUnityBridge already running on port {currentUnityPort}");
+ McpLog.Info($"MCPForUnityBridge already running on port {currentUnityPort}");
}
return;
}
@@ -383,7 +392,7 @@ public static void Start()
isAutoConnectMode = false;
string platform = Application.platform.ToString();
string serverVer = ReadInstalledServerVersionSafe();
- Debug.Log($"MCP-FOR-UNITY: MCPForUnityBridge started on port {currentUnityPort}. (OS={platform}, server={serverVer})");
+ McpLog.Info($"MCPForUnityBridge started on port {currentUnityPort}. (OS={platform}, server={serverVer})");
// Start background listener with cooperative cancellation
cts = new CancellationTokenSource();
listenerTask = Task.Run(() => ListenerLoopAsync(cts.Token));
@@ -403,7 +412,7 @@ public static void Start()
}
catch (SocketException ex)
{
- Debug.LogError($"Failed to start TCP listener: {ex.Message}");
+ McpLog.Error($"Failed to start TCP listener: {ex.Message}");
}
}
}
@@ -437,7 +446,7 @@ public static void Stop()
}
catch (Exception ex)
{
- Debug.LogError($"Error stopping MCPForUnityBridge: {ex.Message}");
+ McpLog.Error($"Error stopping MCPForUnityBridge: {ex.Message}");
}
}
@@ -465,7 +474,7 @@ public static void Stop()
try { AssemblyReloadEvents.afterAssemblyReload -= OnAfterAssemblyReload; } catch { }
try { EditorApplication.quitting -= Stop; } catch { }
- if (IsDebugEnabled()) Debug.Log("MCP-FOR-UNITY: MCPForUnityBridge stopped.");
+ if (IsDebugEnabled()) McpLog.Info("MCPForUnityBridge stopped.");
}
private static async Task ListenerLoopAsync(CancellationToken token)
@@ -504,7 +513,7 @@ private static async Task ListenerLoopAsync(CancellationToken token)
{
if (isRunning && !token.IsCancellationRequested)
{
- if (IsDebugEnabled()) Debug.LogError($"Listener error: {ex.Message}");
+ if (IsDebugEnabled()) McpLog.Error($"Listener error: {ex.Message}");
}
}
}
@@ -524,7 +533,7 @@ private static async Task HandleClientAsync(TcpClient client, CancellationToken
if (IsDebugEnabled())
{
var ep = client.Client?.RemoteEndPoint?.ToString() ?? "unknown";
- Debug.Log($"UNITY-MCP: Client connected {ep}");
+ McpLog.Info($"Client connected {ep}");
}
}
catch { }
@@ -544,11 +553,11 @@ private static async Task HandleClientAsync(TcpClient client, CancellationToken
#else
await stream.WriteAsync(handshakeBytes, 0, handshakeBytes.Length, cts.Token).ConfigureAwait(false);
#endif
- if (IsDebugEnabled()) MCPForUnity.Editor.Helpers.McpLog.Info("Sent handshake FRAMING=1 (strict)", always: false);
+ if (IsDebugEnabled()) McpLog.Info("Sent handshake FRAMING=1 (strict)", always: false);
}
catch (Exception ex)
{
- if (IsDebugEnabled()) MCPForUnity.Editor.Helpers.McpLog.Warn($"Handshake failed: {ex.Message}");
+ if (IsDebugEnabled()) McpLog.Warn($"Handshake failed: {ex.Message}");
return; // abort this client
}
@@ -564,7 +573,7 @@ private static async Task HandleClientAsync(TcpClient client, CancellationToken
if (IsDebugEnabled())
{
var preview = commandText.Length > 120 ? commandText.Substring(0, 120) + "…" : commandText;
- MCPForUnity.Editor.Helpers.McpLog.Info($"recv framed: {preview}", always: false);
+ McpLog.Info($"recv framed: {preview}", always: false);
}
}
catch { }
@@ -585,7 +594,12 @@ private static async Task HandleClientAsync(TcpClient client, CancellationToken
lock (lockObj)
{
- commandQueue[commandId] = (commandText, tcs);
+ commandQueue[commandId] = new QueuedCommand
+ {
+ CommandJson = commandText,
+ Tcs = tcs,
+ IsExecuting = false
+ };
}
// Wait for the handler to produce a response, but do not block indefinitely
@@ -623,7 +637,7 @@ private static async Task HandleClientAsync(TcpClient client, CancellationToken
if (IsDebugEnabled())
{
- try { MCPForUnity.Editor.Helpers.McpLog.Info("[MCP] sending framed response", always: false); } catch { }
+ try { McpLog.Info("[MCP] sending framed response", always: false); } catch { }
}
// Crash-proof and self-reporting writer logs (direct write to this client's stream)
long seq = System.Threading.Interlocked.Increment(ref _ioSeq);
@@ -662,11 +676,11 @@ private static async Task HandleClientAsync(TcpClient client, CancellationToken
|| ex is System.IO.IOException;
if (isBenign)
{
- if (IsDebugEnabled()) MCPForUnity.Editor.Helpers.McpLog.Info($"Client handler: {msg}", always: false);
+ if (IsDebugEnabled()) McpLog.Info($"Client handler: {msg}", always: false);
}
else
{
- MCPForUnity.Editor.Helpers.McpLog.Error($"Client handler error: {msg}");
+ McpLog.Error($"Client handler error: {msg}");
}
break;
}
@@ -817,19 +831,25 @@ private static void ProcessCommands()
}
// Snapshot under lock, then process outside to reduce contention
- List<(string id, string text, TaskCompletionSource tcs)> work;
+ List<(string id, QueuedCommand command)> work;
lock (lockObj)
{
- work = commandQueue
- .Select(kvp => (kvp.Key, kvp.Value.commandJson, kvp.Value.tcs))
- .ToList();
+ work = new List<(string, QueuedCommand)>(commandQueue.Count);
+ foreach (var kvp in commandQueue)
+ {
+ var queued = kvp.Value;
+ if (queued.IsExecuting) continue;
+ queued.IsExecuting = true;
+ work.Add((kvp.Key, queued));
+ }
}
foreach (var item in work)
{
string id = item.id;
- string commandText = item.text;
- TaskCompletionSource tcs = item.tcs;
+ QueuedCommand queuedCommand = item.command;
+ string commandText = queuedCommand.CommandJson;
+ TaskCompletionSource tcs = queuedCommand.Tcs;
try
{
@@ -894,13 +914,41 @@ private static void ProcessCommands()
}
else
{
- string responseJson = ExecuteCommand(command);
- tcs.SetResult(responseJson);
+ // Use JObject for parameters as handlers expect this
+ JObject paramsObject = command.@params ?? new JObject();
+
+ // Execute command (may be sync or async)
+ object result = CommandRegistry.ExecuteCommand(command.type, paramsObject, tcs);
+
+ // If result is null, it means async execution - TCS will be completed by the awaited task
+ // In this case, DON'T remove from queue yet, DON'T complete TCS
+ if (result == null)
+ {
+ // Async command - the task continuation will complete the TCS
+ // Setup cleanup when TCS completes - schedule on next frame to avoid race conditions
+ string asyncCommandId = id;
+ _ = tcs.Task.ContinueWith(_ =>
+ {
+ // Use EditorApplication.delayCall to schedule cleanup on main thread, next frame
+ EditorApplication.delayCall += () =>
+ {
+ lock (lockObj)
+ {
+ commandQueue.Remove(asyncCommandId);
+ }
+ };
+ });
+ continue; // Skip the queue removal below
+ }
+
+ // Synchronous result - complete TCS now
+ var response = new { status = "success", result };
+ tcs.SetResult(JsonConvert.SerializeObject(response));
}
}
catch (Exception ex)
{
- Debug.LogError($"Error processing command: {ex.Message}\n{ex.StackTrace}");
+ McpLog.Error($"Error processing command: {ex.Message}\n{ex.StackTrace}");
var response = new
{
@@ -915,7 +963,7 @@ private static void ProcessCommands()
tcs.SetResult(responseJson);
}
- // Remove quickly under lock
+ // Remove from queue (only for sync commands - async ones skip with 'continue' above)
lock (lockObj) { commandQueue.Remove(id); }
}
}
@@ -1051,9 +1099,7 @@ private static string ExecuteCommand(Command command)
catch (Exception ex)
{
// Log the detailed error in Unity for debugging
- Debug.LogError(
- $"Error executing command '{command?.type ?? "Unknown"}': {ex.Message}\n{ex.StackTrace}"
- );
+ McpLog.Error($"Error executing command '{command?.type ?? "Unknown"}': {ex.Message}\n{ex.StackTrace}");
// Standard error response format
var response = new
@@ -1074,11 +1120,11 @@ private static object HandleManageScene(JObject paramsObject)
{
try
{
- if (IsDebugEnabled()) Debug.Log("[MCP] manage_scene: dispatching to main thread");
+ if (IsDebugEnabled()) McpLog.Info("[MCP] manage_scene: dispatching to main thread");
var sw = System.Diagnostics.Stopwatch.StartNew();
var r = InvokeOnMainThreadWithTimeout(() => ManageScene.HandleCommand(paramsObject), FrameIOTimeoutMs);
sw.Stop();
- if (IsDebugEnabled()) Debug.Log($"[MCP] manage_scene: completed in {sw.ElapsedMilliseconds} ms");
+ if (IsDebugEnabled()) McpLog.Info($"[MCP] manage_scene: completed in {sw.ElapsedMilliseconds} ms");
return r ?? Response.Error("manage_scene returned null (timeout or error)");
}
catch (Exception ex)
diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/MenuItems.meta b/MCPForUnity/Editor/Resources.meta
similarity index 77%
rename from TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/MenuItems.meta
rename to MCPForUnity/Editor/Resources.meta
index fd11c223..8d921dfd 100644
--- a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/MenuItems.meta
+++ b/MCPForUnity/Editor/Resources.meta
@@ -1,5 +1,5 @@
fileFormatVersion: 2
-guid: c01321ff6339b4763807adb979c5c427
+guid: a6f5bafffbb0f48c2a33ad9470bb1e2d
folderAsset: yes
DefaultImporter:
externalObjects: {}
diff --git a/MCPForUnity/Editor/Resources/McpForUnityResourceAttribute.cs b/MCPForUnity/Editor/Resources/McpForUnityResourceAttribute.cs
new file mode 100644
index 00000000..9b895e23
--- /dev/null
+++ b/MCPForUnity/Editor/Resources/McpForUnityResourceAttribute.cs
@@ -0,0 +1,37 @@
+using System;
+
+namespace MCPForUnity.Editor.Resources
+{
+ ///
+ /// Marks a class as an MCP resource handler for auto-discovery.
+ /// The class must have a public static HandleCommand(JObject) method.
+ ///
+ [AttributeUsage(AttributeTargets.Class, AllowMultiple = false)]
+ public class McpForUnityResourceAttribute : Attribute
+ {
+ ///
+ /// The resource name used to route requests to this resource.
+ /// If not specified, defaults to the PascalCase class name converted to snake_case.
+ ///
+ public string ResourceName { get; }
+
+ ///
+ /// Create an MCP resource attribute with auto-generated resource name.
+ /// The resource name will be derived from the class name (PascalCase → snake_case).
+ /// Example: ManageAsset → manage_asset
+ ///
+ public McpForUnityResourceAttribute()
+ {
+ ResourceName = null; // Will be auto-generated
+ }
+
+ ///
+ /// Create an MCP resource attribute with explicit resource name.
+ ///
+ /// The resource name (e.g., "manage_asset")
+ public McpForUnityResourceAttribute(string resourceName)
+ {
+ ResourceName = resourceName;
+ }
+ }
+}
diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/MenuItems/ManageMenuItemTests.cs.meta b/MCPForUnity/Editor/Resources/McpForUnityResourceAttribute.cs.meta
similarity index 83%
rename from TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/MenuItems/ManageMenuItemTests.cs.meta
rename to MCPForUnity/Editor/Resources/McpForUnityResourceAttribute.cs.meta
index 6f1a8c2b..e887db08 100644
--- a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/MenuItems/ManageMenuItemTests.cs.meta
+++ b/MCPForUnity/Editor/Resources/McpForUnityResourceAttribute.cs.meta
@@ -1,5 +1,5 @@
fileFormatVersion: 2
-guid: 2b36e5f577aa1481c8758831c49d8f9d
+guid: 4c2d60f570f3d4bd2a6a2c1293094be3
MonoImporter:
externalObjects: {}
serializedVersion: 2
diff --git a/MCPForUnity/Editor/Tools/MenuItems.meta b/MCPForUnity/Editor/Resources/MenuItems.meta
similarity index 77%
rename from MCPForUnity/Editor/Tools/MenuItems.meta
rename to MCPForUnity/Editor/Resources/MenuItems.meta
index ffbda8e7..df20ed6c 100644
--- a/MCPForUnity/Editor/Tools/MenuItems.meta
+++ b/MCPForUnity/Editor/Resources/MenuItems.meta
@@ -1,5 +1,5 @@
fileFormatVersion: 2
-guid: 2df8f144c6e684ec3bfd53e4a48f06ee
+guid: bca79cd3ef8ed466f9e50e2dc7850e46
folderAsset: yes
DefaultImporter:
externalObjects: {}
diff --git a/MCPForUnity/Editor/Resources/MenuItems/GetMenuItems.cs b/MCPForUnity/Editor/Resources/MenuItems/GetMenuItems.cs
new file mode 100644
index 00000000..c554be2d
--- /dev/null
+++ b/MCPForUnity/Editor/Resources/MenuItems/GetMenuItems.cs
@@ -0,0 +1,71 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using MCPForUnity.Editor.Helpers;
+using Newtonsoft.Json.Linq;
+using UnityEditor;
+
+namespace MCPForUnity.Editor.Resources.MenuItems
+{
+ ///
+ /// Provides a simple read-only resource that returns Unity menu items.
+ ///
+ [McpForUnityResource("get_menu_items")]
+ public static class GetMenuItems
+ {
+ private static List _cached;
+
+ [InitializeOnLoadMethod]
+ private static void BuildCache() => Refresh();
+
+ public static object HandleCommand(JObject @params)
+ {
+ bool forceRefresh = @params?["refresh"]?.ToObject() ?? false;
+ string search = @params?["search"]?.ToString();
+
+ var items = GetMenuItemsInternal(forceRefresh);
+
+ if (!string.IsNullOrEmpty(search))
+ {
+ items = items
+ .Where(item => item.IndexOf(search, StringComparison.OrdinalIgnoreCase) >= 0)
+ .ToList();
+ }
+
+ string message = $"Retrieved {items.Count} menu items";
+ return Response.Success(message, items);
+ }
+
+ internal static List GetMenuItemsInternal(bool forceRefresh)
+ {
+ if (forceRefresh || _cached == null)
+ {
+ Refresh();
+ }
+
+ return (_cached ?? new List()).ToList();
+ }
+
+ private static void Refresh()
+ {
+ try
+ {
+ var methods = TypeCache.GetMethodsWithAttribute