Skip to content

Commit 9872d8f

Browse files
authored
fix: stabilize LocalToken so client configs never go stale (401) (#898)
Stabilize UnityConnectionConfig.LocalToken (seed it directly in SetDefault instead of mis-routing GenerateToken to CloudToken) so the local server token and the client .mcp.json Bearer stay in lockstep, fixing the Custom+token 401; + 5 EditMode regression tests. Closes #897
1 parent 13ae4b6 commit 9872d8f

3 files changed

Lines changed: 144 additions & 1 deletion

File tree

Unity-MCP-Plugin/Packages/com.ivanmurzak.unity.mcp/Runtime/UnityMcpPlugin.Config.cs

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -206,7 +206,15 @@ public UnityConnectionConfig SetDefault()
206206
Tools = DefaultTools;
207207
Prompts = DefaultPrompts;
208208
Resources = DefaultResources;
209-
Token = GenerateToken();
209+
// Seed the LOCAL server token directly, NOT through the mode-routed `Token` setter.
210+
// `ConnectionMode` was set to `Cloud` above, so `Token = GenerateToken()` would route
211+
// the freshly-generated local secret into `CloudToken` and leave `LocalToken` null —
212+
// the local server token would then never be seeded here, forcing the generate-if-empty
213+
// fallback in `GetOrCreateConfig` to mint (and re-mint) it, which is exactly how a
214+
// persisted local token could drift and orphan an already-written client `.mcp.json`
215+
// (stale Bearer -> 401; Unity-MCP #897 / mcp-authorize i2). `GenerateToken()` produces a
216+
// local server secret; `CloudToken` is populated by Cloud sign-in, so it stays null here.
217+
LocalToken = GenerateToken();
210218
return this;
211219
}
212220

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
/*
2+
┌──────────────────────────────────────────────────────────────────┐
3+
│ Author: Ivan Murzak (https://github.com/IvanMurzak) │
4+
│ Repository: GitHub (https://github.com/IvanMurzak/Unity-MCP) │
5+
│ Copyright (c) 2025 Ivan Murzak │
6+
│ Licensed under the Apache License, Version 2.0. │
7+
│ See the LICENSE file in the project root for more information. │
8+
└──────────────────────────────────────────────────────────────────┘
9+
*/
10+
11+
#nullable enable
12+
using System.Text.Json;
13+
using System.Text.Json.Serialization;
14+
using NUnit.Framework;
15+
using AuthOption = com.IvanMurzak.McpPlugin.Common.Consts.MCP.Server.AuthOption;
16+
17+
namespace com.IvanMurzak.Unity.MCP.Editor.Tests
18+
{
19+
/// <summary>
20+
/// Guards the mcp-authorize i2 local-token lifecycle fix (Unity-MCP #897 / BUG-B): the local server
21+
/// token (<see cref="UnityMcpPlugin.UnityConnectionConfig.LocalToken"/>) must be seeded by
22+
/// <c>SetDefault()</c> and must survive a mode re-apply and a serialize/deserialize (domain reload +
23+
/// save/load) round-trip UNCHANGED. A silently regenerated local token orphans the already-written
24+
/// client <c>.mcp.json</c> (stale <c>Bearer</c>) and produces a Claude Code 401.
25+
/// </summary>
26+
public class LocalTokenStabilityTests
27+
{
28+
const string PersistedLocalToken = "PERSISTED_LOCAL_TOKEN";
29+
30+
// Mirror the production serializer options so this round-trip exercises the SAME JSON shape the
31+
// plugin persists and reloads across a domain reload:
32+
// - Save() -> WriteIndented + CamelCase + JsonStringEnumConverter
33+
// - GetOrCreateConfig -> PropertyNameCaseInsensitive + JsonStringEnumConverter
34+
// (both in Editor/Scripts/UnityMcpPluginEditor.Config.cs).
35+
static JsonSerializerOptions SaveOptions() => new JsonSerializerOptions
36+
{
37+
WriteIndented = true,
38+
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
39+
Converters = { new JsonStringEnumConverter() }
40+
};
41+
42+
static JsonSerializerOptions LoadOptions() => new JsonSerializerOptions
43+
{
44+
PropertyNameCaseInsensitive = true,
45+
Converters = { new JsonStringEnumConverter() }
46+
};
47+
48+
static UnityMcpPlugin.UnityConnectionConfig RoundTrip(UnityMcpPlugin.UnityConnectionConfig config)
49+
{
50+
var json = JsonSerializer.Serialize(config, SaveOptions());
51+
return JsonSerializer.Deserialize<UnityMcpPlugin.UnityConnectionConfig>(json, LoadOptions())!;
52+
}
53+
54+
static UnityMcpPlugin.UnityConnectionConfig CustomTokenConfig() => new UnityMcpPlugin.UnityConnectionConfig
55+
{
56+
ConnectionMode = ConnectionMode.Custom,
57+
AuthOption = AuthOption.token,
58+
LocalToken = PersistedLocalToken,
59+
};
60+
61+
[Test]
62+
public void SetDefault_SeedsLocalToken_NotCloudToken()
63+
{
64+
// A freshly-constructed config (the ctor calls SetDefault) must carry a non-empty LOCAL token
65+
// and a null cloud token. The prior bug routed GenerateToken() into CloudToken because
66+
// SetDefault sets ConnectionMode=Cloud BEFORE assigning the token via the mode-routed setter,
67+
// leaving LocalToken unseeded (and re-mintable by the generate-if-empty fallback).
68+
var config = new UnityMcpPlugin.UnityConnectionConfig();
69+
70+
Assert.IsFalse(string.IsNullOrEmpty(config.LocalToken),
71+
"SetDefault must seed a non-empty LocalToken so the generate-if-empty fallback never has to mint it.");
72+
Assert.IsNull(config.CloudToken,
73+
"SetDefault must leave CloudToken null — the cloud token comes from Cloud sign-in, not GenerateToken().");
74+
}
75+
76+
[Test]
77+
public void LocalToken_StableAcrossModeReapply()
78+
{
79+
var config = CustomTokenConfig();
80+
81+
// Re-apply / toggle the connection mode; the local token must never move.
82+
config.ConnectionMode = ConnectionMode.Cloud;
83+
config.ConnectionMode = ConnectionMode.Custom;
84+
85+
Assert.AreEqual(PersistedLocalToken, config.LocalToken,
86+
"Re-applying the connection mode must not regenerate the local token.");
87+
Assert.AreEqual(PersistedLocalToken, config.Token,
88+
"In Custom mode the effective Token must resolve to the (unchanged) LocalToken.");
89+
}
90+
91+
[Test]
92+
public void LocalToken_SurvivesSaveLoadRoundTrip()
93+
{
94+
var reloaded = RoundTrip(CustomTokenConfig());
95+
96+
Assert.AreEqual(PersistedLocalToken, reloaded.LocalToken,
97+
"The persisted local token must survive a serialize/deserialize (domain reload / save-load) round-trip.");
98+
Assert.AreEqual(ConnectionMode.Custom, reloaded.ConnectionMode);
99+
Assert.AreEqual(AuthOption.token, reloaded.AuthOption);
100+
}
101+
102+
[Test]
103+
public void RoundTrip_LeavesLocalTokenNonEmpty_SoGenerateIfEmptyNeverRegenerates()
104+
{
105+
// GetOrCreateConfig re-mints LocalToken only when it is null/empty after load. A persisted
106+
// token must therefore round-trip NON-EMPTY so that guard stays a no-op and never re-mints it.
107+
var reloaded = RoundTrip(CustomTokenConfig());
108+
109+
Assert.IsFalse(string.IsNullOrEmpty(reloaded.LocalToken),
110+
"A reloaded config must carry a non-empty LocalToken so the generate-if-empty fallback stays a no-op.");
111+
}
112+
113+
[Test]
114+
public void LocalToken_SerializesUnderTokenJsonField()
115+
{
116+
var json = JsonSerializer.Serialize(CustomTokenConfig(), SaveOptions());
117+
using var doc = JsonDocument.Parse(json);
118+
119+
Assert.IsTrue(doc.RootElement.TryGetProperty("token", out var tokenProp),
120+
"LocalToken must serialize under the \"token\" JSON field the loader reads back.");
121+
Assert.AreEqual(PersistedLocalToken, tokenProp.GetString());
122+
}
123+
}
124+
}

Unity-MCP-Plugin/Packages/com.ivanmurzak.unity.mcp/Tests/Editor/Config/LocalTokenStabilityTests.cs.meta

Lines changed: 11 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)