From a5a99ee58dcc9eb337e27701143a7cd98e9bf08e Mon Sep 17 00:00:00 2001 From: Jonathan Hamel Date: Mon, 20 Jul 2026 13:34:55 -0700 Subject: [PATCH 01/84] fix: don't let stale path-variant keys shadow Claude registrations ReadUserScopeConfig merged duplicate normalized project keys from ~/.claude.json ("D:/Dev/X" vs "D:\Dev\X") last-entry-wins, on the assumption the last is most recent. JSON property order is not correlated with recency across variants: when the last duplicate is a stale entry without mcpServers it shadows the real registration, and CheckStatus reports NotConfigured despite a working `claude mcp add --scope local` setup (observed live 2026-07-20). Merge by preferring the entry that actually carries a UnityMCP registration (then any mcpServers, then last-wins as before). Co-Authored-By: Claude Fable 5 --- .../Clients/McpClientConfiguratorBase.cs | 32 +++++++++++++++++-- 1 file changed, 29 insertions(+), 3 deletions(-) diff --git a/MCPForUnity/Editor/Clients/McpClientConfiguratorBase.cs b/MCPForUnity/Editor/Clients/McpClientConfiguratorBase.cs index 74fd836f7c..c9b4f47f28 100644 --- a/MCPForUnity/Editor/Clients/McpClientConfiguratorBase.cs +++ b/MCPForUnity/Editor/Clients/McpClientConfiguratorBase.cs @@ -1370,13 +1370,24 @@ private static (JObject serverConfig, string error) ReadUserScopeConfig(string p if (projects == null) return (null, null); - // Build a dictionary of normalized paths for quick lookup - // Use last entry for duplicates (forward/backslash variants) as it's typically more recent + // Build a dictionary of normalized paths for quick lookup. + // Duplicate keys (forward/backslash variants of the same path) + // are merged by preferring the entry that actually carries a + // UnityMCP registration: JSON property order is not correlated + // with recency across variants, so blind last-entry-wins lets a + // stale variant without mcpServers shadow a real registration + // and CheckStatus reports NotConfigured despite a working + // `claude mcp add --scope local` setup. var normalizedProjects = new Dictionary(StringComparer.OrdinalIgnoreCase); foreach (var project in projects.Properties()) { string normalizedPath = NormalizePath(project.Name); - normalizedProjects[normalizedPath] = project.Value as JObject; + var value = project.Value as JObject; + if (!normalizedProjects.TryGetValue(normalizedPath, out var existing) + || RegistrationRank(value) >= RegistrationRank(existing)) + { + normalizedProjects[normalizedPath] = value; + } } // Walk up the directory tree to find a matching project config @@ -1417,6 +1428,21 @@ private static (JObject serverConfig, string error) ReadUserScopeConfig(string p } } + /// + /// Ranks a ~/.claude.json project entry for duplicate-key merging: + /// 2 = has a UnityMCP registration, 1 = has other mcpServers, 0 = none. + /// + private static int RegistrationRank(JObject projectConfig) + { + if (!(projectConfig?["mcpServers"] is JObject servers)) return 0; + foreach (var server in servers.Properties()) + { + if (string.Equals(server.Name, "UnityMCP", StringComparison.OrdinalIgnoreCase)) + return 2; + } + return servers.HasValues ? 1 : 0; + } + /// /// Normalizes a file path for comparison (handles forward/back slashes, trailing slashes). /// From 2d00ed2e877fdeb103fa64e3b0ed72f5c112a857 Mon Sep 17 00:00:00 2001 From: Jonathan Hamel Date: Mon, 20 Jul 2026 13:35:27 -0700 Subject: [PATCH 02/84] fix: resolve Claude registrations for git-worktree Unity projects `claude mcp add --scope local` keys the registration in ~/.claude.json by the git MAIN repo root. When the Unity project is a linked worktree (e.g. C:/Dev/stay-booping -> repo C:/Dev/stay) that key is a sibling path, so ReadUserScopeConfig's ancestor walk never finds it and CheckStatus reports NotConfigured while the CLI itself resolves the worktree fine (`claude mcp list` shows Connected). Parse the main root from the worktree's .git pointer file and retry the walk from there. Co-Authored-By: Claude Fable 5 --- .../Clients/McpClientConfiguratorBase.cs | 91 ++++++++++++++----- 1 file changed, 70 insertions(+), 21 deletions(-) diff --git a/MCPForUnity/Editor/Clients/McpClientConfiguratorBase.cs b/MCPForUnity/Editor/Clients/McpClientConfiguratorBase.cs index c9b4f47f28..c4f021b2cc 100644 --- a/MCPForUnity/Editor/Clients/McpClientConfiguratorBase.cs +++ b/MCPForUnity/Editor/Clients/McpClientConfiguratorBase.cs @@ -1393,39 +1393,88 @@ private static (JObject serverConfig, string error) ReadUserScopeConfig(string p // Walk up the directory tree to find a matching project config // Claude Code may be configured at a parent directory (e.g., repo root) // while Unity project is in a subdirectory (e.g., TestProjects/UnityMCPTests) - string currentDir = NormalizePath(projectDir); - while (!string.IsNullOrEmpty(currentDir)) + var fromProjectDir = FindUnityServerFromWalk(normalizedProjects, NormalizePath(projectDir)); + if (fromProjectDir != null) + return (fromProjectDir, null); + + // `claude mcp add --scope local` keys the registration by the + // git MAIN repo root. When the Unity project lives in a linked + // worktree, that root is a sibling path the ancestor walk above + // can never reach — retry from the parsed main root. + string mainRoot = GetGitMainRepoRoot(projectDir); + if (!string.IsNullOrEmpty(mainRoot) + && !string.Equals(mainRoot, NormalizePath(projectDir), StringComparison.OrdinalIgnoreCase)) { - if (normalizedProjects.TryGetValue(currentDir, out var projectConfig)) + var fromMainRoot = FindUnityServerFromWalk(normalizedProjects, mainRoot); + if (fromMainRoot != null) + return (fromMainRoot, null); + } + + return (null, null); + } + catch (Exception ex) + { + return (null, $"Error reading user Claude config: {ex.Message}"); + } + } + + /// + /// Walks up from startDir (normalized) looking for a project entry with + /// a UnityMCP registration. Stops at the first project entry found even + /// if it lacks UnityMCP (a configured project boundary). + /// + private static JObject FindUnityServerFromWalk(Dictionary normalizedProjects, string startDir) + { + string currentDir = startDir; + while (!string.IsNullOrEmpty(currentDir)) + { + if (normalizedProjects.TryGetValue(currentDir, out var projectConfig)) + { + var mcpServers = projectConfig?["mcpServers"] as JObject; + if (mcpServers != null) { - var mcpServers = projectConfig?["mcpServers"] as JObject; - if (mcpServers != null) + foreach (var server in mcpServers.Properties()) { - foreach (var server in mcpServers.Properties()) + if (string.Equals(server.Name, "UnityMCP", StringComparison.OrdinalIgnoreCase)) { - if (string.Equals(server.Name, "UnityMCP", StringComparison.OrdinalIgnoreCase)) - { - return (server.Value as JObject, null); - } + return server.Value as JObject; } } - // Found the project but no UnityMCP - don't continue walking up - return (null, null); } - - // Move up one directory - int lastSlash = currentDir.LastIndexOf('/'); - if (lastSlash <= 0) - break; - currentDir = currentDir.Substring(0, lastSlash); + // Found the project but no UnityMCP - don't continue walking up + return null; } - return (null, null); + // Move up one directory + int lastSlash = currentDir.LastIndexOf('/'); + if (lastSlash <= 0) + break; + currentDir = currentDir.Substring(0, lastSlash); } - catch (Exception ex) + return null; + } + + /// + /// For a linked git worktree, the main repo root parsed from the .git + /// pointer file ("gitdir: <root>/.git/worktrees/<name>"), + /// normalized. Null for a regular checkout (.git directory) or + /// anything unparseable. + /// + private static string GetGitMainRepoRoot(string dir) + { + try { - return (null, $"Error reading user Claude config: {ex.Message}"); + string gitPath = Path.Combine(dir, ".git"); + if (!File.Exists(gitPath)) return null; + string line = File.ReadAllText(gitPath).Trim(); + if (!line.StartsWith("gitdir:", StringComparison.OrdinalIgnoreCase)) return null; + string gitDir = line.Substring("gitdir:".Length).Trim(); + if (!Path.IsPathRooted(gitDir)) gitDir = Path.Combine(dir, gitDir); + gitDir = NormalizePath(Path.GetFullPath(gitDir)); + int i = gitDir.LastIndexOf("/.git/", StringComparison.OrdinalIgnoreCase); + return i > 0 ? gitDir.Substring(0, i) : null; } + catch { return null; } } /// From 836834242020211daf2b492e4602c4915bab6a6e Mon Sep 17 00:00:00 2001 From: pumisj Date: Wed, 26 Aug 2026 00:40:59 +0900 Subject: [PATCH 03/84] fix(stdio): refresh port registry before reconnect backoff --- .../src/transport/legacy/unity_connection.py | 1 + .../integration/test_connection_deadline.py | 49 +++++++++++++++++++ 2 files changed, 50 insertions(+) diff --git a/Server/src/transport/legacy/unity_connection.py b/Server/src/transport/legacy/unity_connection.py index c2b615d50a..986781bed0 100644 --- a/Server/src/transport/legacy/unity_connection.py +++ b/Server/src/transport/legacy/unity_connection.py @@ -439,6 +439,7 @@ def read_status_file(target_hash: str | None = None) -> dict | None: # Re-discover the port for this specific instance try: + stdio_port_registry.get_instances(force_refresh=True) new_port: int | None = None if self.instance_id: # Try to rediscover the specific instance via shared registry diff --git a/Server/tests/integration/test_connection_deadline.py b/Server/tests/integration/test_connection_deadline.py index d51e6bf553..dd6b280e43 100644 --- a/Server/tests/integration/test_connection_deadline.py +++ b/Server/tests/integration/test_connection_deadline.py @@ -11,6 +11,8 @@ from core.config import config import transport.legacy.unity_connection as uc +from models.models import UnityInstanceInfo +from transport.legacy.stdio_port_registry import StdioPortRegistry from transport.legacy.unity_connection import UnityConnection @@ -108,3 +110,50 @@ def test_reload_signal_drops_socket_for_fresh_reconnect(silent_bridge, tmp_path) response = conn.send_command("get_editor_state", {}) assert conn.sock is None assert uc._extract_response_reason(response) == "reloading" + + +def test_connection_failure_refreshes_cached_port_before_backoff(monkeypatch) -> None: + instance_id = "Repro@deadbeef" + discovered_port = 6400 + + def discover_instances() -> list[UnityInstanceInfo]: + return [ + UnityInstanceInfo( + id=instance_id, + name="Repro", + path="/tmp/Repro/Assets", + hash="deadbeef", + port=discovered_port, + status="running", + ) + ] + + registry = StdioPortRegistry() + monkeypatch.setattr( + "transport.legacy.stdio_port_registry.PortDiscovery.discover_all_unity_instances", + discover_instances, + ) + monkeypatch.setattr(config, "port_registry_ttl", 60.0) + + assert registry.get_port(instance_id) == 6400 + discovered_port = 6401 + + monkeypatch.setattr(uc, "stdio_port_registry", registry) + conn = UnityConnection(port=6400, instance_id=instance_id) + attempted_ports: list[int] = [] + ports_before_backoff: list[int] = [] + + def fail_connect(connect_timeout: float | None = None) -> bool: + attempted_ports.append(conn.port) + return False + + monkeypatch.setattr(conn, "connect", fail_connect) + monkeypatch.setattr( + uc.time, "sleep", lambda _seconds: ports_before_backoff.append(conn.port) + ) + + with pytest.raises(ConnectionError, match="Could not connect to Unity"): + conn.send_command("get_editor_state", {}, max_attempts=1) + + assert attempted_ports == [6400, 6401] + assert ports_before_backoff == [6401] From 8f0e5b1324a3e1d632dfe26575dadf519756e440 Mon Sep 17 00:00:00 2001 From: Anant Sharma <10895811+ananttheant@users.noreply.github.com> Date: Tue, 1 Sep 2026 14:11:32 +0100 Subject: [PATCH 04/84] fix: suppress broker command resends instead of running the work twice MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a command outlives the broker's patience, the Python side closes the socket, reconnects and resends the same payload. The bridge queued that resend as a brand-new command behind the still-executing original, so the work ran twice — visibly so for commands with side effects such as a long ExecuteMenuItem or a build. The enqueue path now looks for an in-flight command with the same payload that was queued by a different connection, and attaches the new caller to that command's completion source rather than queueing a copy. Comparing the owning connection as well as the payload is what keeps this safe: a single connection handles one command at a time, so two identical payloads on one connection are sequential and genuinely distinct requests. QueuedCommand gains an Owner field, held for identity comparison only and never dereferenced. Closes #1130 --- .../Transport/Transports/StdioBridgeHost.cs | 70 ++++++- .../Services/StdioBrokerResendTests.cs | 181 ++++++++++++++++++ .../Services/StdioBrokerResendTests.cs.meta | 11 ++ 3 files changed, 253 insertions(+), 9 deletions(-) create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Services/StdioBrokerResendTests.cs create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Services/StdioBrokerResendTests.cs.meta diff --git a/MCPForUnity/Editor/Services/Transport/Transports/StdioBridgeHost.cs b/MCPForUnity/Editor/Services/Transport/Transports/StdioBridgeHost.cs index 4c0ff0b87b..e8e2827d28 100644 --- a/MCPForUnity/Editor/Services/Transport/Transports/StdioBridgeHost.cs +++ b/MCPForUnity/Editor/Services/Transport/Transports/StdioBridgeHost.cs @@ -27,6 +27,12 @@ class QueuedCommand public TaskCompletionSource Tcs; public bool IsExecuting; public long EnqueuedAtMs; + + /// + /// Connection that queued this command, used to tell a broker resend apart from a + /// genuinely new request. Never dereferenced — identity only. + /// + public object Owner; } [InitializeOnLoad] @@ -262,6 +268,36 @@ private static void EnsureStartedOnEditorIdle() // Routed through EditorStateCache so a deferred domain reload (issue #1276) does not // pin the bridge off: raw EditorApplication.isCompiling stays true for as long as the // reload is held, and this gates bridge startup. + /// + /// True when an already-queued command is the same payload arriving from a different + /// connection — the signature of a broker that reconnected and resent. Payload equality + /// alone is not enough: a single connection handles one command at a time, so two + /// identical payloads on the same connection are sequential and genuinely distinct. + /// + internal static bool IsBrokerResend( + string queuedCommandJson, object queuedOwner, string incomingCommandJson, object incomingOwner) + { + if (queuedOwner == null || incomingOwner == null) return false; + if (ReferenceEquals(queuedOwner, incomingOwner)) return false; + return string.Equals(queuedCommandJson, incomingCommandJson, StringComparison.Ordinal); + } + + /// + /// Finds an in-flight command that is resending. + /// Callers must hold . + /// + private static QueuedCommand FindBrokerResendTarget(string commandText, object incomingOwner) + { + foreach (var kvp in commandQueue) + { + if (IsBrokerResend(kvp.Value.CommandJson, kvp.Value.Owner, commandText, incomingOwner)) + { + return kvp.Value; + } + } + return null; + } + private static bool IsCompiling() => EditorStateCache.GetActualIsCompiling(); public static void Start() @@ -600,15 +636,31 @@ private static async Task HandleClientAsync(TcpClient client, CancellationToken continue; } + // A command already in flight from a different connection means the + // broker gave up waiting, reconnected and resent it. Running it a + // second time would duplicate side effects (issue #1130), so attach + // to the original instead of queueing a copy. + TaskCompletionSource pending = tcs; lock (lockObj) { - commandQueue[commandId] = new QueuedCommand + QueuedCommand inFlight = FindBrokerResendTarget(commandText, client); + if (inFlight != null) { - CommandJson = commandText, - Tcs = tcs, - IsExecuting = false, - EnqueuedAtMs = _uptime.ElapsedMilliseconds - }; + pending = inFlight.Tcs; + McpLog.Warn("Suppressed duplicate command resent on a new connection; " + + "awaiting the in-flight result instead of running it twice."); + } + else + { + commandQueue[commandId] = new QueuedCommand + { + CommandJson = commandText, + Tcs = tcs, + IsExecuting = false, + EnqueuedAtMs = _uptime.ElapsedMilliseconds, + Owner = client + }; + } } // Force Unity's main loop to iterate even when backgrounded, @@ -620,11 +672,11 @@ private static async Task HandleClientAsync(TcpClient client, CancellationToken try { using var respCts = new CancellationTokenSource(FrameIOTimeoutMs); - var completed = await Task.WhenAny(tcs.Task, Task.Delay(FrameIOTimeoutMs, respCts.Token)).ConfigureAwait(false); - if (completed == tcs.Task) + var completed = await Task.WhenAny(pending.Task, Task.Delay(FrameIOTimeoutMs, respCts.Token)).ConfigureAwait(false); + if (completed == pending.Task) { respCts.Cancel(); - response = tcs.Task.Result; + response = pending.Task.Result; Interlocked.Exchange(ref _consecutiveTimeouts, 0); } else diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Services/StdioBrokerResendTests.cs b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Services/StdioBrokerResendTests.cs new file mode 100644 index 0000000000..08cbcd2a88 --- /dev/null +++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Services/StdioBrokerResendTests.cs @@ -0,0 +1,181 @@ +using System; +using System.Collections; +using System.IO; +using System.Reflection; +using System.Net.Sockets; +using System.Text; +using System.Threading; +using NUnit.Framework; + +using UnityEngine.TestTools; +using MCPForUnity.Editor.Services.Transport.Transports; + +namespace MCPForUnityTests.Editor.Services +{ + /// + /// When a long-running command outlives the broker's patience, the Python side reconnects and + /// resends it. The bridge used to queue that resend as a brand-new command behind the original, + /// so the work ran twice — visibly, for commands with side effects (issue #1130). + /// + [TestFixture] + public class StdioBrokerResendTests + { + private const int ConnectTimeoutMs = 5000; + private const int ReadTimeoutMs = 10000; + + [Test] + public void IsBrokerResend_SamePayloadFromAnotherConnection_IsAResend() + { + object first = new object(); + object second = new object(); + + Assert.IsTrue(StdioBridgeHost.IsBrokerResend("{\"type\":\"a\"}", first, "{\"type\":\"a\"}", second), + "same payload arriving on a different connection is the resend signature"); + } + + [Test] + public void IsBrokerResend_SameConnection_IsNotAResend() + { + object connection = new object(); + + Assert.IsFalse(StdioBridgeHost.IsBrokerResend("{\"type\":\"a\"}", connection, "{\"type\":\"a\"}", connection), + "one connection handles commands sequentially, so identical payloads are distinct requests"); + } + + [Test] + public void IsBrokerResend_DifferentPayload_IsNotAResend() + { + Assert.IsFalse(StdioBridgeHost.IsBrokerResend("{\"type\":\"a\"}", new object(), "{\"type\":\"b\"}", new object()), + "different work must never be collapsed"); + } + + [Test] + public void IsBrokerResend_UnknownOwner_IsNotAResend() + { + Assert.IsFalse(StdioBridgeHost.IsBrokerResend("{\"type\":\"a\"}", null, "{\"type\":\"a\"}", new object()), + "a command with no recorded owner cannot be proven to be a resend"); + Assert.IsFalse(StdioBridgeHost.IsBrokerResend("{\"type\":\"a\"}", new object(), "{\"type\":\"a\"}", null), + "an incoming command with no owner cannot be proven to be a resend"); + } + + /// + /// The bridge must not queue a second copy of a command that is already in flight from + /// another connection. Asserted on the queue itself while the main thread is blocked, so + /// no command has been dispatched yet and the result does not depend on what the command + /// would have done. Requires the bridge (batchmode needs UNITY_MCP_ALLOW_BATCH). + /// + [UnityTest] + public IEnumerator IdenticalCommandResentOnNewConnection_IsQueuedOnce() + { + if (!StdioBridgeHost.IsRunning) + { + Assert.Ignore("StdioBridgeHost is not running; skipping broker-resend test."); + yield break; + } + + int port = StdioBridgeHost.GetCurrentPort(); + byte[] command = Encoding.UTF8.GetBytes( + "{\"type\":\"read_console\",\"params\":{\"action\":\"get\",\"count\":1}}"); + + TcpClient first = null; + TcpClient second = null; + int queuedAfterFirst; + int queuedAfterResend; + try + { + first = Connect(port); + SendFrame(first.GetStream(), command); + + // Sleeping on the main thread lets the listener task read and queue the frame while + // ProcessCommands — an editor-update hook — cannot run and drain it. The wait also + // has to happen before the second connect: a new connection closes stale clients, + // and if it wins that race the first frame is never read at all. + Thread.Sleep(1500); + queuedAfterFirst = ReadQueueDepth(); + + // A second connection is what the broker opens after giving up on the first. + second = Connect(port); + SendFrame(second.GetStream(), command); + Thread.Sleep(1500); + queuedAfterResend = ReadQueueDepth(); + } + finally + { + SafeClose(first); + SafeClose(second); + } + + // Let the queue drain so we do not leak state into later tests. + for (int i = 0; i < 120; i++) + yield return null; + + Assert.AreEqual(1, queuedAfterFirst, + "precondition: the first command must be sitting in the queue undrained — " + + $"found {queuedAfterFirst} entries, so this run proves nothing about the resend"); + Assert.AreEqual(1, queuedAfterResend, + $"the resend should have attached to the in-flight command, but {queuedAfterResend} " + + "entries were queued — the command would run that many times"); + } + + /// + /// Reads StdioBridgeHost's private command queue by reflection, so this test compiles and + /// runs against builds both with and without the suppression fix. + /// + private static int ReadQueueDepth() + { + FieldInfo field = typeof(StdioBridgeHost).GetField( + "commandQueue", BindingFlags.NonPublic | BindingFlags.Static); + Assert.IsNotNull(field, "StdioBridgeHost.commandQueue not found — was it renamed?"); + var queue = field.GetValue(null) as ICollection; + Assert.IsNotNull(queue, "commandQueue is not a collection"); + return queue.Count; + } + + private static TcpClient Connect(int port) + { + var client = new TcpClient(); + Assert.IsTrue(client.ConnectAsync("127.0.0.1", port).Wait(ConnectTimeoutMs), "connect timed out"); + client.ReceiveTimeout = ReadTimeoutMs; + string handshake = ReadLine(client.GetStream(), ReadTimeoutMs); + Assert.That(handshake, Does.Contain("FRAMING=1"), "expected the framing handshake"); + return client; + } + + private static void SafeClose(TcpClient client) + { + if (client == null) return; + try + { + client.Client.LingerState = new LingerOption(true, 0); + client.Close(); + } + catch { } + } + + private static string ReadLine(NetworkStream stream, int timeoutMs) + { + stream.ReadTimeout = timeoutMs; + var sb = new StringBuilder(); + var deadline = DateTime.UtcNow.AddMilliseconds(timeoutMs); + while (DateTime.UtcNow <= deadline) + { + int b = stream.ReadByte(); + if (b < 0) throw new IOException("Connection closed while reading handshake"); + if (b == '\n') return sb.ToString(); + sb.Append((char)b); + } + throw new TimeoutException("Timed out reading handshake line"); + } + + private static void SendFrame(NetworkStream stream, byte[] payload) + { + byte[] header = new byte[8]; + ulong len = (ulong)payload.LongLength; + for (int i = 0; i < 8; i++) + header[i] = (byte)(len >> (56 - 8 * i)); + stream.Write(header, 0, 8); + stream.Write(payload, 0, payload.Length); + stream.Flush(); + } + } +} diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Services/StdioBrokerResendTests.cs.meta b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Services/StdioBrokerResendTests.cs.meta new file mode 100644 index 0000000000..593e3d4dcf --- /dev/null +++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Services/StdioBrokerResendTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: e01078ef9fbb449a0a1788974b41a61f +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: From 49f97908d646bbc20ec8706190d65c8811ab1681 Mon Sep 17 00:00:00 2001 From: Anant Sharma <10895811+ananttheant@users.noreply.github.com> Date: Tue, 1 Sep 2026 14:38:39 +0100 Subject: [PATCH 05/84] fix: read the command queue under its lock in the resend test The test sampled Dictionary.Count directly by reflection while listener tasks could be adding entries under lockObj, and Dictionary does not support concurrent reads and writes. Sleeping on the main thread stops ProcessCommands from draining the queue but does nothing to synchronise against the socket threads that fill it. Replaces the reflection with an internal QueuedCommandCount accessor that takes lockObj, documented as diagnostics-only. Addresses CodeRabbit review on #1361. --- .../Transport/Transports/StdioBridgeHost.cs | 10 ++++++++++ .../Services/StdioBrokerResendTests.cs | 19 ++----------------- 2 files changed, 12 insertions(+), 17 deletions(-) diff --git a/MCPForUnity/Editor/Services/Transport/Transports/StdioBridgeHost.cs b/MCPForUnity/Editor/Services/Transport/Transports/StdioBridgeHost.cs index e8e2827d28..32567efa7d 100644 --- a/MCPForUnity/Editor/Services/Transport/Transports/StdioBridgeHost.cs +++ b/MCPForUnity/Editor/Services/Transport/Transports/StdioBridgeHost.cs @@ -268,6 +268,16 @@ private static void EnsureStartedOnEditorIdle() // Routed through EditorStateCache so a deferred domain reload (issue #1276) does not // pin the bridge off: raw EditorApplication.isCompiling stays true for as long as the // reload is held, and this gates bridge startup. + /// + /// Number of commands currently queued, read under the queue lock. Diagnostics only — + /// callers must not reason about individual entries, since the queue mutates from both + /// the listener tasks and the editor update loop. + /// + internal static int QueuedCommandCount + { + get { lock (lockObj) { return commandQueue.Count; } } + } + /// /// True when an already-queued command is the same payload arriving from a different /// connection — the signature of a broker that reconnected and resent. Payload equality diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Services/StdioBrokerResendTests.cs b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Services/StdioBrokerResendTests.cs index 08cbcd2a88..ee64bec5a6 100644 --- a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Services/StdioBrokerResendTests.cs +++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Services/StdioBrokerResendTests.cs @@ -1,7 +1,6 @@ using System; using System.Collections; using System.IO; -using System.Reflection; using System.Net.Sockets; using System.Text; using System.Threading; @@ -91,13 +90,13 @@ public IEnumerator IdenticalCommandResentOnNewConnection_IsQueuedOnce() // has to happen before the second connect: a new connection closes stale clients, // and if it wins that race the first frame is never read at all. Thread.Sleep(1500); - queuedAfterFirst = ReadQueueDepth(); + queuedAfterFirst = StdioBridgeHost.QueuedCommandCount; // A second connection is what the broker opens after giving up on the first. second = Connect(port); SendFrame(second.GetStream(), command); Thread.Sleep(1500); - queuedAfterResend = ReadQueueDepth(); + queuedAfterResend = StdioBridgeHost.QueuedCommandCount; } finally { @@ -117,20 +116,6 @@ public IEnumerator IdenticalCommandResentOnNewConnection_IsQueuedOnce() + "entries were queued — the command would run that many times"); } - /// - /// Reads StdioBridgeHost's private command queue by reflection, so this test compiles and - /// runs against builds both with and without the suppression fix. - /// - private static int ReadQueueDepth() - { - FieldInfo field = typeof(StdioBridgeHost).GetField( - "commandQueue", BindingFlags.NonPublic | BindingFlags.Static); - Assert.IsNotNull(field, "StdioBridgeHost.commandQueue not found — was it renamed?"); - var queue = field.GetValue(null) as ICollection; - Assert.IsNotNull(queue, "commandQueue is not a collection"); - return queue.Count; - } - private static TcpClient Connect(int port) { var client = new TcpClient(); From 20969c07f972bf659233d5cc71dfd6b5de0b1d77 Mon Sep 17 00:00:00 2001 From: GitHub Actions Date: Tue, 1 Sep 2026 20:27:48 +0000 Subject: [PATCH 06/84] chore: set beta version to 10.2.1-beta.1 after release v10.2.0 --- MCPForUnity/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/MCPForUnity/package.json b/MCPForUnity/package.json index 81157f5086..26c903e648 100644 --- a/MCPForUnity/package.json +++ b/MCPForUnity/package.json @@ -1,6 +1,6 @@ { "name": "com.coplaydev.unity-mcp", - "version": "10.2.0", + "version": "10.2.1-beta.1", "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", From 3147b0d922d7585a6f6a8f38defc01722fab47d8 Mon Sep 17 00:00:00 2001 From: Anant Sharma <10895811+ananttheant@users.noreply.github.com> Date: Wed, 2 Sep 2026 10:48:44 +0100 Subject: [PATCH 07/84] perf(discovery): drop the redundant assembly walk from tool discovery too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DiscoverAllTools queried TypeCache and then materialised every type in every loaded assembly anyway, calling GetCustomAttribute on each one before merging the two sets — so the fast path never saved anything. The result is cached, so that walk ran once per domain reload, and again whenever a tool is toggled in the editor window. A reload therefore paid for two full assembly walks: this one and the one in CommandRegistry.AutoDiscoverCommands that #1364 removed. Now TypeCache-only, matching CommandRegistry and ResourceDiscoveryService. Ordered by FullName for the same reason #1364 sorts: a duplicate tool name overwrites the previous registration, so order decides the winner, and TypeCache documents no order. Relates to #1336 --- .../Editor/Services/ToolDiscoveryService.cs | 40 +++++++--------- .../Services/ToolDiscoveryServiceTests.cs | 47 +++++++++++++++++++ 2 files changed, 63 insertions(+), 24 deletions(-) diff --git a/MCPForUnity/Editor/Services/ToolDiscoveryService.cs b/MCPForUnity/Editor/Services/ToolDiscoveryService.cs index e2ff512547..217a25db06 100644 --- a/MCPForUnity/Editor/Services/ToolDiscoveryService.cs +++ b/MCPForUnity/Editor/Services/ToolDiscoveryService.cs @@ -23,30 +23,22 @@ public List DiscoverAllTools() _cachedTools = new Dictionary(); - // Primary scan via TypeCache (fast, but can miss project assemblies in some domain-reload states) - var toolTypes = TypeCache.GetTypesWithAttribute(); - - // Fallback scan via AppDomain (slower but exhaustive; mirrors CommandRegistry behaviour) - var appDomainTypes = AppDomain.CurrentDomain.GetAssemblies() - .Where(a => !a.IsDynamic) - .SelectMany(a => - { - try { return a.GetTypes(); } - catch (Exception ex) - { - McpLog.Warn($"Failed to reflect types from assembly {a.FullName}: {ex.Message}"); - return new Type[0]; - } - }) - .Where(t => t.GetCustomAttribute() != null); - - // Merge both scans, deduplicating by type - var allToolTypes = toolTypes - .Concat(appDomainTypes) - .Distinct() - .ToList(); - - foreach (var type in allToolTypes) + // TypeCache is Unity's precomputed attribute index, rebuilt once per domain reload. + // This used to query it and then ALSO materialise every type in every loaded assembly + // and call GetCustomAttribute on each one, before merging the two sets — so the fast + // path never actually saved anything (issue #1336). The result is cached, so the walk + // ran once per domain reload, and again on each tool toggle in the editor window, + // which invalidates the cache. A reload therefore paid for two full assembly walks: + // this one and CommandRegistry.AutoDiscoverCommands, which #1364 removed. This brings + // the tool registry in line with it, and with ResourceDiscoveryService, which has + // always been TypeCache-only. + // + // Ordered by FullName because a duplicate tool name overwrites the previous + // registration below, so registration order decides which one wins. TypeCache does + // not document an order, so without sorting a name collision could resolve + // differently between domain reloads. + foreach (var type in TypeCache.GetTypesWithAttribute() + .OrderBy(t => t.FullName, StringComparer.Ordinal)) { McpForUnityToolAttribute toolAttr; try diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Services/ToolDiscoveryServiceTests.cs b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Services/ToolDiscoveryServiceTests.cs index 7e267c225d..e02cba3296 100644 --- a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Services/ToolDiscoveryServiceTests.cs +++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Services/ToolDiscoveryServiceTests.cs @@ -113,6 +113,53 @@ public void ToolToggle_PersistsAcrossServiceInstances() Assert.IsFalse(result, "Tool state should persist across service instances"); } + /// + /// Discovery is TypeCache-only, so the built-in tools must all still surface — this is the + /// guard that dropping the exhaustive assembly walk did not lose any of them. + /// + [Test] + public void DiscoverAllTools_FindsEveryBuiltInTool() + { + string[] expected = + { + "manage_asset", + "manage_editor", + "manage_gameobject", + "manage_scene", + "manage_script", + "manage_shader", + "read_console", + "execute_menu_item", + "manage_prefabs" + }; + + var service = new ToolDiscoveryService(); + var discovered = service.DiscoverAllTools().Select(tool => tool.Name).ToList(); + + foreach (string name in expected) + { + CollectionAssert.Contains(discovered, name, $"built-in tool '{name}' should be discovered"); + } + } + + /// + /// A duplicate tool name overwrites the previous registration, so registration order + /// decides the winner. TypeCache documents no order, hence the explicit sort — without it + /// a name collision could resolve differently from one domain reload to the next. + /// + [Test] + public void DiscoverAllTools_RegistersInAStableOrder() + { + var service = new ToolDiscoveryService(); + + var first = service.DiscoverAllTools().Select(tool => tool.Name).ToList(); + service.InvalidateCache(); + var second = service.DiscoverAllTools().Select(tool => tool.Name).ToList(); + + CollectionAssert.AreEqual(first, second, + "repeated discovery must produce the same registration order"); + } + [Test] public void DiscoverAllTools_DoesNotOverrideStoredFalse_ForBuiltInAutoRegisterFalseTool() { From 2c8406a1a41efc364323c90902a73441e160fbaa Mon Sep 17 00:00:00 2001 From: Anant Sharma <10895811+ananttheant@users.noreply.github.com> Date: Wed, 2 Sep 2026 11:27:16 +0100 Subject: [PATCH 08/84] fix(discovery): break full-name ties by assembly when ordering tool types FullName is not a total order: two assemblies can declare the same full type name, and OrderBy leaves their relative position to the input, which TypeCache does not define. If those types also resolve to the same tool name, the later registration overwrites the earlier one, so the winner could differ between domain reloads. Ordering now falls back to Assembly.FullName. The comparison moved into an internal InRegistrationOrder helper so it can be tested directly: two identically named types cannot coexist in one assembly, so the test emits them into separate dynamic assemblies to construct a real tie and asserts the result does not depend on input order. Addresses CodeRabbit review on #1369. --- .../Editor/Services/ToolDiscoveryService.cs | 22 +++++++--- .../Services/ToolDiscoveryServiceTests.cs | 40 +++++++++++++++++++ 2 files changed, 56 insertions(+), 6 deletions(-) diff --git a/MCPForUnity/Editor/Services/ToolDiscoveryService.cs b/MCPForUnity/Editor/Services/ToolDiscoveryService.cs index 217a25db06..cbda375abd 100644 --- a/MCPForUnity/Editor/Services/ToolDiscoveryService.cs +++ b/MCPForUnity/Editor/Services/ToolDiscoveryService.cs @@ -33,12 +33,9 @@ public List DiscoverAllTools() // the tool registry in line with it, and with ResourceDiscoveryService, which has // always been TypeCache-only. // - // Ordered by FullName because a duplicate tool name overwrites the previous - // registration below, so registration order decides which one wins. TypeCache does - // not document an order, so without sorting a name collision could resolve - // differently between domain reloads. - foreach (var type in TypeCache.GetTypesWithAttribute() - .OrderBy(t => t.FullName, StringComparer.Ordinal)) + // Ordered because a duplicate tool name overwrites the previous registration below, + // so registration order decides which one wins, and TypeCache documents no order. + foreach (var type in InRegistrationOrder(TypeCache.GetTypesWithAttribute())) { McpForUnityToolAttribute toolAttr; try @@ -72,6 +69,19 @@ public List DiscoverAllTools() return _cachedTools.Values.ToList(); } + /// + /// Stable registration order for discovered tool types. FullName alone is not a total + /// order: two assemblies can declare the same full type name, and if their tool names + /// also collide the later registration wins, so the assembly identity breaks the tie. + /// Without both keys a collision could resolve differently between domain reloads. + /// + internal static IEnumerable InRegistrationOrder(IEnumerable types) + { + return types + .OrderBy(type => type.FullName, StringComparer.Ordinal) + .ThenBy(type => type.Assembly.FullName, StringComparer.Ordinal); + } + public ToolMetadata GetToolMetadata(string toolName) { if (_cachedTools == null) diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Services/ToolDiscoveryServiceTests.cs b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Services/ToolDiscoveryServiceTests.cs index e02cba3296..410492b037 100644 --- a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Services/ToolDiscoveryServiceTests.cs +++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Services/ToolDiscoveryServiceTests.cs @@ -1,4 +1,8 @@ +using System; +using System.Collections.Generic; using System.Linq; +using System.Reflection; +using System.Reflection.Emit; using NUnit.Framework; using MCPForUnity.Editor.Constants; using MCPForUnity.Editor.Services; @@ -160,6 +164,42 @@ public void DiscoverAllTools_RegistersInAStableOrder() "repeated discovery must produce the same registration order"); } + /// + /// FullName alone is not a total order — two assemblies can declare the same full type + /// name. If their tool names also collide the later registration wins, so the tie has to + /// resolve the same way every reload. Two identically named types cannot coexist in one + /// assembly, so this emits them into separate dynamic assemblies to build a real tie. + /// + [Test] + public void InRegistrationOrder_BreaksFullNameTiesByAssembly() + { + const string sharedName = "McpTieBreak.Namespace.DuplicateTool"; + Type fromA = EmitTypeInOwnAssembly("McpTieBreakAssemblyA", sharedName); + Type fromB = EmitTypeInOwnAssembly("McpTieBreakAssemblyB", sharedName); + + Assert.AreEqual(fromA.FullName, fromB.FullName, + "precondition: the two types must share a full name for this to test a tie"); + Assert.AreNotEqual(fromA.Assembly.FullName, fromB.Assembly.FullName, + "precondition: the two types must live in different assemblies"); + + var forward = ToolDiscoveryService.InRegistrationOrder(new[] { fromA, fromB }).ToList(); + var reversed = ToolDiscoveryService.InRegistrationOrder(new[] { fromB, fromA }).ToList(); + + CollectionAssert.AreEqual(forward, reversed, + "input order must not decide the winner once full names tie"); + Assert.AreSame(fromA, forward[0], "assembly A sorts before assembly B"); + Assert.AreSame(fromB, forward[1], "assembly B registers last, so it would win a name collision"); + } + + private static Type EmitTypeInOwnAssembly(string assemblyName, string typeFullName) + { + AssemblyBuilder assembly = AssemblyBuilder.DefineDynamicAssembly( + new AssemblyName(assemblyName), AssemblyBuilderAccess.Run); + ModuleBuilder module = assembly.DefineDynamicModule(assemblyName); + TypeBuilder type = module.DefineType(typeFullName, TypeAttributes.Public); + return type.CreateType(); + } + [Test] public void DiscoverAllTools_DoesNotOverrideStoredFalse_ForBuiltInAutoRegisterFalseTool() { From eeb4f9044d039a0a5368c39d0ffb67f68f404591 Mon Sep 17 00:00:00 2001 From: Seungpyo1007 Date: Thu, 3 Sep 2026 03:15:49 +0900 Subject: [PATCH 09/84] feat: add Blender Bridge tool with a Generative-tab settings panel Unity talks straight to the BlenderMCP addon socket, so a Blender to Unity handoff is one blender_bridge call: export (GLB/FBX) from Blender, import through the shared model pipeline, place in the open scene and normalize the size from measured bounds. Other actions: status, scene/object info, viewport screenshot, run Python in Blender, check the blender-mcp checkout for updates, and sync its addon.py into Blender's addons folder. The informational "Blender -> Unity Handoff" row in the Asset Gen tab becomes a real panel: socket host/port with Test Connection, blender-mcp checkout and Blender addons dir (Select/Clear, resolved path and addon-in-sync state), and Sync Addon / Check Updates / Import Selection buttons. Settings live in EditorPrefs under MCPForUnity.Blender.* via BlenderBridgePrefs; no machine-specific defaults. BlenderDetection gains user addons dir discovery. Menu items under Window/MCP for Unity/Blender Bridge drive the same handler. --- .../Editor/Constants/EditorPrefKeys.cs | 7 + .../Editor/Helpers/BlenderBridgePrefs.cs | 85 +++ .../Editor/Helpers/BlenderBridgePrefs.cs.meta | 11 + .../Editor/Helpers/BlenderDetection.cs | 87 ++- .../Editor/MenuItems/BlenderBridgeMenu.cs | 57 ++ .../MenuItems/BlenderBridgeMenu.cs.meta | 11 + MCPForUnity/Editor/Services/Blender.meta | 8 + .../Services/Blender/BlenderSocketClient.cs | 130 +++++ .../Blender/BlenderSocketClient.cs.meta | 11 + MCPForUnity/Editor/Tools/Blender.meta | 8 + .../Editor/Tools/Blender/BlenderBridgeTool.cs | 527 ++++++++++++++++++ .../Tools/Blender/BlenderBridgeTool.cs.meta | 11 + .../Components/AssetGen/McpAssetGenSection.cs | 52 +- .../AssetGen/McpAssetGenSection.uxml | 40 ++ .../AssetGen/McpBlenderBridgePanel.cs | 269 +++++++++ .../AssetGen/McpBlenderBridgePanel.cs.meta | 11 + 16 files changed, 1282 insertions(+), 43 deletions(-) create mode 100644 MCPForUnity/Editor/Helpers/BlenderBridgePrefs.cs create mode 100644 MCPForUnity/Editor/Helpers/BlenderBridgePrefs.cs.meta create mode 100644 MCPForUnity/Editor/MenuItems/BlenderBridgeMenu.cs create mode 100644 MCPForUnity/Editor/MenuItems/BlenderBridgeMenu.cs.meta create mode 100644 MCPForUnity/Editor/Services/Blender.meta create mode 100644 MCPForUnity/Editor/Services/Blender/BlenderSocketClient.cs create mode 100644 MCPForUnity/Editor/Services/Blender/BlenderSocketClient.cs.meta create mode 100644 MCPForUnity/Editor/Tools/Blender.meta create mode 100644 MCPForUnity/Editor/Tools/Blender/BlenderBridgeTool.cs create mode 100644 MCPForUnity/Editor/Tools/Blender/BlenderBridgeTool.cs.meta create mode 100644 MCPForUnity/Editor/Windows/Components/AssetGen/McpBlenderBridgePanel.cs create mode 100644 MCPForUnity/Editor/Windows/Components/AssetGen/McpBlenderBridgePanel.cs.meta diff --git a/MCPForUnity/Editor/Constants/EditorPrefKeys.cs b/MCPForUnity/Editor/Constants/EditorPrefKeys.cs index 6f7ca9f04d..01e72b18f8 100644 --- a/MCPForUnity/Editor/Constants/EditorPrefKeys.cs +++ b/MCPForUnity/Editor/Constants/EditorPrefKeys.cs @@ -86,5 +86,12 @@ internal static class EditorPrefKeys internal const string AssetGenOutputRoot = "MCPForUnity.AssetGen.OutputRoot"; internal const string AssetGenAutoNormalize = "MCPForUnity.AssetGen.AutoNormalize"; internal const string AssetGenProviderEnabledPrefix = "MCPForUnity.AssetGen.Enabled."; + + // Blender Bridge (Asset Gen tab). Machine-specific, non-secret. An empty ForkPath means the + // addon sync / update-check features are not configured; import and screenshot still work. + internal const string BlenderHost = "MCPForUnity.Blender.Host"; + internal const string BlenderPort = "MCPForUnity.Blender.Port"; + internal const string BlenderForkPath = "MCPForUnity.Blender.ForkPath"; + internal const string BlenderAddonsDir = "MCPForUnity.Blender.AddonsDir"; } } diff --git a/MCPForUnity/Editor/Helpers/BlenderBridgePrefs.cs b/MCPForUnity/Editor/Helpers/BlenderBridgePrefs.cs new file mode 100644 index 0000000000..c66648e24f --- /dev/null +++ b/MCPForUnity/Editor/Helpers/BlenderBridgePrefs.cs @@ -0,0 +1,85 @@ +using System.IO; +using MCPForUnity.Editor.Constants; +using UnityEditor; + +namespace MCPForUnity.Editor.Helpers +{ + /// + /// Per-user, NON-SECRET configuration for the Blender Bridge (Asset Gen tab): where the + /// BlenderMCP addon socket listens, where the user's blender-mcp checkout lives, and where + /// Blender keeps its user addons. Nothing here is required for the bridge to talk to Blender; + /// the checkout path only unlocks addon sync and update checks. + /// + public static class BlenderBridgePrefs + { + public const string DefaultHost = "127.0.0.1"; + public const int DefaultPort = 9876; + public const string AddonFileName = "addon.py"; + + public static string Host + { + get => EditorPrefs.GetString(EditorPrefKeys.BlenderHost, DefaultHost); + set => SetOrDelete(EditorPrefKeys.BlenderHost, value); + } + + public static int Port + { + get => EditorPrefs.GetInt(EditorPrefKeys.BlenderPort, DefaultPort); + set => EditorPrefs.SetInt(EditorPrefKeys.BlenderPort, value > 0 && value <= 65535 ? value : DefaultPort); + } + + /// Local checkout of the blender-mcp repository (contains addon.py). Empty = not configured. + public static string ForkPath + { + get => NormalizePath(EditorPrefs.GetString(EditorPrefKeys.BlenderForkPath, string.Empty)); + set => SetOrDelete(EditorPrefKeys.BlenderForkPath, NormalizePath(value)); + } + + /// + /// Blender's user addons directory. Empty = auto-detect the newest + /// <user config>/Blender/<version>/scripts/addons that already has the addon installed. + /// + public static string AddonsDirOverride + { + get => NormalizePath(EditorPrefs.GetString(EditorPrefKeys.BlenderAddonsDir, string.Empty)); + set => SetOrDelete(EditorPrefKeys.BlenderAddonsDir, NormalizePath(value)); + } + + public static bool IsForkConfigured => !string.IsNullOrEmpty(ForkPath); + + public static string ForkAddonPath => IsForkConfigured ? ForkPath + "/" + AddonFileName : null; + + public static string ResolveAddonsDir() + { + string o = AddonsDirOverride; + return !string.IsNullOrEmpty(o) ? o : BlenderDetection.FindUserAddonsDir(AddonFileName); + } + + public static string InstalledAddonPath + { + get + { + string dir = ResolveAddonsDir(); + return string.IsNullOrEmpty(dir) ? null : dir + "/" + AddonFileName; + } + } + + /// True when the checkout path points at a folder that actually contains addon.py. + public static bool IsValidForkPath(string path) + { + string p = NormalizePath(path); + return !string.IsNullOrEmpty(p) && File.Exists(Path.Combine(p, AddonFileName)); + } + + internal static string NormalizePath(string value) + { + return (value ?? string.Empty).Trim().Replace('\\', '/').TrimEnd('/'); + } + + private static void SetOrDelete(string key, string value) + { + if (string.IsNullOrWhiteSpace(value)) EditorPrefs.DeleteKey(key); + else EditorPrefs.SetString(key, value.Trim()); + } + } +} diff --git a/MCPForUnity/Editor/Helpers/BlenderBridgePrefs.cs.meta b/MCPForUnity/Editor/Helpers/BlenderBridgePrefs.cs.meta new file mode 100644 index 0000000000..5dafa8364f --- /dev/null +++ b/MCPForUnity/Editor/Helpers/BlenderBridgePrefs.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 029b420c97f34c738e98b875fe56bf73 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Helpers/BlenderDetection.cs b/MCPForUnity/Editor/Helpers/BlenderDetection.cs index 6af74e344e..9e11ee000e 100644 --- a/MCPForUnity/Editor/Helpers/BlenderDetection.cs +++ b/MCPForUnity/Editor/Helpers/BlenderDetection.cs @@ -1,14 +1,16 @@ using System; using System.Collections.Generic; using System.IO; +using System.Linq; using UnityEngine; namespace MCPForUnity.Editor.Helpers { /// - /// Best-effort detection of a locally installed Blender application, for the Asset Gen tab's - /// "Blender → Unity handoff" hint. This finds the Blender APP only — it cannot tell whether the - /// BlenderMCP server is configured in the user's AI client (that lives outside Unity). + /// Best-effort detection of a locally installed Blender application and of Blender's per-user + /// addons folders, for the Asset Gen tab's Blender Bridge. This finds the Blender APP and its + /// config folders only — whether the BlenderMCP addon is running is a socket question answered + /// by the bridge itself. /// internal static class BlenderDetection { @@ -76,5 +78,84 @@ internal static IEnumerable CandidatePaths() } return list; } + + /// + /// Blender's per-user config roots (the folder that holds one subfolder per Blender version): + /// %APPDATA%/Blender Foundation/Blender on Windows, ~/Library/Application Support/Blender on + /// macOS, $XDG_CONFIG_HOME/blender or ~/.config/blender on Linux. + /// + internal static IEnumerable UserConfigRoots() + { + var list = new List(); + switch (Application.platform) + { + case RuntimePlatform.WindowsEditor: + string appData = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData); + if (!string.IsNullOrEmpty(appData)) + list.Add(Path.Combine(appData, "Blender Foundation", "Blender")); + break; + case RuntimePlatform.OSXEditor: + string home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + if (!string.IsNullOrEmpty(home)) + list.Add(Path.Combine(home, "Library", "Application Support", "Blender")); + break; + case RuntimePlatform.LinuxEditor: + string xdg = Environment.GetEnvironmentVariable("XDG_CONFIG_HOME"); + string linuxHome = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + if (!string.IsNullOrEmpty(xdg)) list.Add(Path.Combine(xdg, "blender")); + else if (!string.IsNullOrEmpty(linuxHome)) list.Add(Path.Combine(linuxHome, ".config", "blender")); + break; + } + return list; + } + + /// <root>/<X.Y>/scripts/addons for every versioned config folder, newest version first. + internal static IEnumerable UserAddonsDirs() + { + var found = new List<(Version Ver, string Dir)>(); + foreach (string root in UserConfigRoots()) + { + try + { + if (!Directory.Exists(root)) continue; + foreach (string d in Directory.GetDirectories(root)) + { + Version v = ParseVersion(Path.GetFileName(d)); + if (v != null) found.Add((v, Path.Combine(d, "scripts", "addons"))); + } + } + catch { /* unreadable dir; ignore */ } + } + return found.OrderByDescending(x => x.Ver).Select(x => x.Dir.Replace('\\', '/')).ToList(); + } + + /// Newest user addons dir that already contains , else the newest one, else null. + internal static string FindUserAddonsDir(string fileName) + { + try { return PickAddonsDir(UserAddonsDirs(), File.Exists, fileName); } + catch { return null; } + } + + /// Pure core of . Testable. + internal static string PickAddonsDir(IEnumerable dirsNewestFirst, Func fileExists, string fileName) + { + if (dirsNewestFirst == null) return null; + string first = null; + foreach (string d in dirsNewestFirst) + { + if (string.IsNullOrEmpty(d)) continue; + first ??= d; + if (!string.IsNullOrEmpty(fileName) && fileExists != null && fileExists(d.TrimEnd('/') + "/" + fileName)) + return d; + } + return first; + } + + /// Parses a Blender version folder name ("4.2", "5.2") into a comparable Version; null if it is not one. + internal static Version ParseVersion(string name) + { + if (string.IsNullOrEmpty(name)) return null; + return Version.TryParse(name.Contains('.') ? name : name + ".0", out Version v) ? v : null; + } } } diff --git a/MCPForUnity/Editor/MenuItems/BlenderBridgeMenu.cs b/MCPForUnity/Editor/MenuItems/BlenderBridgeMenu.cs new file mode 100644 index 0000000000..dcd1114941 --- /dev/null +++ b/MCPForUnity/Editor/MenuItems/BlenderBridgeMenu.cs @@ -0,0 +1,57 @@ +using MCPForUnity.Editor.Constants; +using MCPForUnity.Editor.Helpers; +using MCPForUnity.Editor.Tools.Blender; +using MCPForUnity.Editor.Windows; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using UnityEditor; + +namespace MCPForUnity.Editor.MenuItems +{ + /// + /// Menu entry points that drive the same code path as the blender_bridge tool, so the + /// Blender handoff works without any AI client attached. Settings live in the Generative tab. + /// + public static class BlenderBridgeMenu + { + private const string Root = ProductInfo.MenuRoot + "/Blender Bridge/"; + + [MenuItem(Root + "Import Selection From Blender (GLB)", priority = 20)] + private static void ImportSelection() + { + Run(new JObject { ["action"] = "import_model", ["selection_only"] = true, ["format"] = "glb" }); + } + + [MenuItem(Root + "Import Whole Scene From Blender (GLB)", priority = 21)] + private static void ImportScene() + { + Run(new JObject { ["action"] = "import_model", ["format"] = "glb", ["name"] = "BlenderScene" }); + } + + [MenuItem(Root + "Blender Viewport Screenshot", priority = 22)] + private static void Screenshot() + { + JObject r = Run(new JObject { ["action"] = "screenshot" }); + string path = r?["data"]?["path"]?.ToString(); + if (!string.IsNullOrEmpty(path)) EditorUtility.RevealInFinder(path); + } + + [MenuItem(Root + "Settings...", priority = 40)] + private static void OpenSettings() + { + MCPForUnityEditorWindow.ShowWindow(); + McpLog.Info("Blender Bridge settings are in the Generative tab of the MCP for Unity window."); + } + + private static JObject Run(JObject parameters) + { + object result = BlenderBridgeTool.HandleCommand(parameters); + JObject json = JObject.FromObject(result); + bool ok = json.Value("success") ?? false; + string text = json.ToString(Formatting.Indented); + if (ok) McpLog.Info($"[Blender Bridge] {parameters["action"]}: {json["message"]}\n{text}"); + else McpLog.Error($"[Blender Bridge] {parameters["action"]} failed: {json["error"]}\n{text}"); + return json; + } + } +} diff --git a/MCPForUnity/Editor/MenuItems/BlenderBridgeMenu.cs.meta b/MCPForUnity/Editor/MenuItems/BlenderBridgeMenu.cs.meta new file mode 100644 index 0000000000..b490058fb9 --- /dev/null +++ b/MCPForUnity/Editor/MenuItems/BlenderBridgeMenu.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: cf5a30eff9b34262b2e15cc23f196d12 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Services/Blender.meta b/MCPForUnity/Editor/Services/Blender.meta new file mode 100644 index 0000000000..c2029bf052 --- /dev/null +++ b/MCPForUnity/Editor/Services/Blender.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 5171d93ea027470b96c8930306ca0c60 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Services/Blender/BlenderSocketClient.cs b/MCPForUnity/Editor/Services/Blender/BlenderSocketClient.cs new file mode 100644 index 0000000000..d3acfd36a7 --- /dev/null +++ b/MCPForUnity/Editor/Services/Blender/BlenderSocketClient.cs @@ -0,0 +1,130 @@ +using System; +using System.IO; +using System.Net.Sockets; +using System.Text; +using MCPForUnity.Editor.Helpers; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace MCPForUnity.Editor.Services.Blender +{ + /// + /// Minimal client for the BlenderMCP addon socket. Protocol: one JSON object + /// {"type": ..., "params": {...}} per request and one JSON object + /// {"status": "success"|"error", "result"|"message": ...} back, with no length framing — + /// the addon parses whenever the accumulated bytes form valid JSON, so this does the same. + /// A fresh connection is used per command so it never interleaves with the AI client's + /// own BlenderMCP server talking to the same addon. + /// + public static class BlenderSocketClient + { + private const int ConnectTimeoutSeconds = 3; + + public static JToken Send(string type, JObject @params = null, int timeoutSeconds = 60) + { + string host = BlenderBridgePrefs.Host; + int port = BlenderBridgePrefs.Port; + + var request = new JObject { ["type"] = type, ["params"] = @params ?? new JObject() }; + byte[] payload = Encoding.UTF8.GetBytes(request.ToString(Formatting.None)); + + using var client = new TcpClient(); + IAsyncResult connect = client.BeginConnect(host, port, null, null); + if (!connect.AsyncWaitHandle.WaitOne(TimeSpan.FromSeconds(ConnectTimeoutSeconds)) || !client.Connected) + { + throw new BlenderUnavailableException( + $"Blender addon not reachable at {host}:{port}. Start Blender and press " + + "'Connect to MCP server' in the BlenderMCP sidebar (N panel)."); + } + client.EndConnect(connect); + + using NetworkStream stream = client.GetStream(); + stream.ReadTimeout = Math.Max(1, timeoutSeconds) * 1000; + stream.Write(payload, 0, payload.Length); + stream.Flush(); + + var buffer = new MemoryStream(); + var chunk = new byte[65536]; + DateTime deadline = DateTime.UtcNow.AddSeconds(timeoutSeconds); + + while (true) + { + int n; + try + { + n = stream.Read(chunk, 0, chunk.Length); + } + catch (IOException e) + { + throw new TimeoutException( + $"Timed out after {timeoutSeconds}s waiting for Blender to answer '{type}'.", e); + } + + if (n <= 0) break; + buffer.Write(chunk, 0, n); + + if (TryParseResponse(buffer.GetBuffer(), (int)buffer.Length, out JObject parsed)) return Unwrap(parsed, type); + if (DateTime.UtcNow > deadline) + throw new TimeoutException($"Timed out after {timeoutSeconds}s waiting for Blender to answer '{type}'."); + } + + if (TryParseResponse(buffer.GetBuffer(), (int)buffer.Length, out JObject final)) return Unwrap(final, type); + throw new IOException($"Blender closed the connection before a complete response to '{type}' arrived."); + } + + /// Runs Python inside Blender and returns its captured stdout. + public static string RunPython(string code, int timeoutSeconds = 120) + { + JToken result = Send("execute_code", new JObject { ["code"] = code }, timeoutSeconds); + return result?["result"]?.ToString() ?? string.Empty; + } + + public static bool IsReachable(out string error) + { + try + { + Send("get_scene_info", null, 10); + error = null; + return true; + } + catch (Exception e) + { + error = e.Message; + return false; + } + } + + /// True once the bytes received so far form one complete JSON object. + internal static bool TryParseResponse(byte[] buffer, int length, out JObject obj) + { + try + { + obj = JObject.Parse(Encoding.UTF8.GetString(buffer, 0, length)); + return true; + } + catch + { + obj = null; + return false; + } + } + + /// Returns the addon's "result" payload, or throws when it reported an error. + internal static JToken Unwrap(JObject response, string type) + { + if (string.Equals((string)response["status"], "error", StringComparison.OrdinalIgnoreCase)) + throw new BlenderCommandException($"Blender '{type}' failed: {(string)response["message"] ?? "unknown error"}"); + return response["result"]; + } + } + + public class BlenderUnavailableException : Exception + { + public BlenderUnavailableException(string message) : base(message) { } + } + + public class BlenderCommandException : Exception + { + public BlenderCommandException(string message) : base(message) { } + } +} diff --git a/MCPForUnity/Editor/Services/Blender/BlenderSocketClient.cs.meta b/MCPForUnity/Editor/Services/Blender/BlenderSocketClient.cs.meta new file mode 100644 index 0000000000..73c1144e02 --- /dev/null +++ b/MCPForUnity/Editor/Services/Blender/BlenderSocketClient.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 6c83ed8aaae34a829fc59df7c1a53948 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Tools/Blender.meta b/MCPForUnity/Editor/Tools/Blender.meta new file mode 100644 index 0000000000..2f34970271 --- /dev/null +++ b/MCPForUnity/Editor/Tools/Blender.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: d61fccfc880e4bd38412f8705fc73104 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Tools/Blender/BlenderBridgeTool.cs b/MCPForUnity/Editor/Tools/Blender/BlenderBridgeTool.cs new file mode 100644 index 0000000000..3a9267fefc --- /dev/null +++ b/MCPForUnity/Editor/Tools/Blender/BlenderBridgeTool.cs @@ -0,0 +1,527 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Security.Cryptography; +using System.Text; +using MCPForUnity.Editor.Helpers; +using MCPForUnity.Editor.Security; +using MCPForUnity.Editor.Services.Blender; +using MCPForUnity.Editor.Tools.AssetGen; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using UnityEditor; +using UnityEditor.SceneManagement; +using UnityEngine; + +namespace MCPForUnity.Editor.Tools.Blender +{ + /// + /// Lets the Unity Editor talk straight to the BlenderMCP addon socket, so a Blender → Unity + /// handoff is one call (export → import through the shared model pipeline → place → normalize) + /// instead of an AI-orchestrated multi-step dance. Also drives the Blender Bridge panel in the + /// Asset Gen tab and the Window/MCP for Unity/Blender Bridge menu. Carries no API keys. + /// + [McpForUnityTool("blender_bridge", AutoRegister = false, Group = "asset_gen", + Description = "Bridge to a running Blender with the BlenderMCP addon: status, scene/object info, viewport " + + "screenshot, run Python in Blender, import a model (export → import → place → normalize), " + + "check the blender-mcp checkout for updates, and sync its addon into Blender.")] + public static class BlenderBridgeTool + { + private static readonly string[] ValidActions = + { "status", "scene_info", "object_info", "screenshot", "run_python", "import_model", "check_updates", "sync_addon" }; + + private const string NotConfiguredMessage = + "The blender-mcp checkout is not set. Open Window > MCP for Unity > Generative > Blender Bridge " + + "and pick the folder that contains addon.py."; + + public static object HandleCommand(JObject @params) + { + if (@params == null) return new ErrorResponse("Parameters cannot be null."); + var p = new ToolParams(@params); + string action = (p.Get("action") ?? "status").Trim().ToLowerInvariant(); + int timeout = Math.Max(5, p.GetInt("timeout_seconds", 180) ?? 180); + + try + { + switch (action) + { + case "status": return Status(); + case "scene_info": + return new SuccessResponse("Retrieved Blender scene info.", + BlenderSocketClient.Send("get_scene_info", null, timeout)); + case "object_info": + { + string objectName = p.Get("object_name"); + if (string.IsNullOrWhiteSpace(objectName)) + return new ErrorResponse("'object_name' is required for object_info."); + return new SuccessResponse($"Retrieved info for '{objectName}'.", + BlenderSocketClient.Send("get_object_info", new JObject { ["object_name"] = objectName }, timeout)); + } + case "screenshot": return Screenshot(p, timeout); + case "run_python": + { + string code = p.Get("code"); + if (string.IsNullOrWhiteSpace(code)) return new ErrorResponse("'code' is required for run_python."); + string stdout = BlenderSocketClient.RunPython(code, timeout); + return new SuccessResponse("Executed Python in Blender.", new { stdout }); + } + case "import_model": return ImportModel(p, timeout); + case "check_updates": return CheckUpdates(); + case "sync_addon": return SyncAddon(p.GetBool("force", false)); + default: + return new ErrorResponse($"Unknown action '{action}'. Valid: {string.Join(", ", ValidActions)}."); + } + } + catch (BlenderUnavailableException e) + { + return new ErrorResponse(e.Message); + } + catch (Exception e) + { + return new ErrorResponse(SecretRedactor.Scrub($"blender_bridge '{action}' failed: {e.Message}")); + } + } + + // ------------------------------------------------------------------ status + + private static object Status() + { + bool reachable = BlenderSocketClient.IsReachable(out string error); + JToken scene = null; + if (reachable) + { + try { scene = BlenderSocketClient.Send("get_scene_info", null, 10); } + catch (Exception e) { error = e.Message; } + } + + string forkAddon = BlenderBridgePrefs.ForkAddonPath; + string installedAddon = BlenderBridgePrefs.InstalledAddonPath; + string forkMd5 = forkAddon != null && File.Exists(forkAddon) ? FileMd5(forkAddon) : null; + string installedMd5 = installedAddon != null && File.Exists(installedAddon) ? FileMd5(installedAddon) : null; + + var data = new JObject + { + ["blender_reachable"] = reachable, + ["blender_installed"] = BlenderDetection.IsInstalled(), + ["endpoint"] = $"{BlenderBridgePrefs.Host}:{BlenderBridgePrefs.Port}", + ["error"] = reachable ? null : error, + ["scene_name"] = scene?["name"], + ["object_count"] = scene?["object_count"], + ["fork_configured"] = BlenderBridgePrefs.IsForkConfigured, + ["fork_path"] = BlenderBridgePrefs.ForkPath, + ["fork_addon_found"] = forkMd5 != null, + ["installed_addon_path"] = installedAddon, + ["installed_addon_found"] = installedMd5 != null, + ["addon_in_sync"] = forkMd5 != null && installedMd5 != null && forkMd5 == installedMd5, + }; + string msg = reachable + ? $"Blender reachable. Scene '{scene?["name"]}' with {scene?["object_count"]} objects." + : "Blender not reachable."; + return new SuccessResponse(msg, data); + } + + // -------------------------------------------------------------- screenshot + + private static object Screenshot(ToolParams p, int timeout) + { + int maxSize = Math.Max(64, p.GetInt("max_size", 1000) ?? 1000); + string outputFolder = p.Get("output_folder"); + + string dir = Path.Combine(ProjectRoot(), "Library", "BlenderBridge"); + Directory.CreateDirectory(dir); + string file = Path.Combine(dir, $"blender_viewport_{DateTime.Now:yyyyMMdd_HHmmss}.png").Replace('\\', '/'); + + JToken result = BlenderSocketClient.Send("get_viewport_screenshot", + new JObject { ["max_size"] = maxSize, ["filepath"] = file, ["format"] = "png" }, timeout); + + if (!File.Exists(file)) + return new ErrorResponse($"Blender did not write a screenshot to {file}. Response: {result}"); + + string assetPath = null; + if (!string.IsNullOrWhiteSpace(outputFolder)) + { + string rel = outputFolder.Replace('\\', '/').TrimEnd('/'); + if (!rel.StartsWith("Assets/") && rel != "Assets") + return new ErrorResponse("'output_folder' must be under Assets/."); + Directory.CreateDirectory(Path.Combine(ProjectRoot(), rel)); + assetPath = $"{rel}/{Path.GetFileName(file)}"; + File.Copy(file, Path.Combine(ProjectRoot(), assetPath), true); + AssetDatabase.ImportAsset(assetPath); + } + + return new SuccessResponse("Captured Blender viewport.", new JObject + { + ["path"] = file, + ["asset_path"] = assetPath, + ["width"] = result?["width"], + ["height"] = result?["height"], + ["method"] = result?["method"], + }); + } + + // ------------------------------------------------------------ import_model + + private static object ImportModel(ToolParams p, int timeout) + { + string fmt = (p.Get("format") ?? "glb").Trim().ToLowerInvariant(); + if (fmt != "glb" && fmt != "fbx") return new ErrorResponse("'format' must be glb or fbx."); + + string[] names = p.GetStringArray("object_names")?.Where(n => !string.IsNullOrWhiteSpace(n)).ToArray(); + bool selectionOnly = p.GetBool("selection_only", false); + bool applyModifiers = p.GetBool("apply_modifiers", true); + bool place = p.GetBool("place_in_scene", true); + float target = p.GetFloat("target_size", 0f) ?? 0f; + string outputFolder = p.Get("output_folder"); + string animationType = p.Get("animation_type"); + + string name = p.Get("name"); + if (string.IsNullOrWhiteSpace(name)) + name = names != null && names.Length == 1 ? names[0] : "BlenderModel"; + name = SanitizeName(name); + + // 1. Export from Blender to a temp file. + string exportDir = Path.Combine(Path.GetTempPath(), "BlenderBridge"); + Directory.CreateDirectory(exportDir); + string exportPath = Path.Combine(exportDir, $"{name}_{DateTime.Now:yyyyMMdd_HHmmss}.{fmt}").Replace('\\', '/'); + + string script = BuildExportScript(exportPath, names, selectionOnly, applyModifiers, fmt); + string stdout = BlenderSocketClient.RunPython(script, timeout); + + if (!File.Exists(exportPath)) + return new ErrorResponse($"Blender did not produce {exportPath}. Blender output: {Truncate(stdout, 800)}"); + + // 2. Import through the shared pipeline (staging under Assets/, glTFast/FBX, material setup). + var importParams = new JObject + { + ["sourcePath"] = exportPath, + ["name"] = name, + ["targetSize"] = target > 0f ? target : 1f, + }; + if (!string.IsNullOrWhiteSpace(outputFolder)) importParams["outputFolder"] = outputFolder; + if (!string.IsNullOrWhiteSpace(animationType)) importParams["animationType"] = animationType; + + JObject importResult = JObject.FromObject(ImportModelFile.HandleCommand(importParams)); + if (!(importResult.Value("success") ?? false)) + return new ErrorResponse($"Import failed: {importResult["error"] ?? importResult["message"]}"); + + string assetPath = importResult["data"]?["asset_path"]?.ToString(); + var data = new JObject + { + ["asset_path"] = assetPath, + ["asset_guid"] = importResult["data"]?["asset_guid"], + ["export_path"] = exportPath, + ["format"] = fmt, + ["blender_output"] = Truncate(stdout, 400), + ["placed"] = false, + }; + + if (!place || string.IsNullOrEmpty(assetPath)) + return new SuccessResponse($"Imported {assetPath} (not placed).", data); + + // 3. Place in the open scene and normalize size from measured bounds. Blender exports + // commonly land far off scale, so measuring the placed instance beats trusting the importer. + GameObject prefab = AssetDatabase.LoadAssetAtPath(assetPath); + if (prefab == null) + { + data["note"] = "Asset has no GameObject root to instantiate."; + return new SuccessResponse($"Imported {assetPath} but could not instantiate it.", data); + } + + GameObject go = PrefabUtility.InstantiatePrefab(prefab) as GameObject ?? UnityEngine.Object.Instantiate(prefab); + go.name = name; + Undo.RegisterCreatedObjectUndo(go, "Import from Blender"); + go.transform.position = ParsePosition(p.GetRaw("position")); + + float scaleFactor = 1f; + if (target > 0f && TryGetWorldBounds(go, out Bounds b0)) + { + float maxDim = Mathf.Max(b0.size.x, Mathf.Max(b0.size.y, b0.size.z)); + if (maxDim > 1e-4f) + { + scaleFactor = target / maxDim; + go.transform.localScale *= scaleFactor; + } + } + + EditorSceneManager.MarkSceneDirty(go.scene); + Selection.activeGameObject = go; + + data["placed"] = true; + data["game_object"] = go.name; + data["scene"] = go.scene.name; + data["scale_factor_applied"] = scaleFactor; + if (TryGetWorldBounds(go, out Bounds b1)) + { + data["bounds_size"] = new JArray(b1.size.x, b1.size.y, b1.size.z); + data["bounds_center"] = new JArray(b1.center.x, b1.center.y, b1.center.z); + } + return new SuccessResponse($"Imported {assetPath} and placed '{go.name}' in the scene.", data); + } + + private static string BuildExportScript(string outPath, string[] names, bool selectionOnly, bool applyModifiers, string fmt) + { + string namesLiteral = names == null || names.Length == 0 + ? "[]" + : new JArray(names.Cast().ToArray()).ToString(Formatting.None); + + const string template = @" +import bpy, os, json +out = r'__OUT__' +names = __NAMES__ +selection_only = __SELONLY__ +apply_mods = __APPLY__ +fmt = '__FMT__' +os.makedirs(os.path.dirname(out), exist_ok=True) +try: + if bpy.context.object and bpy.context.object.mode != 'OBJECT': + bpy.ops.object.mode_set(mode='OBJECT') +except Exception: + pass +use_sel = False +if names: + missing = [n for n in names if bpy.data.objects.get(n) is None] + if missing: + raise Exception('Objects not found in Blender: ' + ', '.join(missing)) + bpy.ops.object.select_all(action='DESELECT') + for n in names: + o = bpy.data.objects[n] + o.select_set(True) + for c in o.children_recursive: + c.select_set(True) + bpy.context.view_layer.objects.active = bpy.data.objects[names[0]] + use_sel = True +elif selection_only: + if not bpy.context.selected_objects: + raise Exception('Nothing is selected in Blender and no object_names were given.') + use_sel = True +if fmt == 'glb': + bpy.ops.export_scene.gltf(filepath=out, export_format='GLB', use_selection=use_sel, + use_active_scene=True, export_apply=apply_mods, + export_animations=True, export_skins=True, export_morph=True, + export_yup=True) +else: + bpy.ops.export_scene.fbx(filepath=out, use_selection=use_sel, apply_unit_scale=True, + bake_space_transform=apply_mods, use_mesh_modifiers=apply_mods, + path_mode='COPY', embed_textures=True) +print(json.dumps({'path': out, 'bytes': os.path.getsize(out), 'selection_only': use_sel, + 'exported': [o.name for o in (bpy.context.selected_objects if use_sel else bpy.context.scene.objects)]})) +"; + return template + .Replace("__OUT__", outPath) + .Replace("__NAMES__", namesLiteral) + .Replace("__SELONLY__", selectionOnly ? "True" : "False") + .Replace("__APPLY__", applyModifiers ? "True" : "False") + .Replace("__FMT__", fmt); + } + + // ----------------------------------------------------------- check_updates + + private static object CheckUpdates() + { + if (!BlenderBridgePrefs.IsForkConfigured) return new ErrorResponse(NotConfiguredMessage); + string fork = BlenderBridgePrefs.ForkPath; + if (!Directory.Exists(Path.Combine(fork, ".git"))) + return new ErrorResponse($"'{fork}' is not a git checkout (no .git folder); check_updates needs one."); + + if (!TryGit(fork, "--version", out _, out string gitErr, 10000)) + return new ErrorResponse($"git is not available: {gitErr}"); + + TryGit(fork, "log -1 --format=%h%x09%ad%x09%s --date=short", out string head, out _); + TryGit(fork, "status --porcelain", out string porcelain, out _); + TryGit(fork, "remote", out string remotesRaw, out _); + var remotes = remotesRaw.Split(new[] { '\n', '\r' }, StringSplitOptions.RemoveEmptyEntries) + .Select(r => r.Trim()).Where(r => r.Length > 0).ToList(); + + var perRemote = new JArray(); + int totalBehind = 0; + foreach (string remote in remotes.OrderBy(r => r == "upstream" ? 0 : r == "origin" ? 1 : 2)) + { + var entry = new JObject { ["remote"] = remote }; + TryGit(fork, $"remote get-url {remote}", out string url, out _); + entry["url"] = url.Trim(); + + bool fetched = TryGit(fork, $"fetch --quiet {remote}", out _, out string fetchErr, 90000); + entry["fetched"] = fetched; + if (!fetched) entry["fetch_error"] = Truncate(fetchErr, 300); + + string branch = "main"; + if (TryGit(fork, $"symbolic-ref --short refs/remotes/{remote}/HEAD", out string sym, out _) && sym.Trim().Contains('/')) + branch = sym.Trim().Substring(sym.Trim().IndexOf('/') + 1); + else if (!TryGit(fork, $"rev-parse --verify --quiet refs/remotes/{remote}/main", out _, out _) + && TryGit(fork, $"rev-parse --verify --quiet refs/remotes/{remote}/master", out _, out _)) + branch = "master"; + entry["branch"] = branch; + + if (TryGit(fork, $"rev-list --left-right --count HEAD...{remote}/{branch}", out string counts, out string cErr)) + { + var parts = counts.Trim().Split('\t', ' '); + int ahead = parts.Length > 0 && int.TryParse(parts[0], out int a) ? a : 0; + int behind = parts.Length > 1 && int.TryParse(parts[1], out int bb) ? bb : 0; + entry["local_ahead"] = ahead; + entry["behind"] = behind; + if (remote == "upstream" || remotes.Count == 1) totalBehind += behind; + + TryGit(fork, $"log --format=%h%x20%ad%x20%s --date=short -n 20 HEAD..{remote}/{branch}", out string log, out _); + entry["new_commits"] = new JArray(log.Split(new[] { '\n', '\r' }, StringSplitOptions.RemoveEmptyEntries)); + } + else + { + entry["error"] = Truncate(cErr, 300); + } + perRemote.Add(entry); + } + + string forkAddon = BlenderBridgePrefs.ForkAddonPath; + string installedAddon = BlenderBridgePrefs.InstalledAddonPath; + string forkMd5 = File.Exists(forkAddon) ? FileMd5(forkAddon) : null; + string installedMd5 = installedAddon != null && File.Exists(installedAddon) ? FileMd5(installedAddon) : null; + bool addonInSync = forkMd5 != null && forkMd5 == installedMd5; + + int dirty = porcelain.Split(new[] { '\n', '\r' }, StringSplitOptions.RemoveEmptyEntries).Length; + var recommendations = new List(); + if (totalBehind > 0) recommendations.Add($"Checkout is {totalBehind} commit(s) behind upstream: merge upstream into it."); + if (!addonInSync) recommendations.Add("Installed Blender addon differs from the checkout's addon.py: run sync_addon, then restart Blender."); + if (dirty > 0) recommendations.Add($"Checkout has {dirty} uncommitted change(s)."); + if (recommendations.Count == 0) recommendations.Add("Everything is up to date."); + + var data = new JObject + { + ["fork_path"] = fork, + ["head"] = head.Trim(), + ["uncommitted_changes"] = dirty, + ["remotes"] = perRemote, + ["fork_addon_md5"] = forkMd5, + ["installed_addon_path"] = installedAddon, + ["installed_addon_md5"] = installedMd5, + ["addon_in_sync"] = addonInSync, + ["recommendations"] = new JArray(recommendations), + }; + return new SuccessResponse(string.Join(" ", recommendations), data); + } + + // -------------------------------------------------------------- sync_addon + + private static object SyncAddon(bool force) + { + if (!BlenderBridgePrefs.IsForkConfigured) return new ErrorResponse(NotConfiguredMessage); + string src = BlenderBridgePrefs.ForkAddonPath; + string dst = BlenderBridgePrefs.InstalledAddonPath; + if (!File.Exists(src)) return new ErrorResponse($"addon.py not found in the checkout at {src}."); + if (dst == null) + return new ErrorResponse("Could not locate Blender's user addons directory. Set it in Window > MCP for Unity > Generative > Blender Bridge."); + + string srcMd5 = FileMd5(src); + string dstMd5 = File.Exists(dst) ? FileMd5(dst) : null; + if (!force && srcMd5 == dstMd5) + return new SuccessResponse("Installed addon already matches the checkout; nothing copied.", + new { source = src, destination = dst, md5 = srcMd5, copied = false }); + + Directory.CreateDirectory(Path.GetDirectoryName(dst)); + string backup = null; + if (File.Exists(dst)) + { + backup = dst + ".bak"; + File.Copy(dst, backup, true); + } + File.Copy(src, dst, true); + + return new SuccessResponse( + "Copied addon.py into Blender. Restart Blender (or Reload Scripts) and press 'Connect to MCP server' again.", + new { source = src, destination = dst, backup, previous_md5 = dstMd5, new_md5 = srcMd5, copied = true }); + } + + // ------------------------------------------------------------------ helpers + + private static bool TryGit(string workingDir, string args, out string stdout, out string stderr, int timeoutMs = 30000) + { + stdout = string.Empty; + stderr = string.Empty; + try + { + var psi = new ProcessStartInfo("git", args) + { + WorkingDirectory = workingDir, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + StandardOutputEncoding = Encoding.UTF8, + StandardErrorEncoding = Encoding.UTF8, + }; + psi.EnvironmentVariables["GIT_TERMINAL_PROMPT"] = "0"; + using var proc = Process.Start(psi); + if (proc == null) { stderr = "failed to start git"; return false; } + var outTask = proc.StandardOutput.ReadToEndAsync(); + var errTask = proc.StandardError.ReadToEndAsync(); + if (!proc.WaitForExit(timeoutMs)) + { + try { proc.Kill(); } catch { /* already gone */ } + stderr = $"git {args} timed out after {timeoutMs} ms"; + return false; + } + stdout = outTask.Result; + stderr = errTask.Result; + return proc.ExitCode == 0; + } + catch (Exception e) + { + stderr = e.Message; + return false; + } + } + + private static string ProjectRoot() => Path.GetDirectoryName(Application.dataPath).Replace('\\', '/'); + + /// Lower-case hex MD5 of a file; shared with the Asset Gen panel's addon-sync indicator. + internal static string FileMd5(string path) + { + using var md5 = MD5.Create(); + using var fs = File.OpenRead(path); + return BitConverter.ToString(md5.ComputeHash(fs)).Replace("-", "").ToLowerInvariant(); + } + + private static string SanitizeName(string raw) + { + var invalid = Path.GetInvalidFileNameChars(); + var sb = new StringBuilder(); + foreach (char c in raw.Trim()) + sb.Append(invalid.Contains(c) || c == '/' || c == '\\' ? '_' : c); + string s = sb.ToString().Trim('_', '.', ' '); + return string.IsNullOrEmpty(s) ? "BlenderModel" : s; + } + + private static Vector3 ParsePosition(JToken token) + { + if (token == null || token.Type == JTokenType.Null) return Vector3.zero; + JArray arr = token as JArray; + if (arr == null && token.Type == JTokenType.String) + { + try { arr = JArray.Parse(token.ToString()); } catch { return Vector3.zero; } + } + if (arr == null || arr.Count < 3) return Vector3.zero; + return new Vector3(arr[0].Value(), arr[1].Value(), arr[2].Value()); + } + + private static bool TryGetWorldBounds(GameObject go, out Bounds bounds) + { + var renderers = go.GetComponentsInChildren(true); + if (renderers.Length == 0) + { + bounds = default; + return false; + } + bounds = renderers[0].bounds; + for (int i = 1; i < renderers.Length; i++) bounds.Encapsulate(renderers[i].bounds); + return true; + } + + private static string Truncate(string s, int max) + { + if (string.IsNullOrEmpty(s)) return s ?? string.Empty; + s = s.Trim(); + return s.Length <= max ? s : s.Substring(0, max) + "…"; + } + } +} diff --git a/MCPForUnity/Editor/Tools/Blender/BlenderBridgeTool.cs.meta b/MCPForUnity/Editor/Tools/Blender/BlenderBridgeTool.cs.meta new file mode 100644 index 0000000000..34c3f064f1 --- /dev/null +++ b/MCPForUnity/Editor/Tools/Blender/BlenderBridgeTool.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: b800c597408744668544a1b31f757ddd +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Windows/Components/AssetGen/McpAssetGenSection.cs b/MCPForUnity/Editor/Windows/Components/AssetGen/McpAssetGenSection.cs index 6b0e6d1132..3319493031 100644 --- a/MCPForUnity/Editor/Windows/Components/AssetGen/McpAssetGenSection.cs +++ b/MCPForUnity/Editor/Windows/Components/AssetGen/McpAssetGenSection.cs @@ -13,7 +13,9 @@ namespace MCPForUnity.Editor.Windows.Components.AssetGen /// Controller for the AI Asset Generation settings tab. This tab is CONFIG ONLY: /// it lets users enter/clear per-provider API keys, toggle providers on/off, /// presence-check a key, and set non-secret generation preferences. - /// Generation itself is never triggered here — only via MCP tools / CLI. + /// Generation itself is never triggered here — only via MCP tools / CLI. The one exception is + /// the Blender Bridge block (): its buttons run local socket + /// and file operations against a Blender on this machine, never a paid provider call. /// /// Keys are written to the OS secure store (), never to /// EditorPrefs or the project. The stored key is never read back into the field; only @@ -44,6 +46,7 @@ private static readonly (string Id, string Label)[] ImageProviders = private Toggle autoNormalizeToggle; private Button refreshButton; private Label refreshStatusLabel; + private McpBlenderBridgePanel blenderPanel; // Per-provider enable toggles for the GLB-capable (model) providers, used to // recompute the glTFast notice when a toggle changes. @@ -68,6 +71,9 @@ private void CacheUIElements() autoNormalizeToggle = Root.Q("assetgen-auto-normalize"); refreshButton = Root.Q