Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file modified Unity-MCP-Plugin/Assets/Plugins/NuGet/McpPlugin.Common.dll
Binary file not shown.
Binary file modified Unity-MCP-Plugin/Assets/Plugins/NuGet/McpPlugin.dll
Binary file not shown.
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -246,30 +247,30 @@ public static string GenerateToken()
}

/// <summary>
/// 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
/// (<see cref="Environment.CurrentDirectory"/>). Port range: 20000-29999 (avoids Windows
/// ephemeral/reserved port ranges).
/// </summary>
/// <remarks>
/// Defect <b>B10</b> fix (auth-fixes d1): the derivation runs the directory through the shared
/// <see cref="ProjectIdentity"/> <b>v2</b> normalization (trim trailing separators, convert
/// <c>'\\'</c> to <c>'/'</c>, then <see cref="string.ToLowerInvariant"/>) via
/// <see cref="ProjectIdentity.DerivePortV2"/> — instead of hashing the raw, untrimmed, un-separator-
/// normalized <see cref="Environment.CurrentDirectory"/> 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.
/// </remarks>
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;
}
}
/// <summary>
/// Deterministic TCP port for an explicit <paramref name="directory"/>, derived via the shared
/// <see cref="ProjectIdentity"/> v2 normalization (see <see cref="GeneratePortFromDirectory()"/>).
/// Exposed so the derivation is unit-testable independent of the process working directory.
/// </summary>
public static int GeneratePortFromDirectory(string directory)
=> ProjectIdentity.DerivePortV2(directory);
}
}
Original file line number Diff line number Diff line change
@@ -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
{
/// <summary>
/// Defect <b>B10</b> fix (auth-fixes d1): <see cref="UnityMcpPlugin.GeneratePortFromDirectory(string)"/>
/// now derives the deterministic local port through the shared <see cref="ProjectIdentity"/> <b>v2</b>
/// normalization (trim trailing separators, <c>'\\'</c> → <c>'/'</c>, <see cref="string.ToLowerInvariant"/>)
/// 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
/// (<c>MCP-Plugin-dotnet/McpPlugin/src/AgentConfig/ProjectIdentity.GoldenVectors.v2.json</c>).
/// </summary>
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<ArgumentNullException>(() => UnityMcpPlugin.GeneratePortFromDirectory(null!));
}
}
}

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

Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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()
{
Expand Down
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file modified Unity-Tests/6000.3.1f1/Assets/Plugins/NuGet/McpPlugin.dll
Binary file not shown.
Binary file not shown.
Binary file modified Unity-Tests/6000.5.0b3/Assets/Plugins/NuGet/McpPlugin.dll
Binary file not shown.
Binary file not shown.
Binary file modified Unity-Tests/6000.6.0a2/Assets/Plugins/NuGet/McpPlugin.dll
Binary file not shown.
Loading