@@ -18,12 +18,22 @@ import (
1818var resolveDockerBinary = shellwrap .ResolveDockerPath
1919
2020// setupDockerIsolation configures Docker isolation for the MCP server process.
21- // Returns the docker command, arguments, and whether the returned command was
22- // wrapped in the user's login shell. shellWrapped governs how the caller
23- // inserts --cidfile: a direct-exec spawn (shellWrapped == false) uses the
24- // args-based insertCidfileIntoDockerArgs, while the login-shell fallback
25- // (shellWrapped == true) uses the string-based insertCidfileIntoShellDockerCommand.
26- func (c * Client ) setupDockerIsolation (command string , args []string ) (dockerCommand string , dockerArgs []string , shellWrapped bool ) {
21+ // Returns the docker command, arguments, whether the returned command was
22+ // wrapped in the user's login shell, and the docker binary's bundle directory
23+ // (empty unless docker resolved to a verified absolute executable).
24+ //
25+ // shellWrapped governs how the caller inserts --cidfile: a direct-exec spawn
26+ // (shellWrapped == false) uses the args-based insertCidfileIntoDockerArgs, while
27+ // the login-shell fallback (shellWrapped == true) uses the string-based
28+ // insertCidfileIntoShellDockerCommand.
29+ //
30+ // dockerDir is the directory of the resolved absolute docker binary (e.g.
31+ // /Applications/Docker.app/Contents/Resources/bin). The caller must prepend it
32+ // to the child PATH (prependDockerDirToPath) so the spawned docker can exec its
33+ // sibling tooling — most importantly the docker-credential-* helper named by
34+ // ~/.docker/config.json's credsStore, which Docker Desktop installs into the
35+ // same bundle dir and which docker shells out to for every registry op (#715).
36+ func (c * Client ) setupDockerIsolation (command string , args []string ) (dockerCommand string , dockerArgs []string , shellWrapped bool , dockerDir string ) {
2737 // Detect the runtime type from the command
2838 runtimeType := c .isolationManager .DetectRuntimeType (command )
2939 c .logger .Debug ("Detected runtime type for Docker isolation" ,
@@ -38,7 +48,7 @@ func (c *Client) setupDockerIsolation(command string, args []string) (dockerComm
3848 zap .String ("server" , c .config .Name ),
3949 zap .Error (err ))
4050 shellCmd , shellArgs := c .wrapWithUserShell (command , args )
41- return shellCmd , shellArgs , true
51+ return shellCmd , shellArgs , true , ""
4252 }
4353
4454 // Extract container name from Docker args for tracking
@@ -95,6 +105,14 @@ func (c *Client) setupDockerIsolation(command string, args []string) (dockerComm
95105// the login-shell fallback (shellWrapped == true) uses the string-based
96106// insertCidfileIntoShellDockerCommand.
97107//
108+ // dockerDir is the directory of the resolved absolute docker binary (empty
109+ // unless docker resolved to a verified absolute executable). The caller must
110+ // prepend it to the child PATH (prependDockerDirToPath) so the spawned docker
111+ // can exec its sibling tooling — most importantly the docker-credential-*
112+ // helper named by ~/.docker/config.json's credsStore, which Docker Desktop
113+ // installs into the same bundle dir and which docker shells out to for every
114+ // registry op (#715 / MCP-2877).
115+ //
98116// CRITICAL FIX (#696 / MCP-2744 / MCP-2753 / MCP-2868): resolve `docker` to an
99117// ABSOLUTE path and exec it DIRECTLY — no login-shell wrap. Docker Desktop
100118// installed the default way on macOS (without the optional, admin-gated "install
@@ -133,7 +151,7 @@ func (c *Client) setupDockerIsolation(command string, args []string) (dockerComm
133151// When we fall back to the shell wrap we still prefer the resolved absolute path
134152// as the wrapped command (it sidesteps the login shell's PATH re-derivation),
135153// dropping to bare "docker" only when resolution failed.
136- func (c * Client ) resolveDockerSpawn (finalArgs []string ) (dockerCommand string , dockerArgs []string , shellWrapped bool ) {
154+ func (c * Client ) resolveDockerSpawn (finalArgs []string ) (dockerCommand string , dockerArgs []string , shellWrapped bool , dockerDir string ) {
137155 resolved , resErr := resolveDockerBinary (c .logger )
138156 switch {
139157 case resErr == nil && isDirectExecutable (resolved ) && dockerDaemonEnvGuaranteed ():
@@ -145,7 +163,7 @@ func (c *Client) resolveDockerSpawn(finalArgs []string) (dockerCommand string, d
145163 zap .String ("server" , c .config .Name ),
146164 zap .String ("docker_path" , resolved ),
147165 zap .Bool ("shell_wrapped" , false ))
148- return resolved , finalArgs , false
166+ return resolved , finalArgs , false , filepath . Dir ( resolved )
149167 case resErr != nil :
150168 c .logger .Warn ("Could not resolve docker to an absolute path; falling back to bare 'docker' via login shell (isolated server may fail if docker is not on the spawn PATH)" ,
151169 zap .String ("server" , c .config .Name ),
@@ -164,8 +182,14 @@ func (c *Client) resolveDockerSpawn(finalArgs []string) (dockerCommand string, d
164182 }
165183
166184 dockerBin := cmdDocker
185+ // Seed the bundle dir for PATH augmentation whenever docker resolved to a
186+ // verified absolute executable — even though we keep the login-shell wrap
187+ // here. It is harmless on the shell-wrap path and still helps if the login
188+ // shell preserves the inherited PATH (#715).
189+ var resolvedDir string
167190 if isDirectExecutable (resolved ) {
168191 dockerBin = resolved
192+ resolvedDir = filepath .Dir (resolved )
169193 }
170194 // INFO: the login-shell fallback is the ONLY path that can produce #696's
171195 // `command not found: docker`, so surface that we took it (and whether we at
@@ -176,7 +200,47 @@ func (c *Client) resolveDockerSpawn(finalArgs []string) (dockerCommand string, d
176200 zap .Bool ("docker_resolved" , isDirectExecutable (resolved )),
177201 zap .Bool ("shell_wrapped" , true ))
178202 shellCmd , shellArgs := c .wrapWithUserShell (dockerBin , finalArgs )
179- return shellCmd , shellArgs , true
203+ return shellCmd , shellArgs , true , resolvedDir
204+ }
205+
206+ // prependDockerDirToPath returns env with dockerDir prepended to its PATH entry
207+ // so a spawned docker can resolve the sibling binaries it depends on — the
208+ // docker-credential-* helper named by ~/.docker/config.json's credsStore,
209+ // docker-compose, docker-buildx — which Docker Desktop installs into the same
210+ // bundle dir as the docker CLI but which mcpproxy's sanitized PATH omits (#715).
211+ //
212+ // dockerDir is prepended (existing entries preserved) and deduped: if it is
213+ // already present anywhere on PATH the env is returned unchanged. An empty
214+ // dockerDir (docker not resolved to an absolute path) is a no-op. The input
215+ // slice is never mutated. os.PathListSeparator keeps it correct on Windows.
216+ func prependDockerDirToPath (env []string , dockerDir string ) []string {
217+ if dockerDir == "" {
218+ return env
219+ }
220+ for i , kv := range env {
221+ name , val , ok := strings .Cut (kv , "=" )
222+ if ! ok || name != "PATH" {
223+ continue
224+ }
225+ // Dedup: leave PATH untouched if the bundle dir is already on it.
226+ for _ , p := range filepath .SplitList (val ) {
227+ if p == dockerDir {
228+ return env
229+ }
230+ }
231+ out := make ([]string , len (env ))
232+ copy (out , env )
233+ if val == "" {
234+ out [i ] = "PATH=" + dockerDir
235+ } else {
236+ out [i ] = "PATH=" + dockerDir + string (os .PathListSeparator ) + val
237+ }
238+ return out
239+ }
240+ // No PATH entry present — add one so docker still finds its bundle dir.
241+ out := make ([]string , len (env ), len (env )+ 1 )
242+ copy (out , env )
243+ return append (out , "PATH=" + dockerDir )
180244}
181245
182246// isDirectExecutable reports whether path is safe to hand to exec directly
0 commit comments