Skip to content

fix(windows): launch MCP engine via sh wrapper to bypass CreateProcess - #61

Closed
5uck1ess wants to merge 1 commit into
mainfrom
fix/windows-mcp-sh-wrapper
Closed

fix(windows): launch MCP engine via sh wrapper to bypass CreateProcess#61
5uck1ess wants to merge 1 commit into
mainfrom
fix/windows-mcp-sh-wrapper

Conversation

@5uck1ess

Copy link
Copy Markdown
Owner

Summary

Why the simpler fixes don't work

Every obvious approach is a dead end, which is why this took investigation:

Approach Status Reason
bin/devkit.cmd sibling, unchanged plugin.json Dead Node only applies PATHEXT on bare command lookups through PATH, not absolute paths. spawn('.../bin/devkit') just ENOENTs regardless of sibling files.
plugin.jsonbin/devkit.cmd Dead CVE-2024-27980. Node ≥ 20.12.2 refuses to spawn .cmd/.bat without shell: true and throws EINVAL at the runtime level. Claude Code's MCP launcher doesn't opt in.
Platform-branched command in plugin.json Dead I pulled the CC binary's Zod schema out of strings: mcpServers.* is h.strictObject({command: string, args: array, env: object}). Extra keys (e.g. {win32: ..., darwin: ...}) are rejected at parse time.
Rename to bin/devkit.sh + ship bin/devkit.exe Dead Same strict-schema reason — plugin.json can't select between them.
Polyglot .cmd/sh file Ugly and still hits CVE-2024-27980 via the .cmd extension.
PowerShell .ps1 wrapper Works but duplicates ~150 lines of download/resume/checksum logic from bin/devkit into a second implementation.

Why sh works on both sides

  • POSIX (macOS/Linux): /bin/sh /path/bin/devkit mcpsh is always at /bin/sh, always on PATH. This is functionally identical to the old shebang-based spawn, just explicit.

  • Windows: CC hard-requires Git Bash — I pulled this exact error string from the CC binary:

    "Claude Code on Windows requires git-bash. If installed but not in PATH, set CLAUDE_CODE_GIT_BASH_PATH=..."

    It process.exit(1)s if bash.exe can't be found. So sh.exe is guaranteed present wherever CC runs. Critically:

    1. "sh" is a bare command name, so Node does PATH + PATHEXT resolution → picks up Git Bash's sh.exe.
    2. .exe spawns are not blocked by CVE-2024-27980 — that's .cmd/.bat only.
    3. Git Bash's sh.exe auto-translates ${CLAUDE_PLUGIN_ROOT}'s Windows-style path internally.
    4. bin/devkit already has the MINGW*|MSYS*|CYGWIN* branch in detect_platform and the full cygpath fallback logic for gh and PowerShell downloaders — so the script is already designed to run under Git Bash. This PR just makes that path explicit.

End-to-end validation on Windows 11

Reporter (#60) ran the patched plugin.json through:

1. Spawn-layer repro (what the CC UI launcher does):

spawn('sh', ['.../bin/devkit', 'mcp'], { stdio: ['pipe','pipe','pipe'] })

No EINVAL, no ENOENT.

2. claude mcp list:

plugin:devkit:devkit-engine: sh .../bin/devkit mcp - ✓ Connected

3. Full JSON-RPC roundtrip through the new launcher:

[err] devkit MCP server ready
[1] initialize                 → OK  serverInfo = { name: "devkit-engine", version: "1.0.0" }
[2] notifications/initialized  → sent
[3] tools/list                 → OK  count=4  [ devkit_advance, devkit_list, devkit_start, devkit_status ]
[4] tools/call devkit_list     → JSON-RPC response returned

CC UI confirmation post-restart will be appended to the issue thread by the reporter.

Test plan

  • Confirm sh resolution on POSIX (trivial — /bin/sh is standard)
  • Confirm sh.exe resolution on Windows via Git Bash (reporter validated)
  • Confirm .exe spawn is not affected by CVE-2024-27980 (confirmed via Node docs)
  • Confirm end-to-end JSON-RPC roundtrip through the new launcher on Windows (reporter validated)
  • Confirm claude mcp list shows Connected on Windows (reporter validated)
  • Manual smoke on macOS after merge: /mcp UI shows devkit-engine Connected, devkit_list returns workflows
  • Manual smoke on Linux after merge: same

Out of scope (filing separately)

Two side findings the reporter spotted while validating end-to-end, unrelated to this launcher fix:

  1. _principles.yml not found stderr warning from the engine at startup — cosmetic missing-bundled-asset issue.
  2. devkit_list returned isError: true with "no workflows directory: C:\...\plugins\cache\context-mode\1.0.75\workflows" — the engine is resolving its workflows directory relative to another plugin's cache dir. Looks like CLAUDE_PLUGIN_ROOT/cwd cross-contamination in the engine, not the launcher.

Both will get their own issues after this lands.

Windows `/mcp` UI couldn't spawn `bin/devkit` because it's an
extensionless POSIX shell script and Claude Code's MCP launcher uses
`child_process.spawn` without `shell: true`. CreateProcess can't run
shebang scripts, and the file has no `.exe`/`.cmd`/`.bat` extension for
PATHEXT fallback.

Naive shim options are all dead:
  - `.cmd` sibling with absolute `command` path: Node only applies
    PATHEXT on bare command lookups through PATH, not absolute paths.
  - `plugin.json` pointing at `bin/devkit.cmd`: blocked at runtime by
    CVE-2024-27980 — Node >= 20.12.2 refuses to spawn .cmd/.bat
    without `shell: true` and throws EINVAL.
  - Platform-branched `command`: Claude Code's plugin.json parser uses
    `h.strictObject` on mcpServers.* entries and rejects extra keys.

The fix: make `sh` the spawned process and pass the existing POSIX
wrapper as its first argument. `sh` is a bare command, so Node resolves
it through PATH + PATHEXT; on POSIX that's `/bin/sh`, and on Windows
Claude Code already hard-requires Git Bash (it `process.exit(1)`s
without bash on PATH), so `sh.exe` is guaranteed present. `.exe` spawns
are unaffected by CVE-2024-27980. `bin/devkit` already has the
MINGW/MSYS/CYGWIN branch and `cygpath` fallbacks for `gh` and
PowerShell downloaders, so it's already designed to run under Git Bash.

Validated end-to-end on Windows 11 by the reporter of #60:
  - `claude mcp list`: devkit-engine Connected
  - `spawn('sh', [.../bin/devkit, 'mcp'])`: no EINVAL, no ENOENT
  - Full JSON-RPC roundtrip: initialize, notifications/initialized,
    tools/list (4 tools), tools/call devkit_list all succeed

Closes #60
@5uck1ess

Copy link
Copy Markdown
Owner Author

🛑 Hold — Option E does not work in the Claude Code /mcp UI on Windows. Please don't merge this yet.

I'm the reporter from #60. The PR body says "reporter validated" and "CC UI confirmation post-restart will be appended to the issue thread by the reporter" — that's premature. Here's what actually happened when I tested it in the UI after the patch:

What I tested after the previous comment

  1. Applied the exact diff in this PR to the cached plugin.json.
  2. Fully quit Claude Code.
  3. Reopened CC. /mcpdevkit-engine still shows × failed.
  4. Opened a second fresh CC window (in case the first was caching something). Still × failed in that window too.
  5. claude mcp list in the same box still shows ✓ Connected — the UI/CLI divergence is still there.

So the disagreement between CLI probe and UI launcher that started this whole investigation is also present with the sh-based launcher. Option E fixes the CLI probe but does not fix the actual UI failure mode, which is the one that matters.

Root cause I just reproduced

After seeing the UI fail, I went back to child_process.spawn and tried to match the CC UI launcher's environment more precisely. The issue is that CC's UI launcher spawns MCP server children with a PATH that does not contain Git Bash's bin directory.

Repro — strip Git Bash entries from PATH, then spawn sh:

const winPath = (process.env.PATH || '')
  .split(';')
  .filter(p => !p.toLowerCase().includes('git') && !p.toLowerCase().includes('usr'))
  .join(';');

spawn('sh',
  ['C:/Users/tymra/.claude/plugins/cache/5uck1ess-plugins/devkit/2.1.6/bin/devkit', 'mcp'],
  { stdio: ['pipe','pipe','pipe'], env: { ...process.env, PATH: winPath } }
);

Result:

filtered PATH entry count: 37
has git-bin? false
SPAWN-ERROR: ENOENT spawn sh ENOENT

Identical failure mode to the UI. sh is not resolvable when Git Bash isn't on PATH, and Node's PATH + PATHEXT resolver for bare command names only searches what's actually in PATH.

Why claude mcp list works but the UI doesn't

This is the piece we both missed earlier. Two different code paths:

  • claude mcp list runs inside the user's interactive shell (which for CC-on-Windows users is Git Bash or at least a terminal with Git Bash on PATH). The child inherits that parent shell's PATH, so sh resolves fine.
  • CC UI's in-session MCP launcher is inside the Electron/Node main process, whose PATH comes from however CC was launched (Start menu shortcut, claude.exe from a Windows Terminal tab that doesn't have Git Bash prepended, etc.). In many common launches, Git Bash's bin is not on that PATH, even though CC itself found bash.exe elsewhere (via its own resolver / CLAUDE_CODE_GIT_BASH_PATH env var at a different layer).

Confirming this: I checked CLAUDE_CODE_GIT_BASH_PATH on my box → unset. CC is finding Git Bash some other way (probably HKLM/SOFTWARE/GitForWindows/InstallPath or %ProgramFiles%\Git\bin\bash.exe) but it's not prepending that directory to the child env when it spawns MCP servers.

Which means: any fix that relies on bare sh being resolvable in the MCP server child's PATH is going to be fragile. It'll work for some users (whoever launches CC from a Git Bash terminal) and silently break for others (Start menu, Windows Terminal without custom profile, PowerShell, etc).

What actually works in the spawn layer

From my previous Node repros, only two launcher forms work when spawned by the UI-shaped launcher (no shell, default CC env):

  1. spawn('…/bin/devkit-engine-<version>-<platform>-<arch>.exe', ['mcp'], {}) — clean roundtrip, full MCP, initialize + tools/list + tools/call all work. .exe is first-class for CreateProcess, no CVE exposure, no PATH dependency.

  2. spawn('<absolute path to sh.exe>', ['…/bin/devkit', 'mcp'], {}) — would presumably work if the absolute path were baked in, but that's not portable across Git installs.

(For completeness: .cmd shims → EINVAL from CVE-2024-27980; bare sh → ENOENT without Git Bash on PATH; extensionless script direct → ENOENT because Windows doesn't honor shebangs.)

Recommended direction

Option E as written in this PR works in some launch contexts and not others. On my box specifically, it doesn't work in the UI even with CC restarted from scratch in a new window. I'd flag this as a Windows regression risk if it merges without more environments tested.

Concrete alternatives, in order of effort:

E′ — resolve sh.exe to an absolute path in bin/devkit installer / plugin.json rewrite step

If there's any install-time hook that runs once, have it find bash.exe/sh.exe (where sh, CLAUDE_CODE_GIT_BASH_PATH, common install paths) and rewrite plugin.json's command to that absolute path. Same design as Option E but without the PATH-resolution fragility. Downside: needs a first-run install step that CC actually supports, which I'm not sure it does.

C — tiny native launcher stub (bin/devkit-launcher-<platform>-<arch>.exe)

Still my recommendation. Ship a ~100-line Go binary per-platform in the release tarball whose entire job is: find the engine binary (download on first run if needed, same logic as bin/devkit), exec it with forwarded args. Because it's a .exe, CreateProcess handles it directly. plugin.json points at it with an extensionless base name + no PATH dependency. Zero shell dependency. Works in every CC launch context.

The engine binary already exists as a Go program with a cross-compile matrix — adding a second tiny Go binary with just os/exec + path lookup is a few hours of work and eliminates this whole class of problem forever.

Z — per-platform plugin.json, picked at install time

If CC ever gains os/platform fields in mcpServers.* schema (or you can convince them to), this becomes a three-line fix. Not available today per your own binary-string digging.

What I'd like to do right now

  • Revert the PR locally and test the .exe-direct workaround in the UI (I never actually visually confirmed that one either — we pivoted to Option E based on your comment before I checked).
  • Report back with UI evidence for the .exe-direct path.
  • If that works, I think the right move is ship E as an opportunistic improvement but block the close of Windows: /mcp UI fails to spawn bin/devkit (extensionless shell script) #60 on a proper fix (Option C) — because Option E's environment sensitivity is a real landmine for users whose Windows setup doesn't happen to have Git Bash on the CC process PATH.

Happy to test whatever you want. Please hold the merge until I can confirm a launcher form that actually lights the UI up green on my box — otherwise we'll be closing #60 on a fix that doesn't fix it.

@5uck1ess

Copy link
Copy Markdown
Owner Author

Hold — Option E fails in the CC /mcp UI on Windows

Retested with actual UI verification this time (earlier "validated" was from Node spawn repros inside Git Bash — contaminated env, hid the bug):

Option E (this PR's diff): applied to cached plugin.json, fully quit CC, reopened in a fresh window → /mcp shows plugin:devkit:devkit-engine · × failed. Confirmed in a second fresh window from a different launch context. claude mcp list still reports ✓ Connected in parallel — so the original CLI/UI divergence from #60 is still present with this PR applied. It fixes the CLI probe, not the UI.

Root cause. spawn('sh', [...]) resolves sh through the child's PATH + PATHEXT. CC's UI launcher does not propagate Git Bash's bin directory onto MCP server children's PATH (confirmed: CLAUDE_CODE_GIT_BASH_PATH is unset here, yet CC itself runs fine — it's finding bash.exe through some other channel that isn't inherited by children). Reproduced the exact UI failure with:

spawn('sh', ['…/bin/devkit','mcp'], {
  env: { ...process.env, PATH: pathWithoutGitBash }
})
// → SPAWN-ERROR: ENOENT spawn sh ENOENT

claude mcp list looks fine because it inherits the interactive Git Bash shell's PATH. That's the whole UI/CLI divergence in one sentence.

Net: Option E silently ships a Windows regression for any user whose CC launch context doesn't happen to put Git Bash on the spawn child's PATH (Start menu, desktop shortcut, Windows Terminal default profile, PowerShell, etc). Merging it closes #60 on a fix that doesn't fix the reported symptom for a real chunk of users.

What actually works in the UI

"command": "${CLAUDE_PLUGIN_ROOT}/bin/devkit-engine-v2.1.6-windows-amd64.exe",
"args": ["mcp"]

Fresh CC session → /mcp shows plugin:devkit:devkit-engine · √ connected. Devkit's MCP tools (devkit_advance, devkit_list, devkit_start, devkit_status) are actually registered and callable in the session — not just a green status. That's the load-bearing evidence I was missing before.

.exe is first-class for CreateProcess, zero PATH / sh / shell dependency, no CVE-2024-27980 exposure.

Chicken-and-egg caveat

.exe-direct only works because the wrapper had already run once to download the engine binary. A fresh install has only bin/devkit (the shell wrapper) in the tarball and no engine .exe yet — so pointing plugin.json directly at the engine on day one would ENOENT. This is a local workaround, not a shippable one-line fix.

Recommended upstream direction

Ship a tiny native launcher .exe per-platform in the release tarball whose entire job is: (1) locate the engine binary next to itself, (2) download it the first time if missing (port the resume/checksum logic from bin/devkit), (3) exec it with forwarded args. Because the launcher itself is a .exe, CC's CreateProcess-based spawn handles it in every Windows launch context. No sh dependency, no .cmd shims, no PATH fragility. plugin.json points at the launcher per-platform.

Happy to help test whatever direction you pick — now that I know not to trust programmatic repros that run inside Git Bash, I can validate launcher forms against an actual fresh-CC UI probe.

Ask

  1. Please don't merge this PR as-is — it's a Windows regression for the launch contexts where it matters most.
  2. Keep Windows: /mcp UI fails to spawn bin/devkit (extensionless shell script) #60 open — underlying UI-failure bug is not fixed.

@5uck1ess

Copy link
Copy Markdown
Owner Author

Superseded by #62 — real Go launcher in an MCPB bundle with platform_overrides.win32. The command: "sh" approach here is a Windows regression: CC's UI MCP child PATH on Windows does not include Git Bash (confirmed from the reporter's real CC debug log), so bare-command sh resolution ENOENTs. Closing as wontfix.

@5uck1ess 5uck1ess closed this Apr 10, 2026
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.

Windows: /mcp UI fails to spawn bin/devkit (extensionless shell script)

1 participant