Skip to content

feat: honour UNITY_MCP_SERVER_PATH — launch a given server binary, skip download + version match - #975

Merged
IvanMurzak merged 2 commits into
mainfrom
worktree-p1-server-path-unity
Sep 3, 2026
Merged

feat: honour UNITY_MCP_SERVER_PATH — launch a given server binary, skip download + version match#975
IvanMurzak merged 2 commits into
mainfrom
worktree-p1-server-path-unity

Conversation

@IvanMurzak

@IvanMurzak IvanMurzak commented Sep 3, 2026

Copy link
Copy Markdown
Owner

Summary

  • Adds UNITY_MCP_SERVER_PATH. When it resolves to a file that exists, that file is the
    binary the Editor launches, and both the GitHub-release download and the pinned-version match are
    skipped. When it is unset — or set to a path that does not exist — nothing changes: the plugin
    keeps using Library/mcp-server/<rid>/ and the release pinned by ServerVersion (9.2.5, which
    this PR does not touch). Same rule as Unreal-MCP's UNREAL_MCP_SERVER_PATH, fall-through
    included.
  • Resolved through the existing DevControlEnv.Resolve layer (process env
    <projectRoot>/.env → unset) rather than a bare Environment.GetEnvironmentVariable, so an
    Editor launched from the GUI or an IDE — which inherits no shell exports — can still pick the
    override up from a .env at the Unity project root.
  • The override splits the two tiers that used to be one: the launch target follows it, the
    download cache never does. Everything that describes or verifies a download reads the cache
    tier explicitly, so a manual Download Binaries run still verifies and reports the file it
    actually wrote.
  • Scope: one product file, one new EditMode test file, one docs subsection.
    git diff --stat origin/main -- Unity-MCP-Plugin/Packages/com.ivanmurzak.unity.mcp/Editor lists
    only Scripts/McpServerManager.cs; git diff --stat origin/main -- .github is empty.

What the override changes, member by member

Member Override inactive (unset, or set-but-missing) Override active
ResolveServerPathOverride() null the full path
ExecutableFullPath Library/mcp-server/<rid>/gamedev-mcp-server(.exe) the override
ExecutableFolderPath (StartServer's WorkingDirectory) Library/mcp-server/<rid>/ the override's directory
VersionFullPath Library/mcp-server/<rid>/version <override dir>/version
IsVersionMatches() reads the version marker and compares to ServerVersion true (there is no marker beside a workspace-built binary)
CachedExecutableFolderPath Library/mcp-server/<rid>/ Library/mcp-server/<rid>/never redirected
CachedExecutableFullPath Library/mcp-server/<rid>/gamedev-mcp-server(.exe) unchanged — never redirected
GetCachedBinaryVersion() Library/mcp-server/<rid>/version unchanged — never redirected

IsBinaryReadyToStart(), the DownloadServerBinaryIfNeeded gate and the editor-startup /
package-update paths all short-circuit off IsBinaryExists() && IsVersionMatches(), so under an
override DownloadAndUnpackBinary is never entered from either automatic path.

The manual menu item is the one download path that stays live under an override, and the bottom
three rows of that table are what keep it honest. MenuItems.DownloadServer calls
DownloadAndUnpackBinary directly, bypassing that gate, so with an override set it still runs —
and every member that describes the launch target is, by construction, true about the developer's
own binary. So the download path publishes to CachedExecutableFolderPath, verifies
CachedExecutableFullPath + GetCachedBinaryVersion(), and reports the same, rather than
ExecutableFullPath / IsBinaryExists() / IsVersionMatches() / GetBinaryVersion(). Had it kept
reading the launch tier, Download Binaries would have reported success however the publish went
(the override file exists because the resolver only returns it after File.Exists passed, and
IsVersionMatches() short-circuits to true), logged the override path as the download
destination, and shown a blank Version: in the result popup. This is the same reasoning that moved
PublishStagedBinary off ExecutableFolderPath: without it, a manual download would have deleted
and replaced the developer's own override directory, and the documented sentence "downloads into
Library/ regardless of the override; the override still wins at launch"
would have been false.

Evidence

1. Behavioural proof against the real launch path

Unity 2022.3.62f3, this repo's own Unity-MCP-Plugin project in a fresh worktree whose
Library/ did not exist. Override target: a locally published server —
dotnet publish shared/GameDev-MCP-Server/com.IvanMurzak.GameDev.MCP.Server.csproj -c Release -r win-x64 --self-contained true -p:PublishSingleFile=trueProductVersion 9.2.6+62a6b7d1,
i.e. deliberately not the pinned 9.2.5, at a path outside Library/.

Half 1 — UNITY_MCP_SERVER_PATH set (Editor.log, verbatim, colour tags stripped):

info: [13:53:11:2855] [AI] McpServerManager UNITY_MCP_SERVER_PATH override active: launching
  ...\.agent-scratch\chain-server\gamedev-mcp-server.exe — server download and version check are skipped.
info: [13:53:15:0678] [AI] McpServerManager Starting MCP server:
  ...\.agent-scratch\chain-server\gamedev-mcp-server.exe port=5780 plugin-timeout=1800000
  client-transport=streamableHttp auth=none
  • grep -c 'Downloaded and unpacked' Editor.log0
  • grep -c 'Deleted existing MCP server folder' Editor.log0
  • Unity-MCP-Plugin/Library/mcp-serverdoes not exist
  • live process: gamedev-mcp-server.exe ExecutablePath = ...\.agent-scratch\chain-server\gamedev-mcp-server.exe
  • the plugin reached READY through it (wait-for-ready exit 0, SignalR negotiate returned 200 with a connectionId).

Half 2 — the plant the brief asks for: same Editor, variable unset.

info: [13:54:39:7257] [AI] McpServerManager Starting MCP server:
  ...\Unity-MCP-Plugin\Library\mcp-server\win-x64\gamedev-mcp-server.exe port=5780 ...
Downloaded and unpacked GameDev-MCP-Server binary to:
  ...\Unity-MCP-Plugin\Library\mcp-server\win-x64\gamedev-mcp-server.exe

Library/mcp-server/win-x64/ now exists with a version marker reading 9.2.5, and the
override active line is absent from that run. So the launch target moved with the variable in
both directions, and the download only happened when the override was gone.

2. Plant rounds — six plants, each RED attributed from its own run

Applied with the Edit tool, each confirmed live in git diff before the run and reverted after;
assets-refresh between every plant and its run. Every round ran
tests-run --input {"testMode":"EditMode","testClass":"McpServerPathOverrideTests",...} and the
verdict is read per test from the run's own JSONunity-mcp-cli exits 0 even when tests
fail, so an exit status could not have told these apart.

Plant Mutation Result
baseline none 7 passed / 0 failed
P1 delete the IsVersionMatches() override short-circuit 3 failed: Override_ExistingFile_IsWhatGetsLaunched ("the override must skip the pinned-release version match"), Override_SkipsVersionMatch_EvenWithAMismatchedVersionMarkerBesideIt, Override_ResolvesFromProjectDotEnv_WhenTheProcessEnvIsUnset
P2 delete the File.Exists(raw) check in ResolveServerPathOverride 1 failed: Override_SetButMissingFile_FallsThroughToThePinnedRelease ("a set-but-missing override must not resolve (the Unreal rule)")
P3 ExecutableFolderRootPath literal "mcp-server""mcp-server-planted" 3 failed: NoOverride_KeepsThePinnedLibraryCache_AndTheVersionMarkerCheck, the pinned-cache assertion in Override_SetButMissingFile_FallsThroughToThePinnedRelease, and the cache assertion in Override_ExistingFile_IsWhatGetsLaunched
P4 DevControlEnv.Resolve(...)Environment.GetEnvironmentVariable(...) 2 failed: Override_ResolvesFromProjectDotEnv_WhenTheProcessEnvIsUnset and ResolveServerPathOverride_ReadsTheDotEnvOfTheGivenProjectRoot (both But was: null)
P5 make CachedExecutableFolderPath follow the override 2 failed: "an active override must NOT move the download cache" and "the download-cache version read must NOT follow the override" — while the no-override test stays green, which is what proves the mutation bites only under an override
P6 GetCachedBinaryVersion() reads VersionFullPath instead of <cache>/version 1 failed: "the download-cache version read must NOT follow the override" (But was: "0.0.0-not-the-pinned-version")
final all plants reverted, no residue 7 passed / 0 failed, Logs: []

P1 and P2 are the two the brief mandates. P3 and P4 exist because two other claims would otherwise
have been unfalsifiable: P3 proves the no-override test really pins Library/mcp-server/<rid>/
rather than comparing a value with itself, and P4 proves the .env layer is load-bearing rather
than incidental. P5 and P6 cover the download/launch tier split — they are the plants for behaviour
this PR changed in production code, derived from the change rather than from an existing test.

P5 and P6 attack one claim at two sites and share a red with identical text. Both redden
"the download-cache version read must NOT follow the override" verbatim. They are told apart by
their failure SET, not by that line: P5 additionally reddens the cache-folder assertion in
Override_ExistingFile_IsWhatGetsLaunched; P6 reddens the version assertion alone. Stated rather
than papered over, because a future red on that marker alone means P6's site, not P5's.

What makes each assertion able to fail (stated because a check that cannot fail scores green
with the feature deleted):

  • The override fixture directory contains no version marker, and the test asserts that
    (Assert.IsFalse(File.Exists(VersionFullPath)), Assert.IsNull(GetBinaryVersion())) before
    asserting IsVersionMatches() is true — so that true can only come from the short-circuit.
    A sibling test goes further and puts a marker reading 0.0.0-not-the-pinned-version beside the
    override, asserts the marker is readable and disagrees with ServerVersion, and still requires
    IsVersionMatches() to be true. That same fixture is what makes P6 discriminate: with the
    mismatched marker beside the override, a cache read that followed the override returns it.
  • The fall-through test captures the genuinely-unset baseline first ([SetUp] neutralises both
    sources — the process env var and <projectRoot>/.env — and [TearDown] restores them), then
    asserts equality with it and equality with an independently re-derived
    Library/mcp-server/<rid>/gamedev-mcp-server.exe, so "unchanged" cannot be satisfied by nothing
    having been read at all.
  • The .env tests assert the process env var is empty and that nothing resolves before the file
    is written, so neither can pass on a process-env read.

Environment-conditional assertions — four of them, not one. Corrected here after the review
pass measured it; the earlier revision of this section understated the count.

Assertion Discriminates on a clean CI runner (no Library/ cache) Discriminates on a box holding the pinned release
NoOverride…: IsVersionMatches() == (marker == ServerVersion) yes (no marker ⇒ false == false, and an unconditional true reddens) no (both sides true)
SetButMissing…: IsBinaryExists() unchanged no yes
SetButMissing…: IsVersionMatches() unchanged yes no
SetButMissing…: IsBinaryReadyToStart() unchanged no yes

The suite is sound regardless, because the path equalities in those same tests — and the
cache-folder assertions P3 and P5 redden — discriminate in every environment. No assertion is
relied on where it cannot fail, and the two assertions that could not discriminate anywhere
(GetBinaryVersion() re-derived from its own body, and IsBinaryReadyToStart() restated as
IsBinaryExists() && IsVersionMatches()) were removed rather than left reading as coverage.

3. Local suites

  • Suite 1 — Unity EditMode, full chunked run against Unity-MCP-Plugin (2022.3.62f3): see the
    "Suites" line below.
  • Suite 2 — PlayMode: not applicable. The diff touches only Editor/ (Editor-only assembly)
    and docs/; nothing is compiled into a player.
  • Suite 3 — CLI: not applicable. No file under cli/ changed.
  • Suite 4 — CI surface: not applicable (no .github/** change), but
    python .github/scripts/check_nuget_gate.py was run anyway and exits 0:
    NuGet gate OK: 15 pins, generation UNITY_MCP_DEPS_3, propagation consistent.

Review pass (/code-review + /simplify)

Three report-only reviewers over the full diff, plus an in-context pass. Everything below was
verified from source before it was applied; findings whose prescription was wrong are recorded as
such rather than followed.

Applied — three real defects, all on the manual-download path, all invisible unless the override
is active
(detailed in the member table above): the post-publish verification was vacuous, the
success log named the override instead of the download destination, and the result popup read the
version marker from the override's directory. Plus one consistency fix: StartServer now derives
WorkingDirectory from the executablePath local instead of re-resolving ExecutableFolderPath,
so FileName and WorkingDirectory cannot describe different binaries.

Applied — test hardening. [SetUp] used to File.Delete the real <projectRoot>/.env with the
only copy in a managed field. That file is gitignored, user-owned config that this feature's own
docs
tell developers to create, so git holds no copy and a run killed before [TearDown] — a
domain reload, a cancelled run, an editor crash — would have destroyed it. It is now moved aside
to a sibling path and moved back, [SetUp] recovers a file left parked by an earlier killed run,
and a failed restore is a Debug.LogError (which fails the test) instead of a silent
catch { /* best effort */ }.

Applied — one new test. ResolveServerPathOverride(string? projectRootPath) is public and
documented as the unit-test seam, and no test used it. It has one now, reading an arbitrary root's
.env from a temp directory — which also pins that the root is an argument rather than the live
project.

Not applied, recorded instead:

  • Moving the override resolution beside DevControlEnv, where the two sibling dev-only vars and
    their resolvers live. Genuinely the better home; out of bounds, since the brief fixes
    McpServerManager.cs as the only product file.
  • Rewriting the .env tests to use the seam against a temp root instead of the real project root.
    That would drop the end-to-end coverage of ExecutableFullPath following <projectRoot>/.env,
    which is exactly what the brief asks the .env case to prove. The seam got its own test instead.
  • Logging a warning when UNITY_MCP_SERVER_PATH is set but the file is missing. The silent
    fall-through is the ruled Unreal semantics; a new unverified log line late in review is not worth
    it. The docs now point at the Starting MCP server: <path> line as the reliable confirmation
    instead — which is also why the override active notice is no longer described as authoritative:
    it is emitted once per domain load, so it is absent if you write the .env afterwards even though
    the override still applies.
  • Caching the resolution per domain load. It would break the tests and change behaviour; no caller
    is on a per-frame path (checked: no EditorApplication.update subscriber reads these members
    without self-unsubscribing, no OnGUI, no schedule.Execute loop).

Not done, on purpose

  • ServerVersion (McpServerManager.cs) is unchanged.
  • MenuItems.DownloadServer still downloads into Library/, which is documented.
  • Editor/DependencyResolver/**, NuGetConfig.cs, the Assets/Plugins/NuGet drops, cli/**,
    Runtime/**, UpdateChecker and every UI file are untouched.
  • No version field in any package.json was bumped.
  • DownloadAndUnpackBinary's own body has no EditMode coverage — it downloads — so the
    post-publish fix is verified through the members it now reads (CachedExecutableFolderPath,
    GetCachedBinaryVersion(), planted P5/P6) rather than by exercising the download itself.

Test plan

  • EditMode suite McpServerPathOverrideTests7 tests, 0 failures, Logs: [].
  • Six plant rounds, each RED attributed per test from its own run log; all reverted; residue-checked; final run green.
  • Behavioural proof on the real launch path, both directions.
  • check_nuget_gate.py exit 0.
  • Target-specific local tests passed (see profile test.md).

CI

  • Authoritative CI run for this PR's current head (test-pull-request) on
    05be00d2f137907414582d362e9836f8322e5dcc: id 33815830447
    https://github.com/IvanMurzak/Unity-MCP/actions/runs/3381583044717/17 checks green:
    nuget gate, test-cli (20), test-cli (22), the twelve licensed test-unity-* legs
    (2022.3.62f3 / 2023.2.22f1 / 6000.3.1f1 x editmode/standalone x base/windows-mono),
    Test Results and save-event-file. SHA-tied: that run's headSha equals this PR's
    current headRefOid, so it covers the refine commit as well as the implement commit.
  • SUPERSEDED — PR run on the pre-refine head 80143639: id 33807352185
    https://github.com/IvanMurzak/Unity-MCP/actions/runs/33807352185 — all 17 checks green
    (SHA-tied: 801436398b953ad345e454b3f719fdc5b96a65c9). Retained for history only; it does
    NOT cover the refine commit. The authoritative run above is the one this PR is gated on.
    This is a same-repository branch, so the twelve licensed test-unity-* legs
    receive the repository secrets (the late-August red runs on this repo were fork PRs, per docs(ci): the red test-unity legs are fork PRs with no secrets, not a regression #974).

IvanMurzak and others added 2 commits September 3, 2026 14:18
…ip download + version match

Adds a dev/CI-only override so a workspace-built `gamedev-mcp-server` can be
driven end-to-end without cutting a GameDev-MCP-Server release. When
`UNITY_MCP_SERVER_PATH` resolves to a file that EXISTS, that file is what
`StartServer` launches, `ExecutableFolderPath` (its WorkingDirectory) is the
override's own directory, and `IsVersionMatches()` reports true — so
`IsBinaryReadyToStart()`, the `DownloadServerBinaryIfNeeded` gate and the
post-publish check all short-circuit and `DownloadAndUnpackBinary` is never
entered from the editor-startup or package-update paths. Set-but-missing falls
through to the pinned release, matching Unreal-MCP's `UNREAL_MCP_SERVER_PATH`.

Resolved through the existing `DevControlEnv.Resolve` layer (process env >
`<projectRoot>/.env`) rather than a bare `Environment.GetEnvironmentVariable`,
so an Editor launched from the GUI or an IDE — which inherits no shell exports
— can still pick the override up from a `.env` file.

`DownloadAndUnpackBinary` now publishes to a new `CachedExecutableFolderPath`
instead of `ExecutableFolderPath`, so the manual
`Tools/AI Game Developer/Server/Download Binaries` menu item always lands in
`Library/mcp-server/<rid>/` and can never delete and replace the directory the
override points at.

`ServerVersion` is unchanged and no `.github/**` file is touched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bac1LKpVobv1i1FNGCRNvM
Three report-only reviewers plus an in-context pass over the UNITY_MCP_SERVER_PATH
override. Everything below was verified from source before it was applied.

Product (McpServerManager.cs) - the download path must not read the launch target.
Once the override makes ExecutableFullPath / IsBinaryExists() / IsVersionMatches() /
GetBinaryVersion() describe the developer's own binary, every existing READER of them
has to be re-classified. PublishStagedBinary was moved to CachedExecutableFolderPath
already; the verification and the reporting beside it were not, and the manual
Tools/AI Game Developer/Server/Download Binaries item still reaches them:

- the post-publish verification was VACUOUS under an active override. Both checks
  were true by construction (the resolver returns a path only after File.Exists
  passed; IsVersionMatches() short-circuits to true), so a publish that landed
  nothing in Library/mcp-server/<rid>/ reported success. It now verifies
  CachedExecutableFullPath and GetCachedBinaryVersion() - the cache it actually
  wrote to.
- the success log named the override as the download destination, contradicting the
  documented "downloads into Library/ regardless of the override" sentence.
- ShowUpdateResultPopup rendered Version: from GetBinaryVersion(), which follows the
  override's directory - where the tests assert no version marker normally sits, so
  the popup would read blank after a successful download.
- StartServer derives WorkingDirectory from the executablePath local instead of
  re-resolving ExecutableFolderPath, so FileName and WorkingDirectory cannot end up
  describing different binaries.

Adds a non-public CachedExecutableFullPath (which also removes the duplicated cache
composition inside ExecutableFullPath) and GetCachedBinaryVersion(), public so the
launch-vs-download contrast can be asserted. IsVersionMatches()'s comment no longer
claims the post-publish check as a beneficiary of its short-circuit.

Tests (McpServerPathOverrideTests.cs):

- [SetUp] MOVES the real <projectRoot>/.env aside instead of deleting it. That file
  is gitignored, user-owned config this feature's own docs tell developers to create,
  so git holds no copy and a run killed before [TearDown] destroyed it with the only
  copy in a managed field. [SetUp] also recovers a file left parked by an earlier
  killed run, and a failed restore is now a Debug.LogError rather than a silent
  best-effort catch.
- removed two assertions that could not fail: GetBinaryVersion() re-derived from its
  own body, and IsBinaryReadyToStart() restated as IsBinaryExists() &&
  IsVersionMatches() (whose operands are equal in both environments the suite runs
  in, so even the && -> || mutation leaves it green). The entailed but DoD-mandated
  IsBinaryExists() assertions are kept and labelled as documentation, not evidence.
- new test for the public projectRootPath overload, which was documented as the
  unit-test seam and used by no test.
- new assertions pinning that an active override does NOT move the download cache,
  and that the cache version read does not follow the override.

Docs: the override notice is logged once per domain load, so it is absent when the
.env is written afterwards - the Starting MCP server line is the reliable
confirmation and the docs now say so. Adds the relative-path and Open Server Logs
notes.

Verification: 6 plants, Edit tool, each confirmed live in git diff and reverted
after, verdicts read per test from each run's own JSON (unity-mcp-cli exits 0 even
when tests fail); every one RED and attributed, final round 7/7 green with Logs: [].
Full chunked EditMode suite green. PR body updated with the corrected
environment-conditional disclosure (four assertions, not one).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bac1LKpVobv1i1FNGCRNvM
@IvanMurzak
IvanMurzak merged commit 70918e7 into main Sep 3, 2026
17 checks passed
@IvanMurzak
IvanMurzak deleted the worktree-p1-server-path-unity branch September 3, 2026 23:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant