Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions cmd/mcpproxy/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ import (
"github.com/smart-mcp-proxy/mcpproxy-go/internal/logs"
"github.com/smart-mcp-proxy/mcpproxy-go/internal/registries"
"github.com/smart-mcp-proxy/mcpproxy-go/internal/server"
"github.com/smart-mcp-proxy/mcpproxy-go/internal/shellwrap"
"github.com/smart-mcp-proxy/mcpproxy-go/internal/storage"
"github.com/smart-mcp-proxy/mcpproxy-go/internal/telemetry"
_ "github.com/smart-mcp-proxy/mcpproxy-go/oas" // Import generated swagger docs
Expand Down Expand Up @@ -499,6 +500,17 @@ func runServer(cmd *cobra.Command, _ []string) error {
zap.String("log_level", cmdLogLevel),
zap.Bool("log_to_file", cmdLogToFile))

// MCP-2751: when launched from a macOS GUI/launchd context (Launchpad, the
// SMAppService login item, or the tray spawning the core), the process
// inherits a launchd-minimal environment and never sources the user's
// login shell — so it lacks Homebrew/Docker PATH entries and exported vars
// like DOCKER_HOST. Hydrate a curated allow-list (PATH + DOCKER_*/proxy/
// tool-home) once, before any manager reads os.Environ(), so every spawn
// path (docker, stdio servers, uvx/npx, ResolveDockerPath,
// secureenv.BuildSecureEnvironment) inherits a correct environment with no
// call-site changes. No-op on terminal launches and non-macOS.
shellwrap.HydrateFromLoginShell(logger)

// Pass edition and version to internal packages
httpapi.SetEdition(Edition)
server.SetMCPServerVersion(version)
Expand Down
13 changes: 11 additions & 2 deletions docs/errors/MCPX_STDIO_SPAWN_ENOENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,8 +55,17 @@ sudo apt install nodejs npm # Debian/Ubuntu

### 2. Make the GUI inherit the shell PATH (macOS)

The macOS tray launches mcpproxy with the user's login `PATH`, which is shorter
than your interactive shell `PATH`. Either:
When launched from a macOS GUI/launchd context (Launchpad, the login item, or
the tray spawning the core), mcpproxy inherits a launchd-minimal environment
rather than your interactive shell `PATH`. Since the daemon now **hydrates the
login-shell environment once at startup** — sourcing your login shell to merge
in `PATH` (and curated `DOCKER_*`/proxy/tool-home vars) — `uvx`/`npx` installed
in `/opt/homebrew/bin`, `~/.local/bin`, or a version-manager shim directory are
normally found automatically. (Hydration is a no-op when mcpproxy is started
from a terminal whose `PATH` is already comprehensive.)

If the tool still isn't found — e.g. it lives in a directory your login shell
doesn't export, or your rc files don't run non-interactively — either:

- Move the binary to a system directory: `sudo ln -s "$(which uvx)" /usr/local/bin/uvx`, or
- Set an absolute path in the upstream config: `"command": "/Users/you/.local/bin/uvx"`.
Expand Down
48 changes: 48 additions & 0 deletions internal/secureenv/launchd_path_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,54 @@ func TestLaunchdMinimalPath_AlreadyComprehensive(t *testing.T) {
"comprehensive PATH must be returned unchanged — terminal-launched processes should not be polluted by login-shell capture")
}

// TestBuildSecureEnvironment_AllowsHydratedDockerVars verifies the allow-list
// extension for MCP-2751: once shellwrap.HydrateFromLoginShell has placed
// curated DOCKER_*/tool-home vars into the process env, the default allow-list
// must pass them through to spawned upstreams — while secrets remain filtered.
// Proxy vars are intentionally excluded from the allow-list (separate follow-up).
func TestBuildSecureEnvironment_AllowsHydratedDockerVars(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("uses unix PATH semantics")
}

originalEnv := os.Environ()
defer func() {
os.Clearenv()
for _, env := range originalEnv {
parts := strings.SplitN(env, "=", 2)
if len(parts) == 2 {
os.Setenv(parts[0], parts[1])
}
}
}()

t.Cleanup(withFakeLoginShellPath(""))

os.Clearenv()
os.Setenv("PATH", "/usr/bin:/bin:/usr/sbin:/sbin")
os.Setenv("HOME", "/tmp/test-home")
os.Setenv("DOCKER_HOST", "unix:///Users/me/.docker/run/docker.sock")
os.Setenv("DOCKER_CONTEXT", "desktop-linux")
os.Setenv("HOMEBREW_PREFIX", "/opt/homebrew")
// Genuine secrets that must still be filtered out by the allow-list.
os.Setenv("AWS_ACCESS_KEY_ID", "AKIA_test_dummy_value_00000000")
os.Setenv("GITHUB_TOKEN", "ghp_dummy_test_token_1234567890abcdef")
// Proxy vars are excluded from the allow-list.
os.Setenv("HTTPS_PROXY", "http://proxy.corp:8080")

manager := NewManager(DefaultEnvConfig())
joined := strings.Join(manager.BuildSecureEnvironment(), "\n")

assert.Contains(t, joined, "DOCKER_HOST=unix:///Users/me/.docker/run/docker.sock",
"curated DOCKER_HOST must survive the allow-list")
assert.Contains(t, joined, "DOCKER_CONTEXT=desktop-linux")
assert.Contains(t, joined, "HOMEBREW_PREFIX=/opt/homebrew")

assert.NotContains(t, joined, "HTTPS_PROXY", "proxy vars must be filtered (out of scope)")
assert.NotContains(t, joined, "AWS_ACCESS_KEY_ID", "secrets must still be filtered out")
assert.NotContains(t, joined, "GITHUB_TOKEN", "secrets must still be filtered out")
}

// --- test helpers --------------------------------------------------------

// withFakeLoginShellPath swaps loginShellPATHFn for a stub returning `path`.
Expand Down
10 changes: 10 additions & 0 deletions internal/secureenv/manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,16 @@ func DefaultEnvConfig() *EnvConfig {
}
allowedVars = append(allowedVars, localeVars...)

// Add container / tool-home passthrough variables (MCP-2751). These are NOT
// secrets; they mirror the curated set hydrated by shellwrap.HydrateFromLoginShell
// so the now-present vars survive this allow-list filter and reach upstream
// stdio/docker spawns. Proxy vars (HTTP_PROXY etc.) are intentionally excluded
// — proxy forwarding is a separate opt-in concern.
allowedVars = append(allowedVars,
"DOCKER_HOST", "DOCKER_CONTEXT", "DOCKER_CONFIG", "DOCKER_CERT_PATH", "DOCKER_TLS_VERIFY",
"NVM_DIR", "ASDF_DIR", "PYENV_ROOT", "VOLTA_HOME", "HOMEBREW_PREFIX", "COLIMA_HOME",
)

return &EnvConfig{
InheritSystemSafe: true,
AllowedSystemVars: allowedVars,
Expand Down
Loading
Loading