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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,8 @@ Releases follow [Semantic Versioning](https://semver.org/).

### Features

- **config: `MCPPROXY_API_KEY_FILE` reads the API key from a file (#1706).** The `*_FILE` form of Docker Compose/Swarm secrets, Kubernetes secret volumes and systemd `LoadCredential=`: the key is not in the process environment (`docker inspect`, `/proc/<pid>/environ`) and is never written to `mcp_config.json`. The path must be absolute; one trailing LF or CRLF is removed; at most 64 KiB is read. Setting both `MCPPROXY_API_KEY` and `MCPPROXY_API_KEY_FILE` (both non-empty) is an error: mcpproxy refuses to start with a message naming both variables (never a key or file contents), and the CLI commands and the tray stop the same way. An empty value is treated as unset. A file that is named but cannot be used stops startup with an error naming the variable and path: mcpproxy does not fall back to the config file or a generated key. `mcpproxy status`, the other CLI commands and the tray read it too, and a tray-launched core reads the file itself instead of receiving the key in its environment. `Config.EnsureAPIKey` now also returns an error. (refs #1706, spec 111 FR-014/FR-030/US7)

- **catalog:** a catalog source whose live search times out or fails is now answered from the listing
the daemon last saw from it (at most 24 hours old, kept in memory). Those results are marked
`from_cache` ("From cached list" in the Web UI and macOS, `(cached)` in the CLI) and the source
Expand Down
38 changes: 30 additions & 8 deletions cmd/mcpproxy-tray/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -156,14 +156,30 @@ func main() {
// Determine if we're using socket/pipe communication (which doesn't need API key)
usingSocketCommunication := isSocketEndpoint(coreURL)

// Read the API key the environment names (MCPPROXY_API_KEY_FILE or
// MCPPROXY_API_KEY), for consistency with the core. Resolve it even when the
// tray talks over a socket: the core it launches inherits the same
// environment and refuses to start when both variables are set or the file
// cannot be used, so the tray stops here with the reason instead of
// generating a key that would hide it. The error never contains a key.
envKey, envErr := config.ResolveEnvAPIKey()
if envErr != nil {
logger.Error("Cannot read the API key named by the environment", zap.Error(envErr))
_ = logger.Sync()
os.Exit(1)
}

// Setup API key for secure communication between tray and core
// Skip API key generation for socket/pipe connections as they're trusted by default
if trayAPIKey == "" && !usingSocketCommunication {
// Check environment variable first (for consistency with core behavior)
if envAPIKey := os.Getenv("MCPPROXY_API_KEY"); envAPIKey != "" {
trayAPIKey = envAPIKey
logger.Info("Using API key from environment variable for tray-core communication",
zap.String("api_key_source", "MCPPROXY_API_KEY environment variable"),
if envKey.Set && envKey.Key != "" {
trayAPIKey = envKey.Key
source := "MCPPROXY_API_KEY environment variable"
if envKey.FromFile {
source = config.EnvAPIKeyFile + " file"
}
logger.Info("Using API key from environment for tray-core communication",
zap.String("api_key_source", source),
zap.String("api_key_prefix", maskAPIKey(trayAPIKey)))
} else {
trayAPIKey = generateAPIKey()
Expand Down Expand Up @@ -1935,9 +1951,15 @@ func (cpl *CoreProcessLauncher) buildCoreEnvironment() []string {
}

// Add our environment variables
filtered = append(filtered,
"MCPPROXY_ENABLE_TRAY=false",
fmt.Sprintf("MCPPROXY_API_KEY=%s", trayAPIKey))
filtered = append(filtered, "MCPPROXY_ENABLE_TRAY=false")
// When the key lives in a file (MCPPROXY_API_KEY_FILE, which the core
// inherits), let the core read it from there: injecting it as
// MCPPROXY_API_KEY would put it in the core's environment, which is what
// the file form exists to avoid. The tray read the same file, so both
// sides hold the same key.
if os.Getenv(config.EnvAPIKeyFile) == "" {
filtered = append(filtered, fmt.Sprintf("MCPPROXY_API_KEY=%s", trayAPIKey))
}

// Tell the core it was launched by the tray, so telemetry's launch_source
// can say so. Without this a tray-spawned core is unclassifiable: its parent
Expand Down
40 changes: 40 additions & 0 deletions cmd/mcpproxy-tray/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -425,3 +425,43 @@ func TestShouldTerminateCore(t *testing.T) {
})
}
}

// With MCPPROXY_API_KEY_FILE the core reads the key from the file itself; the
// tray must not copy it into the core's environment.
func TestBuildCoreEnvironmentAPIKey(t *testing.T) {
prev := trayAPIKey
t.Cleanup(func() { trayAPIKey = prev })
trayAPIKey = "tray-key"
launcher := &CoreProcessLauncher{}

count := func(env []string, prefix string) (n int, last string) {
for _, e := range env {
if strings.HasPrefix(e, prefix) {
n++
last = e
}
}
return n, last
}

t.Run("key injected when no file is named", func(t *testing.T) {
t.Setenv("MCPPROXY_API_KEY_FILE", "")
t.Setenv("MCPPROXY_API_KEY", "stale")
n, last := count(launcher.buildCoreEnvironment(), "MCPPROXY_API_KEY=")
if n != 1 || last != "MCPPROXY_API_KEY=tray-key" {
t.Fatalf("want exactly the tray key injected, got %d entries, last %q", n, last)
}
})

t.Run("key left in the file when one is named", func(t *testing.T) {
t.Setenv("MCPPROXY_API_KEY_FILE", "/run/secrets/mcpproxy_key")
t.Setenv("MCPPROXY_API_KEY", "")
env := launcher.buildCoreEnvironment()
if n, _ := count(env, "MCPPROXY_API_KEY="); n != 0 {
t.Fatalf("the key must not be copied into the core's environment, found %d entries", n)
}
if n, _ := count(env, "MCPPROXY_API_KEY_FILE="); n != 1 {
t.Fatalf("the core must inherit MCPPROXY_API_KEY_FILE, found %d entries", n)
}
})
}
17 changes: 14 additions & 3 deletions cmd/mcpproxy/daemon_client.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ package main

import (
"context"
"fmt"
"net"
"os"
"strings"
Expand All @@ -30,11 +31,21 @@ import (
const daemonProbeTimeout = 2 * time.Second

// resolveAPIKey returns the API key the daemon would accept, mirroring
// Config.EnsureAPIKey precedence (env > config file) WITHOUT ever generating
// Config.EnsureAPIKey precedence (MCPPROXY_API_KEY_FILE or MCPPROXY_API_KEY,
// which may not both be set, then the config file) WITHOUT ever generating
// a new key — a fabricated key cannot match a running daemon's.
func resolveAPIKey(cfg *config.Config) string {
if envAPIKey, exists := os.LookupEnv("MCPPROXY_API_KEY"); exists && envAPIKey != "" {
return envAPIKey
envKey, err := config.ResolveEnvAPIKey()
if err != nil {
// Both variables are set, or MCPPROXY_API_KEY_FILE names a file that
// cannot be used. Do not fall back to another key (it would be the
// wrong one, or none): say so and let the command take its no-daemon
// path. The error text never contains a key.
fmt.Fprintf(os.Stderr, "Warning: %v; not using any API key\n", err)
return ""
}
if envKey.Set && envKey.Key != "" {
return envKey.Key
}
if cfg != nil {
return cfg.APIKey
Expand Down
45 changes: 45 additions & 0 deletions cmd/mcpproxy/daemon_client_api_key_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
package main

import (
"os"
"path/filepath"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"github.com/smart-mcp-proxy/mcpproxy-go/internal/config"
)

func TestResolveAPIKey_Precedence(t *testing.T) {
keyFile := filepath.Join(t.TempDir(), "key")
require.NoError(t, os.WriteFile(keyFile, []byte("file-key\n"), 0o600))
cfg := &config.Config{APIKey: "config-key"}

t.Run("config only", func(t *testing.T) {
t.Setenv(config.EnvAPIKey, "")
require.NoError(t, os.Unsetenv(config.EnvAPIKey))
t.Setenv(config.EnvAPIKeyFile, "")
assert.Equal(t, "config-key", resolveAPIKey(cfg))
})
t.Run("env beats config", func(t *testing.T) {
t.Setenv(config.EnvAPIKey, "env-key")
t.Setenv(config.EnvAPIKeyFile, "")
assert.Equal(t, "env-key", resolveAPIKey(cfg))
})
t.Run("file beats config, as the daemon resolves it", func(t *testing.T) {
t.Setenv(config.EnvAPIKey, "")
t.Setenv(config.EnvAPIKeyFile, keyFile)
assert.Equal(t, "file-key", resolveAPIKey(cfg))
})
t.Run("both variables set gives no key, as the daemon refuses to start", func(t *testing.T) {
t.Setenv(config.EnvAPIKey, "env-key")
t.Setenv(config.EnvAPIKeyFile, keyFile)
assert.Empty(t, resolveAPIKey(cfg))
})
t.Run("an unusable file gives no key rather than a wrong one", func(t *testing.T) {
t.Setenv(config.EnvAPIKey, "")
t.Setenv(config.EnvAPIKeyFile, filepath.Join(t.TempDir(), "missing"))
assert.Empty(t, resolveAPIKey(cfg))
})
}
9 changes: 7 additions & 2 deletions cmd/mcpproxy/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -549,8 +549,13 @@ func runServer(cmd *cobra.Command, _ []string) error {
zap.Bool("enable_prompts", cfg.EnablePrompts))

// Ensure API key is configured
apiKey, wasGenerated, source := cfg.EnsureAPIKey()

apiKey, wasGenerated, source, keyErr := cfg.EnsureAPIKey()
if keyErr != nil {
// Both MCPPROXY_API_KEY and MCPPROXY_API_KEY_FILE are set, or the file
// cannot be used. Stop here: generating and saving a key instead would
// replace the credential the operator meant to run with.
return keyErr
}
// SECURITY: API key is always required - empty keys are never allowed
if apiKey == "" {
return fmt.Errorf("API key is required but could not be generated")
Expand Down
3 changes: 2 additions & 1 deletion cmd/mcpproxy/serve_flag_persistence_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -200,7 +200,8 @@ func TestServeSaverDoesNotPersistEnvAPIKey(t *testing.T) {

cfg, saver, err := loadConfig(newServeFlagTestCmd())
require.NoError(t, err)
apiKey, wasGenerated, _ := cfg.EnsureAPIKey()
apiKey, wasGenerated, _, keyErr := cfg.EnsureAPIKey()
require.NoError(t, keyErr)
require.Equal(t, "mcp_env_secret", apiKey)
require.False(t, wasGenerated)

Expand Down
12 changes: 9 additions & 3 deletions cmd/mcpproxy/status_cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,9 @@ func runStatus(cmd *cobra.Command, _ []string) error {
}

// Ensure API key exists
cfg.EnsureAPIKey()
if _, _, _, keyErr := cfg.EnsureAPIKey(); keyErr != nil {
return keyErr
}

configPath := config.GetConfigPath(cfg.DataDir)

Expand All @@ -149,8 +151,12 @@ func runStatus(cmd *cobra.Command, _ []string) error {
fmt.Fprintln(os.Stderr)

// Check if env var overrides
if envKey, exists := os.LookupEnv("MCPPROXY_API_KEY"); exists && envKey != "" {
fmt.Fprintln(os.Stderr, "Warning: MCPPROXY_API_KEY environment variable is set and will override the config file key.")
if envKey, envErr := config.ResolveEnvAPIKey(); envErr == nil && envKey.Set && envKey.Key != "" {
if envKey.FromFile {
fmt.Fprintf(os.Stderr, "Warning: %s is set and the key in %s will override the config file key.\n", config.EnvAPIKeyFile, envKey.Path)
} else {
fmt.Fprintln(os.Stderr, "Warning: MCPPROXY_API_KEY environment variable is set and will override the config file key.")
}
fmt.Fprintln(os.Stderr)
}

Expand Down
3 changes: 2 additions & 1 deletion docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -827,7 +827,7 @@ With `require_mcp_auth` off, a change that would let a client bound to a profile
| `allow_server_remove` | boolean | `true` | Allow removing servers via API/tools |

**Security Notes:**
- **API Key**: Set via `--api-key` flag, `MCPPROXY_API_KEY` environment variable, or config file
- **API Key**: Set via `--api-key` flag, `MCPPROXY_API_KEY` environment variable, `MCPPROXY_API_KEY_FILE` (a file holding the key, for Docker/Kubernetes secrets and systemd credentials; see [Environment variables](/configuration/environment-variables#api-key-from-a-file)), or config file
- **Empty API Key**: Empty values are replaced with an auto-generated key; authentication is always enforced
- **Auto-Generation**: If no API key is provided, one is generated, persisted to the config file, and printed once to the terminal (stderr). It is deliberately **not** written to the log files - to recover it later, read `api_key` from `~/.mcpproxy/mcp_config.json`
- **Tray Integration**: Tray app automatically manages API keys for core communication
Expand Down Expand Up @@ -1836,6 +1836,7 @@ Many configuration options can be overridden via environment variables:
|----------------------|--------------|-------------|
| `MCPPROXY_LISTEN` / `MCPP_LISTEN` | `listen` | Network binding address |
| `MCPPROXY_API_KEY` | `api_key` | API key for authentication (empty values trigger auto-generation; auth remains enabled) |
| `MCPPROXY_API_KEY_FILE` | `api_key` | Absolute path of a file that holds the API key; set it or `MCPPROXY_API_KEY`, not both (both set stops startup with an error naming both); an unusable file stops startup (no fallback, nothing generated) |
| `MCPPROXY_TRUSTED_HOSTS` | `trusted_hosts` | Comma-separated `Host` allowlist for loopback listeners behind a reverse proxy |
| `MCPPROXY_TRUSTED_PROXIES` | `trusted_proxies` | Comma-separated CIDRs/IPs whose `X-Forwarded-*` headers are honoured |
| `MCPPROXY_AUDIT_LOG_ENABLED` | `audit_log.enabled` | Turn audit logging on/off |
Expand Down
37 changes: 37 additions & 0 deletions docs/configuration/environment-variables.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,46 @@ Environment variables are useful for CI/CD environments or temporary overrides d
|----------|-------------|---------|
| `MCPPROXY_LISTEN` | Override listen address | `127.0.0.1:8080` or `:8080` |
| `MCPPROXY_API_KEY` | Set API key for authentication | `my-secret-key` |
| `MCPPROXY_API_KEY_FILE` | Read the API key from a file (absolute path). Set it or `MCPPROXY_API_KEY`, not both; see [API key from a file](#api-key-from-a-file) | `/run/secrets/mcpproxy_api_key` |
| `MCPPROXY_DATA_DIR` | Override data directory | `/var/lib/mcpproxy` |
| `MCPPROXY_DATA` | Override data directory (backward compatibility, prefer `MCPPROXY_DATA_DIR`) | `/var/lib/mcpproxy` |

### API key from a file

`MCPPROXY_API_KEY_FILE` is the `*_FILE` form used by Docker Compose and Swarm secrets
(`/run/secrets/<name>`), Kubernetes secret volumes and systemd `LoadCredential=`. The key is read
from the file at start, so it is not in the process environment (`docker inspect`,
`/proc/<pid>/environ`, crash dumps) and is never written to `mcp_config.json`.

- The path must be absolute. A symlink is followed (Kubernetes secret volumes are symlinks) but must
point at a regular file. At most 64 KiB is read.
- One trailing newline (LF or CRLF) is removed. Everything else, including inner whitespace, is kept.
- An empty `MCPPROXY_API_KEY_FILE` is treated as unset.
- Set `MCPPROXY_API_KEY` or `MCPPROXY_API_KEY_FILE`, never both. If both are non-empty, mcpproxy
refuses to start (and the CLI commands and the tray stop) with an error that names both
variables: `MCPPROXY_API_KEY and MCPPROXY_API_KEY_FILE are both set; set only one of them`. The
check does not open the file, and the error never contains a key or file contents. An empty
variable counts as unset.
- A file that is named but cannot be used (missing, unreadable, not absolute, too large, empty) stops
startup with an error that names the variable and path. mcpproxy does **not** fall back to
the config file or a generated key, so a typo in a secret path cannot start the
proxy under a different credential.
- `mcpproxy status`, the other CLI commands and the tray read the same variable, so they find the key
the daemon uses. A tray-launched core reads the file itself rather than receiving the key in its
environment.

```yaml
# docker-compose.yml
services:
mcpproxy:
environment:
MCPPROXY_API_KEY_FILE: /run/secrets/mcpproxy_api_key
secrets: [mcpproxy_api_key]
secrets:
mcpproxy_api_key:
file: ./mcpproxy_api_key.txt
```

### Security Settings

| Variable | Description | Default |
Expand Down
3 changes: 3 additions & 0 deletions docs/getting-started/installation.md
Original file line number Diff line number Diff line change
Expand Up @@ -397,6 +397,9 @@ docker run -d --name mcpproxy \
- **API key**: the REST API and Web UI require one. Set `MCPPROXY_API_KEY` explicitly and keep a
copy — the image is distroless (no shell), so reading an auto-generated key back out of the
container is awkward.
To keep the key out of `docker inspect` and the process environment, mount it as a secret and set
`MCPPROXY_API_KEY_FILE=/run/secrets/<name>` instead of `MCPPROXY_API_KEY` (setting both is an error; see
[API key from a file](/configuration/environment-variables#api-key-from-a-file)).
- **Web UI**: `http://localhost:8080/ui/`.

### Behind an ingress with SSO
Expand Down
Loading
Loading