feat(connection): honour GODOT_MCP_SERVER_PATH via the env/.env layer - #354
Merged
Conversation
Add the one permitted product knob for the chain-testing work (taskflow 2026-09-03-chain-testing, row p1-server-path-godot): an editor-side override that launches a caller-supplied gamedev-mcp-server instead of the release pinned by GodotMcpServerView.ServerVersion. - GodotMcpEnv.ServerPath registers GODOT_MCP_SERVER_PATH in the canonical env-name class, so no call site hard-codes the literal. - GodotMcpServerPathOverride is a new pure-managed resolver beside the server manager it serves (the DevControlGate pattern): the caller does the I/O and passes raw strings plus a fileExists delegate, so the precedence, the normalization, the existing-file gate, the launch path and the working directory are all unit-testable in the binary-less xUnit host. - GodotMcpServerManager resolves the raw value ONCE per boot with the addon's standard precedence -- process env > project res://.env via GodotMcpEnvFile.LookupRaw -- the same order GODOT_MCP_DEV_CONTROL uses. While it is active the release download is skipped BEFORE the CI check, IsVersionMatches() is true, the launch working directory is the override binary's own folder, and orphaned-server cleanup is skipped: that cleanup claims every server process in the same directory, which a shared override binary is expected to have siblings in. - A value naming no existing file is ignored and the normal download path runs, matching UNREAL_MCP_SERVER_PATH's ResolveBinaryPath rule. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Bac1LKpVobv1i1FNGCRNvM
Review pass over the GODOT_MCP_SERVER_PATH override (4 report-only helpers + an in-context pass). Behaviour-preserving except for one added diagnostic. Correctness / observability - A value that is SET but names no existing file was ignored in complete silence, byte-indistinguishable from "not set": the pinned release was downloaded and launched with nothing in the log. That is the worst outcome for this feature's own use case (a CI or dev run whose point is to exercise its OWN server build would pass against the wrong binary). Added GodotMcpServerPathOverride.IsIgnoredValue plus a warning at the boot site, which now caches the selected-but-ungated raw value alongside the gated one. - DownloadAndUnpackBinary's post-unpack verdict used the launch-scoped IsBinaryExists()/IsVersionMatches() pair, both of which an active override answers unconditionally, so the check that exists to prove the unpack produced a good cache could not fail. Re-scoped to the cache, matching the CachedExecutableFullPath() choice the same hunk already made for the unpack target. Unreachable under override today (the download short-circuits above it), so this is a no-op on every path reachable now. - IsBinaryExists()'s doc still said "the cached executable" after ExecutableFullPath() became override-aware underneath it; corrected. KillOrphanedServerProcesses now takes the cache-scoped accessor directly on the branch where the override is provably absent. Testability (DoD 3) - The two under-override decisions lived as a bare `||` and an early `return` inside GodotMcpServerManager, which is #if TOOLS and is not compiled into the xUnit host at all — so their POLARITY was pinned by nothing. Factored into VersionMatchesOrOverridden (delegate-valued, so the cache read keeps its short-circuit) and ShouldKillOrphans, and pinned. Tests - WorkingDirectory_NoOverride_IsStillTheCacheFolder passed a fallback equal to the executable's own directory, so both arms returned the same string and no mutation of that method could redden it. Given a fallback the answer must not be. - Reordered the two assertions in Resolve_SetButMissingFile_ReturnsNull and added an IsActive assertion to ExecutablePath_NoOverride_LaunchesTheCachedBinary so the plants sharing those markers now fail with different text. - Pinned the single-quote/double-quote normalization ORDER the docstring claims (only a nested pair discriminates it), and covered the blank-input, misuse-guard and two-layer-composition claims that had assertions but no plant. Docs - README: "read once per editor session" was false (it caches per assembly load; a C# hot-reload re-reads, a plugin toggle does not). Noted the ignored-value warning and that an override binary must already be executable on Unix. - Removed two workspace-internal nouns from added comments that resolve nowhere for a reader of this repository. Gates: dotnet build 0 errors / 0 warnings; dotnet test 1334/1334 (was 1329). Plant round re-run and extended to 22 plants against the final tree: 22/22 matched their expect | 22 RED | 0 GREEN | restore-failures 0. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Bac1LKpVobv1i1FNGCRNvM
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
GODOT_MCP_SERVER_PATHin the addon's existing env resolution layer — process env > projectres://.env(GodotMcpEnvFile.LookupRaw) > none, the same precedenceGODOT_MCP_DEV_CONTROLuses — so a chain/CI-builtgamedev-mcp-servercan be launched instead of the release pinned byGodotMcpServerView.ServerVersion. Never a bareEnvironment.GetEnvironmentVariable.IsVersionMatches()as true (an arbitrary build carries noversionmarker), launches with the override binary's own directory as the working directory, and skips orphaned-server cleanup. The last one is a correctness requirement:GodotMcpServerOwnership.IsOwnedByThisProjectmatches on the same containing directory, and an override binary is shared by design, so the cleanup would kill a sibling project's or harness's live server.UNREAL_MCP_SERVER_PATH/ResolveBinaryPathrule.GodotMcpServerPathOverride(theDevControlGatepattern): the caller does the env/file I/O and passes raw strings plus afileExistsdelegate. This is deliberate — the manager statics that would otherwise carry them (ExecutableFullPath/IsVersionMatches/KillOrphanedServerProcesses) are#if TOOLSand reachProjectSettings.GlobalizePath, which faults in the plain-xUnit host, so they are not unit-testable at all; factoring the decisions out is what makes them pinnable.Taskflow
2026-09-03-chain-testing, rowp1-server-path-godot(goal G13) — the one permitted product code change for this plugin (design decision D3 / C30).Files
addons/godot_mcp/Runtime/GodotMcpEnv.cs+ ServerPath = "GODOT_MCP_SERVER_PATH"in the canonical env-name class.addons/godot_mcp/Editor/Connection/GodotMcpServerPathOverride.cs(new)addons/godot_mcp/Editor/Connection/GodotMcpServerManager.csCachedExecutableFullPath()keeps the download target override-free.Godot-MCP.Tests/GodotMcpServerPathOverrideTests.cs(new)Godot-MCP.Tests/Godot-MCP.Tests.csproj<Compile Include>for the new resolver source.README.md.github/**diff is empty (owned byp2-dispatch-godot-mcp).Godot-MCP.csprojpins,Godot-Tests/**,cli/**,plugin.cfgandGodotMcpServerView.ServerVersionare untouched.Test plan
test.md).dotnet restore+dotnet build Godot-MCP.sln --configuration Debug --no-restore: 0 errors, 0 warnings.dotnet test Godot-MCP.Tests/...: 1334 passed / 0 failed (was 1310 before this change; +24 new test methods — 19 from the first push, 5 more from the refine pass below).0a58c26): 22/22 matched their expect | 22 RED | 0 GREEN | restore-failures 0, harness exit0, source restored byte-exactly (md5c164fbc6451f477401312079fbd715febefore and after). Every one of the 22 per-plant logs collectedTotal tests: 1334— identical to the clean run — so no RED came from an import/compile break.cli/npm) — not applicable: nocli/file in the diff and no tool-surface /ToolTypechange, which are the only triggerstest.mdlists for it.Plant table — every RED attributed from its own per-plant log
Each plant reverts exactly one claim; the verify command rebuilds and re-runs the suite
(
dotnet build … && dotnet test … --no-build --verbosity normal), so a plant cannot score against astale assembly. All 22 per-plant logs collected
Total tests: 1334— identical to the clean-baseline run onthis same tree — so no RED came from an import/compile break. The round was re-run against the
final tree
0a58c26with every per-plant log retained;SUMMARY 22/22 matched their expect | 22 RED | 0 GREEN | restore-failures 0, harness exit0, anddigest_before == digest_afteron all 22 restores.P20/P21 share one marker and are discriminated by assertion, not by test name (
Assert.Falseatline 284 vs
Assert.Trueat line 280).NN-<name>.log)fileExistsgate (return normalized;)Resolve_SetButMissingFile_ReturnsNull(+6 siblings incl.Resolve_ExistingFile_ReturnsThatPath, whose recorded-probe assertion is what sees a removed gate)fileExistsgateResolve_ExistingFile_ReturnsThatPath(+6)Resolve_DoubleQuotedValue_TrimsQuotesBeforeTheExistenceGate,Normalize_TrimsWhitespaceThenOnePairOfQuotes,SelectRaw_AppliesPrecedenceAndNormalizationResolve_SingleQuotedValue_TrimsQuotesBeforeTheExistenceGate(+2).envbeats process env)Resolve_BothLayersSet_ProcessValueWins,SelectRaw_AppliesPrecedenceAndNormalizationExecutablePathignores the overrideExecutablePath_OverrideActive_LaunchesTheOverrideExecutablePathalways uses the overrideExecutablePath_NoOverride_LaunchesTheCachedBinaryWorkingDirectoryalways returns the fallbackWorkingDirectory_IsTheDirectoryOfTheResolvedExecutableWorkingDirectorydrops the fallbackWorkingDirectory_PathWithoutADirectoryComponent_FallsBackIsActivealways trueIsActive_OnlyForANonEmptyResolvedOverride,ExecutablePath_NoOverride_LaunchesTheCachedBinaryfileExistsargument rather than only anull/non-nullAdded by the refine pass (P11-P22). The verify command is unchanged, so these ran under the same
rebuild-then-retest shape as P1-P10.
NN-<name>.log)normalized = string.Emptyinstead of returning)Resolve_NullEmptyOrWhitespace_ReturnsNullWithoutProbingNormalizestops collapsing blank to null (return trimmed;)Normalize_NullEmptyOrWhitespace_ReturnsNullResolve'sfileExistsnull guardResolve_NullFileExistsDelegate_ThrowsResolvedrops the.envlayerResolve_ProcessValueBlank_FallsBackToEnvFileValueNormalize_StripsSingleQuotesBEFORETheSharedDoubleQuoteNormalizerShouldKillOrphanspolarity invertedShouldKillOrphans_OnlyWithoutAnOverrideVersionMatchesOrOverridden||→&&(override stops forcing true)VersionMatchesOrOverridden_TrueUnderOverride…(Assert.True, line 247)VersionMatchesOrOverridden_TrueUnderOverride…(Assert.False(consulted), line 251)VersionMatchesOrOverriddenstops deferring without an override (return true;)VersionMatchesOrOverridden_NoOverride_DefersToTheCachedVersionVerdictIsIgnoredValuefires when nothing was supplied (drop the raw check)IsIgnoredValue_TrueOnlyWhen…(Assert.False, line 284)IsIgnoredValuenever fires (=> false)IsIgnoredValue_TrueOnlyWhen…(Assert.True, line 280)WorkingDirectoryno-override answer comes from the fallback (ternary inverted)WorkingDirectory_NoOverride_IsStillTheCacheFolderTwo plants share a marker in each of two groups (P17/P18 and P20/P21), and both groups were
hand-checked to fail DIFFERENTLY — the harness's own shared-marker comparator extracts a pytest
FAILURESblock and so reportscould NOT be comparedon this xUnit profile, which is not a pass.Read from the per-plant logs: P17 fails
Assert.Trueat line 247 while P18 failsAssert.False(consulted)at line 251; P21 fails
Assert.Trueat line 280 while P20 failsAssert.Falseat line 284. Distinctassertion, line and text in both groups.
Refine pass (
0a58c26)Four report-only review helpers plus an in-context pass. Behaviour-preserving except for one added
diagnostic. What it changed and why:
"not set", so a CI or dev run whose purpose is to exercise its OWN server build would pass against
the downloaded release with nothing in the log. New pure
IsIgnoredValue+ a warning at the bootsite, which now caches the selected-but-ungated raw value alongside the gated one (P20/P21).
DownloadAndUnpackBinary's post-unpack verdict could not fail under an override: it used thelaunch-scoped
IsBinaryExists()/IsVersionMatches()pair, both of which an active override answersunconditionally. Re-scoped to the cache, matching the
CachedExecutableFullPath()choice the samehunk already made for the unpack target. Unreachable under override today, so it is a no-op on every
currently reachable path — the fix removes a latent trap rather than a live bug.
||and an earlyreturninsideGodotMcpServerManager, which is#if TOOLSand is not compiled into the xUnit host,so their POLARITY was pinned by nothing — only the
IsActiveinput was. Factored intoVersionMatchesOrOverridden(delegate-valued, so the cache read keeps its short-circuit) andShouldKillOrphans(P16-P19).WorkingDirectory_NoOverride_IsStillTheCacheFolderpassed a fallbackequal to the executable's own directory, so both arms of the method returned the same string and
neither P8 nor P9 could redden it. It now passes a fallback the answer must not be (P22).
IsBinaryExists()'s summary still said "the cached executable" afterExecutableFullPath()became override-aware underneath it; the README's "read once per editor session" was false (the cache
is per assembly load — a C# hot-reload re-reads it, a plugin toggle does not); added the
ignored-value warning and the Unix executable-bit caveat.
Recorded, deliberately NOT changed (each was raised by a helper and rejected on evidence):
GodotMcpServerPathOverride.Normalizeduplicates the privateGodotMcpEnvFile.Sanitize— sharing itwould edit a file outside the task's capped product-diff set. Removing the double normalization on the
production path is a BEHAVIOUR change, not a cleanup:
"'…'"unwraps today and would stop, so it isdocumented instead. A relative override path is accepted by the existence gate but then does not get
its own directory as the working directory — documented on
WorkingDirectory; rejecting non-rootedvalues was declined as an unforced behaviour restriction. Two helpers recommended deleting
VersionMatchesOrOverridden/ShouldKillOrphansasa || band!are-implemented — declinedbecause DoD 3 requires exactly that factoring, with the class doc updated so the file no longer reads
as contradicting itself.
Honest limitation. The download-skip, version-match bypass and orphan-cleanup skip are ONE
decision (
IsActive) read at three call sites, not three independent flags — so they are entailed byeach other and one plant (P10) covers the predicate. The manager call sites themselves carry no
unit plant:
GodotMcpServerManageris#if TOOLSand is not compiled into the xUnit assembly, so anyplant there would score a (correct but useless) GREEN. The refine pass above closed the two that WERE
expressible as pure decisions (
VersionMatchesOrOverridden,ShouldKillOrphans, plants P16-P19); what remains uncovered by a unit plant is the manager WIRING itself — the early return's position BEFORE the CI check, and the four call sites — covered by the compile and by the behavioural proof below.Behavioural proof — headless Godot 4.5.1 mono,
engines/godot/test-projectHeld-open
--editorsession with the dev-control bridge,POST /control/click {"target":"start-server"}.With
GODOT_MCP_SERVER_PATHset to a locally builtgamedev-mcp-server.exe:grep -c "downloading GameDev-MCP-Server"= 0, andtest-project/.godot/mcp-server/was ABSENT after the run.Same testbed, same editor, variable UNSET (control):
.godot/mcp-server/win-x64/was then present withgamedev-mcp-server.exe+ theversionmarker.Notes for the reviewer
ExecutableFullPath()is now "what do we launch"; the newCachedExecutableFullPath()is "where does the download land".DownloadAndUnpackBinaryuses the latter, so unpacking can never be redirected at the override.docs/runtime-security.md's env table was deliberately not touched: it is scoped to the game-build runtime config read byGodotMcpConfig, and this variable is editor-only.CI on the final head
0a58c264d039b6a893d6fe314cf9b1bb907f226bCICITest Pull RequestAll three runs are tied to headSha
0a58c264d039b6a893d6fe314cf9b1bb907f226b, the current head of thisbranch — status-check rollup 28/28 COMPLETED/SUCCESS at that SHA.
Superseded, kept for history: the first push
8966f2dwas green on33804240480 (
CI) and33804240776 (
Test Pull Request).🤖 Generated with Claude Code
https://claude.ai/code/session_01Bac1LKpVobv1i1FNGCRNvM