Summary
On Windows, when the login shell resolves to Git Bash / MSYS ($SHELL set, or otherwise detected as bash-like), shellwrap.WrapWithUserShell correctly single-quotes a backslash-style Windows path (e.g. C:\ProgramData\foo\bar.cmd), but Git Bash still cannot execute it. MSYS's exec layer only resolves POSIX-style paths (C:/ProgramData/foo/bar.cmd); a backslash-style path falls through to bash's PATH lookup, which fails with command not found and — in its own error rendering — drops the backslashes entirely, producing a confusing message like:
/usr/bin/bash: line 1: C:ProgramDataQalatCybermcpproxy-wrappersnexusmods-mcp.cmd: command not found
This affects every stdio server whose command is an absolute Windows path (wrapper .cmd/.bat scripts are the common case) on any host where the resolved login shell is bash-like — i.e. any Windows box with Git Bash and $SHELL set, which is common for anyone using Git Bash / VS Code's integrated terminal / MSYS2 as their default shell.
Repro
- On Windows, with
$SHELL set to a Git Bash path (e.g. C:\Program Files\Git\bin\bash.exe), configure a stdio server with an absolute Windows-path command, e.g.:
{ "command": "C:\ProgramData\SomeVendor\wrappers\foo.cmd" }
- Start mcpproxy and watch it try to connect. It fails every retry with:
failed to connect: stdio transport (command="C:\Program Files\Git\bin\bash.exe", ...):
server process exited before completing the MCP initialize handshake; recent stderr:
/usr/bin/bash: line 1: C:ProgramDataSomeVendorwrappersfoo.cmd: command not found
- Confirmed with a minimal Go reproduction calling
shellwrap.WrapWithUserShell directly and then exec.Command(shell, args...) on the result — the quoting is correct (backslashes are preserved inside the single-quoted string), but the resulting bash -l -c '...' invocation still fails to exec, proving the bug is in MSYS's own path resolution, not in the Go-side quoting.
Root cause
internal/shellwrap/shellwrap.go, WrapWithUserShell: when the resolved shell is bash-like (isBashLikeShell(shell) is true) and runtime.GOOS == "windows", the command and args are still shell-escaped and joined verbatim, backslashes and all. MSYS/Git-Bash's exec() implementation does not translate a backslash-style absolute Windows path to POSIX form before attempting to run it, so the exec attempt silently fails and falls through to a PATH-name lookup, which is what produces the garbled command not found message.
Fix
Convert backslashes to forward slashes in the command and each argument before shell-escaping, but only on the Windows + bash-like-shell branch (Windows itself accepts forward-slash paths interchangeably with backslash ones via CreateProcess, and MSYS accepts them natively, so this is safe in both directions).
// Git Bash / MSYS on Windows cannot exec a backslash-style Windows path
// (C:\ProgramData\...) even when it is correctly single-quoted: MSYS's
// own exec layer only resolves POSIX-style paths, so it falls through to
// bash's PATH lookup, which reports "command not found" using its own
// mangled rendering of the argv word (backslashes silently dropped). A
// forward-slash path (C:/ProgramData/...) is accepted by both Windows'
// CreateProcess and MSYS's exec layer, so convert before quoting when
// we're about to run a bash-like shell on Windows.
toBashPath := func(s string) string {
if runtime.GOOS == osWindows && isBash {
return strings.ReplaceAll(s, `\`, "/")
}
return s
}
Verified locally:
- Unit test reproducing the original bug (single-quoted backslash path still fails to exec through real Git Bash —
exit status 127, command not found, backslashes dropped from bash's own error message).
- Unit test proving the fix (
bash -l -c '<forward-slash form>' successfully execs a real Windows binary, e.g. whoami.exe, through the wrapped path).
- Full
internal/shellwrap and internal/upstream/... test suites pass after the change; one existing Docker-isolation test (TestSetupDockerIsolationShellWrapsWhenDaemonEnvMissingNonDarwin) needed its assertion updated to accept either separator style, since the Docker shell-wrap fallback path goes through the same code and was hitting the identical latent bug.
- End-to-end: rebuilt mcpproxy with the fix and reconnected a real config with ~14 wrapper-
.cmd-based stdio servers that were all failing with this exact error — all connected successfully afterward (connected server count went from 13 to 27, tool count from 249 to 403).
Happy to open a PR with this change (plus the updated test) if useful — flagging as an issue first in case there's a preferred approach or an existing fix already in flight.
Environment
- OS: Windows 11 Pro
- mcpproxy-go: v0.67.0
- Shell: Git Bash (
$SHELL set to Git's bash.exe)
Summary
On Windows, when the login shell resolves to Git Bash / MSYS (
$SHELLset, or otherwise detected as bash-like),shellwrap.WrapWithUserShellcorrectly single-quotes a backslash-style Windows path (e.g.C:\ProgramData\foo\bar.cmd), but Git Bash still cannot execute it. MSYS's exec layer only resolves POSIX-style paths (C:/ProgramData/foo/bar.cmd); a backslash-style path falls through to bash'sPATHlookup, which fails withcommand not foundand — in its own error rendering — drops the backslashes entirely, producing a confusing message like:This affects every stdio server whose
commandis an absolute Windows path (wrapper.cmd/.batscripts are the common case) on any host where the resolved login shell is bash-like — i.e. any Windows box with Git Bash and$SHELLset, which is common for anyone using Git Bash / VS Code's integrated terminal / MSYS2 as their default shell.Repro
$SHELLset to a Git Bash path (e.g.C:\Program Files\Git\bin\bash.exe), configure a stdio server with an absolute Windows-path command, e.g.:{ "command": "C:\ProgramData\SomeVendor\wrappers\foo.cmd" }shellwrap.WrapWithUserShelldirectly and thenexec.Command(shell, args...)on the result — the quoting is correct (backslashes are preserved inside the single-quoted string), but the resultingbash -l -c '...'invocation still fails to exec, proving the bug is in MSYS's own path resolution, not in the Go-side quoting.Root cause
internal/shellwrap/shellwrap.go,WrapWithUserShell: when the resolved shell is bash-like (isBashLikeShell(shell)is true) andruntime.GOOS == "windows", the command and args are still shell-escaped and joined verbatim, backslashes and all. MSYS/Git-Bash'sexec()implementation does not translate a backslash-style absolute Windows path to POSIX form before attempting to run it, so the exec attempt silently fails and falls through to aPATH-name lookup, which is what produces the garbledcommand not foundmessage.Fix
Convert backslashes to forward slashes in the command and each argument before shell-escaping, but only on the Windows + bash-like-shell branch (Windows itself accepts forward-slash paths interchangeably with backslash ones via
CreateProcess, and MSYS accepts them natively, so this is safe in both directions).Verified locally:
exit status 127,command not found, backslashes dropped from bash's own error message).bash -l -c '<forward-slash form>'successfully execs a real Windows binary, e.g.whoami.exe, through the wrapped path).internal/shellwrapandinternal/upstream/...test suites pass after the change; one existing Docker-isolation test (TestSetupDockerIsolationShellWrapsWhenDaemonEnvMissingNonDarwin) needed its assertion updated to accept either separator style, since the Docker shell-wrap fallback path goes through the same code and was hitting the identical latent bug..cmd-based stdio servers that were all failing with this exact error — all connected successfully afterward (connected server count went from 13 to 27, tool count from 249 to 403).Happy to open a PR with this change (plus the updated test) if useful — flagging as an issue first in case there's a preferred approach or an existing fix already in flight.
Environment
$SHELLset to Git'sbash.exe)