diff --git a/Unity-MCP-Plugin/Assets/Plugins/NuGet/McpPlugin.Common.dll b/Unity-MCP-Plugin/Assets/Plugins/NuGet/McpPlugin.Common.dll
index bf15822c6..42bd785fe 100644
Binary files a/Unity-MCP-Plugin/Assets/Plugins/NuGet/McpPlugin.Common.dll and b/Unity-MCP-Plugin/Assets/Plugins/NuGet/McpPlugin.Common.dll differ
diff --git a/Unity-MCP-Plugin/Assets/Plugins/NuGet/McpPlugin.dll b/Unity-MCP-Plugin/Assets/Plugins/NuGet/McpPlugin.dll
index 462d0a62d..b4e19e11c 100644
Binary files a/Unity-MCP-Plugin/Assets/Plugins/NuGet/McpPlugin.dll and b/Unity-MCP-Plugin/Assets/Plugins/NuGet/McpPlugin.dll differ
diff --git a/Unity-MCP-Plugin/Packages/com.ivanmurzak.unity.mcp/Runtime/UnityMcpPlugin.cs b/Unity-MCP-Plugin/Packages/com.ivanmurzak.unity.mcp/Runtime/UnityMcpPlugin.cs
index b5c0c0655..d94ba408f 100644
--- a/Unity-MCP-Plugin/Packages/com.ivanmurzak.unity.mcp/Runtime/UnityMcpPlugin.cs
+++ b/Unity-MCP-Plugin/Packages/com.ivanmurzak.unity.mcp/Runtime/UnityMcpPlugin.cs
@@ -14,6 +14,7 @@
using System.Threading;
using System.Threading.Tasks;
using com.IvanMurzak.McpPlugin;
+using com.IvanMurzak.McpPlugin.AgentConfig;
using com.IvanMurzak.McpPlugin.Common.Model;
using com.IvanMurzak.ReflectorNet;
using Microsoft.AspNetCore.SignalR.Client;
@@ -246,30 +247,30 @@ public static string GenerateToken()
}
///
- /// Generate a deterministic TCP port based on current directory.
- /// Uses SHA256 hash for better distribution and less collisions.
- /// Port range: 20000-29999 (avoids Windows ephemeral/reserved port ranges).
+ /// Generate a deterministic TCP port for the current working directory
+ /// (). Port range: 20000-29999 (avoids Windows
+ /// ephemeral/reserved port ranges).
///
+ ///
+ /// Defect B10 fix (auth-fixes d1): the derivation runs the directory through the shared
+ /// v2 normalization (trim trailing separators, convert
+ /// '\\' to '/', then ) via
+ /// — instead of hashing the raw, untrimmed, un-separator-
+ /// normalized string it used before. This keeps the
+ /// local port in lock-step with the routing pin (also v2-normalized), so a Windows working
+ /// directory reported with backslashes hashes identically to its forward-slash form. The port
+ /// byte-math (first 4 hash bytes, little-endian, mapped into 20000-29999) is unchanged, so a
+ /// path with no backslashes and no trailing separator yields the same port as before.
+ ///
public static int GeneratePortFromDirectory()
- {
- const int MinPort = 20000; // Range chosen to avoid Windows ephemeral/reserved ports (49152-65535)
- const int MaxPort = 29999;
- const int PortRange = MaxPort - MinPort + 1;
-
- var currentDir = Environment.CurrentDirectory.ToLowerInvariant();
-
- using (var sha256 = System.Security.Cryptography.SHA256.Create())
- {
- var hashBytes = sha256.ComputeHash(System.Text.Encoding.UTF8.GetBytes(currentDir));
-
- // Use first 4 bytes as an unsigned integer to avoid Math.Abs(int.MinValue) overflow
- var hash = (uint)BitConverter.ToInt32(hashBytes, 0);
+ => GeneratePortFromDirectory(Environment.CurrentDirectory);
- // Map to port range
- var port = MinPort + (int)(hash % PortRange);
-
- return port;
- }
- }
+ ///
+ /// Deterministic TCP port for an explicit , derived via the shared
+ /// v2 normalization (see ).
+ /// Exposed so the derivation is unit-testable independent of the process working directory.
+ ///
+ public static int GeneratePortFromDirectory(string directory)
+ => ProjectIdentity.DerivePortV2(directory);
}
}
diff --git a/Unity-MCP-Plugin/Packages/com.ivanmurzak.unity.mcp/Tests/Editor/Auth/PortDerivationV2Tests.cs b/Unity-MCP-Plugin/Packages/com.ivanmurzak.unity.mcp/Tests/Editor/Auth/PortDerivationV2Tests.cs
new file mode 100644
index 000000000..57e6f397f
--- /dev/null
+++ b/Unity-MCP-Plugin/Packages/com.ivanmurzak.unity.mcp/Tests/Editor/Auth/PortDerivationV2Tests.cs
@@ -0,0 +1,81 @@
+/*
+┌──────────────────────────────────────────────────────────────────┐
+│ Author: Ivan Murzak (https://github.com/IvanMurzak) │
+│ Repository: GitHub (https://github.com/IvanMurzak/Unity-MCP) │
+│ Copyright (c) 2025 Ivan Murzak │
+│ Licensed under the Apache License, Version 2.0. │
+│ See the LICENSE file in the project root for more information. │
+└──────────────────────────────────────────────────────────────────┘
+*/
+
+#nullable enable
+using System;
+using com.IvanMurzak.McpPlugin.AgentConfig;
+using NUnit.Framework;
+
+namespace com.IvanMurzak.Unity.MCP.Editor.Tests
+{
+ ///
+ /// Defect B10 fix (auth-fixes d1):
+ /// now derives the deterministic local port through the shared v2
+ /// normalization (trim trailing separators, '\\' → '/', )
+ /// instead of hashing the raw, untrimmed working-directory string. That keeps the port in lock-step with
+ /// the routing pin, so a Windows working directory reported with backslashes maps to the SAME port as its
+ /// forward-slash form. Expected values are the committed v2 golden vectors
+ /// (MCP-Plugin-dotnet/McpPlugin/src/AgentConfig/ProjectIdentity.GoldenVectors.v2.json).
+ ///
+ public class PortDerivationV2Tests
+ {
+ [Test]
+ public void GeneratePortFromDirectory_DelegatesToProjectIdentityV2()
+ {
+ const string dir = @"C:\Users\user\my-game";
+ Assert.AreEqual(ProjectIdentity.DerivePortV2(dir), UnityMcpPlugin.GeneratePortFromDirectory(dir));
+ }
+
+ // v2 golden vectors: backslash and forward-slash forms of the same Windows root converge (the B5/B10
+ // fix); a trailing separator is trimmed; a POSIX path is unaffected (v2 == v1 there).
+ [TestCase(@"C:\Users\user\my-game", 24298)] // Windows backslash form
+ [TestCase(@"C:\Users\user\my-game\", 24298)] // trailing backslash trimmed → identical
+ [TestCase("C:/Users/user/my-game", 24298)] // forward-slash form → SAME port under v2
+ [TestCase("C:/Users/user/my-game/", 24298)] // trailing slash trimmed → identical
+ [TestCase("/home/user/my-game", 23940)] // POSIX typical → v2 == v1
+ [TestCase("/home/user/my-game/", 23940)] // trailing slash trimmed → identical
+ public void GeneratePortFromDirectory_MatchesV2GoldenPort(string dir, int expectedPort)
+ {
+ Assert.AreEqual(expectedPort, UnityMcpPlugin.GeneratePortFromDirectory(dir));
+ }
+
+ [Test]
+ public void GeneratePortFromDirectory_BackslashAndForwardSlash_Converge()
+ {
+ // The defining B10/B5 property: the two separator forms of one root yield the SAME port,
+ // whereas the pre-fix raw-string derivation gave two different ports on Windows.
+ Assert.AreEqual(
+ UnityMcpPlugin.GeneratePortFromDirectory(@"C:\Users\user\my-game"),
+ UnityMcpPlugin.GeneratePortFromDirectory("C:/Users/user/my-game"));
+ }
+
+ [Test]
+ public void GeneratePortFromDirectory_TrailingSeparatorInsensitive()
+ {
+ Assert.AreEqual(
+ UnityMcpPlugin.GeneratePortFromDirectory(@"C:\Users\user\my-game"),
+ UnityMcpPlugin.GeneratePortFromDirectory(@"C:\Users\user\my-game\"));
+ }
+
+ [Test]
+ public void GeneratePortFromDirectory_StaysInDeterministicRange()
+ {
+ var port = UnityMcpPlugin.GeneratePortFromDirectory(@"C:\Users\user\my-game");
+ Assert.GreaterOrEqual(port, ProjectIdentity.MinPort);
+ Assert.LessOrEqual(port, ProjectIdentity.MaxPort);
+ }
+
+ [Test]
+ public void GeneratePortFromDirectory_NullDirectory_Throws()
+ {
+ Assert.Throws(() => UnityMcpPlugin.GeneratePortFromDirectory(null!));
+ }
+ }
+}
diff --git a/Unity-MCP-Plugin/Packages/com.ivanmurzak.unity.mcp/Tests/Editor/Auth/PortDerivationV2Tests.cs.meta b/Unity-MCP-Plugin/Packages/com.ivanmurzak.unity.mcp/Tests/Editor/Auth/PortDerivationV2Tests.cs.meta
new file mode 100644
index 000000000..4c9ad8206
--- /dev/null
+++ b/Unity-MCP-Plugin/Packages/com.ivanmurzak.unity.mcp/Tests/Editor/Auth/PortDerivationV2Tests.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 8971f228deb68044c9c1b4443bcf241d
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Unity-MCP-Plugin/Packages/com.ivanmurzak.unity.mcp/Tests/Editor/Auth/ProjectInstanceServiceTests.cs b/Unity-MCP-Plugin/Packages/com.ivanmurzak.unity.mcp/Tests/Editor/Auth/ProjectInstanceServiceTests.cs
index 680e0f91b..d12ed32e1 100644
--- a/Unity-MCP-Plugin/Packages/com.ivanmurzak.unity.mcp/Tests/Editor/Auth/ProjectInstanceServiceTests.cs
+++ b/Unity-MCP-Plugin/Packages/com.ivanmurzak.unity.mcp/Tests/Editor/Auth/ProjectInstanceServiceTests.cs
@@ -13,6 +13,7 @@
using System.IO;
using com.IvanMurzak.McpPlugin;
using com.IvanMurzak.McpPlugin.AgentConfig;
+using com.IvanMurzak.McpPlugin.Common;
using com.IvanMurzak.Unity.MCP.Editor.Services;
using NUnit.Framework;
@@ -60,6 +61,41 @@ public void BuildMetadata_ProducesUnityHandshakePayload_WithPinAsHashPrefix()
StringAssert.StartsWith(ProjectIdentity.DerivePin(ProjectRoot), metadata.ProjectPathHash);
}
+ [Test]
+ public void BuildMetadata_SendsDualHash_V2PrimaryAndLegacyV1()
+ {
+ // A Windows-style backslash root is the case where the v1 and v2 normalizations diverge
+ // (v2 converts '\' -> '/', v1 does not), so both hashes must be present and distinct — this
+ // is exactly the dual-hash transition (auth-fixes T3 / defect B5) that lets a session pinned
+ // by an OLD (v1-pin) config still match this NEW plugin.
+ const string winRoot = @"C:\Users\dev\MyGame";
+ var metadata = ProjectInstanceService.BuildMetadata(winRoot, "MyGame", instanceId: "sess-dh");
+
+ // Primary hash = v2 (separator-normalized); legacy hash = v1 (separators NOT normalized).
+ Assert.AreEqual(ProjectIdentity.DeriveProjectPathHashV2(winRoot), metadata.ProjectPathHash);
+ Assert.AreEqual(ProjectIdentity.DeriveProjectPathHash(winRoot), metadata.ProjectPathHashLegacy);
+ Assert.AreEqual(64, metadata.ProjectPathHash.Length);
+ Assert.AreEqual(64, metadata.ProjectPathHashLegacy.Length);
+ Assert.AreNotEqual(metadata.ProjectPathHash, metadata.ProjectPathHashLegacy,
+ "on a Windows backslash root the v2 and v1 hashes must diverge, proving both are sent");
+
+ // The v2 routing pin is a prefix of the primary hash (server pin-matches by prefix).
+ StringAssert.StartsWith(ProjectIdentity.DerivePinV2(winRoot), metadata.ProjectPathHash);
+ }
+
+ [Test]
+ public void BuildMetadata_ToQuery_CarriesBothHashKeys()
+ {
+ const string winRoot = @"C:\Users\dev\MyGame";
+ var metadata = ProjectInstanceService.BuildMetadata(winRoot, "MyGame", instanceId: "sess-dh2");
+
+ var query = metadata.ToQuery();
+
+ // Both hashes travel as hub-handshake query parameters (dual-hash visible in the handshake).
+ Assert.AreEqual(metadata.ProjectPathHash, query[Consts.MCP.Server.HubQuery.ProjectPathHash]);
+ Assert.AreEqual(metadata.ProjectPathHashLegacy, query[Consts.MCP.Server.HubQuery.ProjectPathHashLegacy]);
+ }
+
[Test]
public void BuildMetadata_DefaultsInstanceId_ToSessionInstanceId()
{
diff --git a/Unity-Tests/2022.3.62f3/Assets/Plugins/NuGet/McpPlugin.Common.dll b/Unity-Tests/2022.3.62f3/Assets/Plugins/NuGet/McpPlugin.Common.dll
index bf15822c6..42bd785fe 100644
Binary files a/Unity-Tests/2022.3.62f3/Assets/Plugins/NuGet/McpPlugin.Common.dll and b/Unity-Tests/2022.3.62f3/Assets/Plugins/NuGet/McpPlugin.Common.dll differ
diff --git a/Unity-Tests/2022.3.62f3/Assets/Plugins/NuGet/McpPlugin.dll b/Unity-Tests/2022.3.62f3/Assets/Plugins/NuGet/McpPlugin.dll
index 462d0a62d..b4e19e11c 100644
Binary files a/Unity-Tests/2022.3.62f3/Assets/Plugins/NuGet/McpPlugin.dll and b/Unity-Tests/2022.3.62f3/Assets/Plugins/NuGet/McpPlugin.dll differ
diff --git a/Unity-Tests/2023.2.22f1/Assets/Plugins/NuGet/McpPlugin.Common.dll b/Unity-Tests/2023.2.22f1/Assets/Plugins/NuGet/McpPlugin.Common.dll
index bf15822c6..42bd785fe 100644
Binary files a/Unity-Tests/2023.2.22f1/Assets/Plugins/NuGet/McpPlugin.Common.dll and b/Unity-Tests/2023.2.22f1/Assets/Plugins/NuGet/McpPlugin.Common.dll differ
diff --git a/Unity-Tests/2023.2.22f1/Assets/Plugins/NuGet/McpPlugin.dll b/Unity-Tests/2023.2.22f1/Assets/Plugins/NuGet/McpPlugin.dll
index 462d0a62d..b4e19e11c 100644
Binary files a/Unity-Tests/2023.2.22f1/Assets/Plugins/NuGet/McpPlugin.dll and b/Unity-Tests/2023.2.22f1/Assets/Plugins/NuGet/McpPlugin.dll differ
diff --git a/Unity-Tests/6000.3.1f1/Assets/Plugins/NuGet/McpPlugin.Common.dll b/Unity-Tests/6000.3.1f1/Assets/Plugins/NuGet/McpPlugin.Common.dll
index bf15822c6..42bd785fe 100644
Binary files a/Unity-Tests/6000.3.1f1/Assets/Plugins/NuGet/McpPlugin.Common.dll and b/Unity-Tests/6000.3.1f1/Assets/Plugins/NuGet/McpPlugin.Common.dll differ
diff --git a/Unity-Tests/6000.3.1f1/Assets/Plugins/NuGet/McpPlugin.dll b/Unity-Tests/6000.3.1f1/Assets/Plugins/NuGet/McpPlugin.dll
index 462d0a62d..b4e19e11c 100644
Binary files a/Unity-Tests/6000.3.1f1/Assets/Plugins/NuGet/McpPlugin.dll and b/Unity-Tests/6000.3.1f1/Assets/Plugins/NuGet/McpPlugin.dll differ
diff --git a/Unity-Tests/6000.5.0b3/Assets/Plugins/NuGet/McpPlugin.Common.dll b/Unity-Tests/6000.5.0b3/Assets/Plugins/NuGet/McpPlugin.Common.dll
index bf15822c6..42bd785fe 100644
Binary files a/Unity-Tests/6000.5.0b3/Assets/Plugins/NuGet/McpPlugin.Common.dll and b/Unity-Tests/6000.5.0b3/Assets/Plugins/NuGet/McpPlugin.Common.dll differ
diff --git a/Unity-Tests/6000.5.0b3/Assets/Plugins/NuGet/McpPlugin.dll b/Unity-Tests/6000.5.0b3/Assets/Plugins/NuGet/McpPlugin.dll
index 462d0a62d..b4e19e11c 100644
Binary files a/Unity-Tests/6000.5.0b3/Assets/Plugins/NuGet/McpPlugin.dll and b/Unity-Tests/6000.5.0b3/Assets/Plugins/NuGet/McpPlugin.dll differ
diff --git a/Unity-Tests/6000.6.0a2/Assets/Plugins/NuGet/McpPlugin.Common.dll b/Unity-Tests/6000.6.0a2/Assets/Plugins/NuGet/McpPlugin.Common.dll
index bf15822c6..42bd785fe 100644
Binary files a/Unity-Tests/6000.6.0a2/Assets/Plugins/NuGet/McpPlugin.Common.dll and b/Unity-Tests/6000.6.0a2/Assets/Plugins/NuGet/McpPlugin.Common.dll differ
diff --git a/Unity-Tests/6000.6.0a2/Assets/Plugins/NuGet/McpPlugin.dll b/Unity-Tests/6000.6.0a2/Assets/Plugins/NuGet/McpPlugin.dll
index 462d0a62d..b4e19e11c 100644
Binary files a/Unity-Tests/6000.6.0a2/Assets/Plugins/NuGet/McpPlugin.dll and b/Unity-Tests/6000.6.0a2/Assets/Plugins/NuGet/McpPlugin.dll differ