Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
42 commits
Select commit Hold shift + click to select a range
d38c0c0
deps: add tomli>=2.3.0 dependency to UnityMcpServer package
msanatan Oct 11, 2025
e69387e
feat: dynamically fetch package version from pyproject.toml for telem…
msanatan Oct 11, 2025
716e1f6
Add pydantic
msanatan Oct 12, 2025
3270a5c
feat: add resource registry for MCP resource auto-discovery
msanatan Oct 12, 2025
1e4070d
feat: add telemetry decorator for tracking MCP resource usage
msanatan Oct 12, 2025
560b9d0
feat: add auto-discovery and registration system for MCP resources
msanatan Oct 12, 2025
b81919d
feat: add resource registration to MCP server initialization
msanatan Oct 12, 2025
e00899b
feat: add MCPResponse model class for standardized API responses
msanatan Oct 12, 2025
b1df08f
refactor: replace Debug.Log calls with McpLog wrapper for consistent …
msanatan Oct 12, 2025
eb33612
feat: add test discovery endpoints for Unity Test Framework integration
msanatan Oct 12, 2025
2d2e62a
Fix server setup
msanatan Oct 12, 2025
8c1300e
refactor: reduce log verbosity by changing individual resource/tool r…
msanatan Oct 12, 2025
bbde287
chore: bump mcp[cli] dependency from 1.15.0 to 1.17.0
msanatan Oct 12, 2025
1fe28e4
refactor: remove Context parameter and add uri keyword argument in re…
msanatan Oct 12, 2025
6f0e8e1
chore: upgrade Python base image to 3.13 and simplify Dockerfile setup
msanatan Oct 12, 2025
57c699b
fix: apply telemetry decorator before mcp.tool to ensure proper wrapp…
msanatan Oct 12, 2025
2c30710
fix: swap order of telemetry and resource decorators to properly wrap…
msanatan Oct 12, 2025
d577de8
fix: update log prefixes for consistency in logging methods
msanatan Oct 12, 2025
48eb897
Fix compile errors
msanatan Oct 12, 2025
5cb3511
feat: extend command registry to support both tools and resources
msanatan Oct 12, 2025
8f470a7
Run get tests as a coroutine because it doesn't return results immedi…
msanatan Oct 12, 2025
6c02714
refactor: migrate from coroutines to async/await for test retrieval a…
msanatan Oct 12, 2025
990903c
feat: add optional error field to MCPResponse model
msanatan Oct 12, 2025
f1a9647
Increased timeout because loading tests can take some time
msanatan Oct 13, 2025
8c53288
Make message optional so error responses that only have success and e…
msanatan Oct 13, 2025
45bf332
Set max_retries to 5
msanatan Oct 13, 2025
0a267e7
Use pydantic model to structure the error output
msanatan Oct 13, 2025
8351d80
fix: initialize data field in GetTestsResponse to avoid potential errors
msanatan Oct 13, 2025
d973f0d
Don't return path parameter
msanatan Oct 13, 2025
0d55f66
feat: add Unity test runner execution with structured results and Pyt…
msanatan Oct 13, 2025
1427c90
refactor: simplify GetTests by removing mode filtering and related pa…
msanatan Oct 13, 2025
5b78dd8
refactor: move test runner functionality into dedicated service inter…
msanatan Oct 13, 2025
1c8c671
feat: add resource retrieval telemetry tracking with new record type …
msanatan Oct 13, 2025
012ea6b
fix: convert tool functions to async and await ctx.info calls
msanatan Oct 13, 2025
403713d
refactor: reorganize menu item functionality into separate execute an…
msanatan Oct 13, 2025
942d249
refactor: rename manage_menu_item to execute_menu_item and update too…
msanatan Oct 13, 2025
57d8044
Revert "fix: convert tool functions to async and await ctx.info calls"
msanatan Oct 13, 2025
dc6035a
fix: replace tomllib with tomli for Python 3.10 compatibility in tele…
msanatan Oct 13, 2025
da60e38
Remove confusing comment
msanatan Oct 13, 2025
1bd9703
refactor: improve error handling and simplify test retrieval logic in…
msanatan Oct 13, 2025
3299dce
No cache by default
msanatan Oct 13, 2025
ffb30a9
docs: remove redundant comment for HandleCommand method in ExecuteMen…
msanatan Oct 13, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 6 additions & 4 deletions MCPForUnity/Editor/Helpers/McpLog.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@ namespace MCPForUnity.Editor.Helpers
{
internal static class McpLog
{
private const string Prefix = "<b><color=#2EA3FF>MCP-FOR-UNITY</color></b>:";
private const string LogPrefix = "<b><color=#2EA3FF>MCP-FOR-UNITY</color></b>:";
private const string WarnPrefix = "<b><color=#cc7a00>MCP-FOR-UNITY</color></b>:";
private const string ErrorPrefix = "<b><color=#cc3333>MCP-FOR-UNITY</color></b>:";

private static bool IsDebugEnabled()
{
Expand All @@ -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($"<color=#cc7a00>{Prefix} {message}</color>");
Debug.LogWarning($"{WarnPrefix} {message}");
}

public static void Error(string message)
{
Debug.LogError($"<color=#cc3333>{Prefix} {message}</color>");
Debug.LogError($"{ErrorPrefix} {message}");
}
}
}
130 changes: 88 additions & 42 deletions MCPForUnity/Editor/MCPForUnityBridge.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
{

/// <summary>
/// Outbound message structure for the writer thread
/// </summary>
class Outbound
{
public byte[] Payload;
public string Tag;
public int? ReqId;
}

/// <summary>
/// Queued command structure for main thread processing
/// </summary>
class QueuedCommand
{
public string CommandJson;
public TaskCompletionSource<string> Tcs;
public bool IsExecuting;
}
[InitializeOnLoad]
public static partial class MCPForUnityBridge
{
Expand All @@ -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<TcpClient> activeClients = new();
// Single-writer outbox for framed responses
private class Outbound
{
public byte[] Payload;
public string Tag;
public int? ReqId;
}
private static readonly BlockingCollection<Outbound> _outbox = new(new ConcurrentQueue<Outbound>());
private static CancellationTokenSource cts;
private static Task listenerTask;
Expand All @@ -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<string> tcs)
> commandQueue = new();
private static Dictionary<string, QueuedCommand> commandQueue = new();
private static int mainThreadId;
private static int currentUnityPort = 6400; // Dynamic port, starts with default
private static bool isAutoConnectMode = false;
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -297,7 +306,7 @@ public static void Start()
{
if (IsDebugEnabled())
{
Debug.Log($"<b><color=#2EA3FF>MCP-FOR-UNITY</color></b>: MCPForUnityBridge already running on port {currentUnityPort}");
McpLog.Info($"MCPForUnityBridge already running on port {currentUnityPort}");
}
return;
}
Expand Down Expand Up @@ -383,7 +392,7 @@ public static void Start()
isAutoConnectMode = false;
string platform = Application.platform.ToString();
string serverVer = ReadInstalledServerVersionSafe();
Debug.Log($"<b><color=#2EA3FF>MCP-FOR-UNITY</color></b>: 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));
Expand All @@ -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}");
}
}
}
Expand Down Expand Up @@ -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}");
}
}

Expand Down Expand Up @@ -465,7 +474,7 @@ public static void Stop()
try { AssemblyReloadEvents.afterAssemblyReload -= OnAfterAssemblyReload; } catch { }
try { EditorApplication.quitting -= Stop; } catch { }

if (IsDebugEnabled()) Debug.Log("<b><color=#2EA3FF>MCP-FOR-UNITY</color></b>: MCPForUnityBridge stopped.");
if (IsDebugEnabled()) McpLog.Info("MCPForUnityBridge stopped.");
}

private static async Task ListenerLoopAsync(CancellationToken token)
Expand Down Expand Up @@ -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}");
}
}
}
Expand All @@ -524,7 +533,7 @@ private static async Task HandleClientAsync(TcpClient client, CancellationToken
if (IsDebugEnabled())
{
var ep = client.Client?.RemoteEndPoint?.ToString() ?? "unknown";
Debug.Log($"<b><color=#2EA3FF>UNITY-MCP</color></b>: Client connected {ep}");
McpLog.Info($"Client connected {ep}");
}
}
catch { }
Expand All @@ -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
}

Expand All @@ -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 { }
Expand All @@ -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
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -817,19 +831,25 @@ private static void ProcessCommands()
}

// Snapshot under lock, then process outside to reduce contention
List<(string id, string text, TaskCompletionSource<string> 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<string> tcs = item.tcs;
QueuedCommand queuedCommand = item.command;
string commandText = queuedCommand.CommandJson;
TaskCompletionSource<string> tcs = queuedCommand.Tcs;

try
{
Expand Down Expand Up @@ -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
{
Expand All @@ -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); }
}
}
Expand Down Expand Up @@ -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
Expand All @@ -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)
Expand Down

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

37 changes: 37 additions & 0 deletions MCPForUnity/Editor/Resources/McpForUnityResourceAttribute.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
using System;

namespace MCPForUnity.Editor.Resources
{
/// <summary>
/// Marks a class as an MCP resource handler for auto-discovery.
/// The class must have a public static HandleCommand(JObject) method.
/// </summary>
[AttributeUsage(AttributeTargets.Class, AllowMultiple = false)]
public class McpForUnityResourceAttribute : Attribute
{
/// <summary>
/// The resource name used to route requests to this resource.
/// If not specified, defaults to the PascalCase class name converted to snake_case.
/// </summary>
public string ResourceName { get; }

/// <summary>
/// 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
/// </summary>
public McpForUnityResourceAttribute()
{
ResourceName = null; // Will be auto-generated
}

/// <summary>
/// Create an MCP resource attribute with explicit resource name.
/// </summary>
/// <param name="resourceName">The resource name (e.g., "manage_asset")</param>
public McpForUnityResourceAttribute(string resourceName)
{
ResourceName = resourceName;
}
}
}

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading