Skip to content

Commit 97c8c8f

Browse files
feat(config): read the API key from MCPPROXY_API_KEY_FILE (#1716)
## Description First of the two PRs for #1706 (the second adds `${file:}` and builds on this one). Adds `MCPPROXY_API_KEY_FILE`, the `*_FILE` form used by Docker Compose/Swarm secrets, Kubernetes secret volumes and systemd `LoadCredential=`, so the proxy's own key need not sit in the process environment (`docker inspect`, `/proc/<pid>/environ`, crash dumps) or in `mcp_config.json`. - **One helper.** `config.ResolveEnvAPIKey` is now the only reader of the environment's key. The loader (`Validate`), `EnsureAPIKey`, the CLI client (`resolveAPIKey`), `status`, the tray and the core it launches all go through it. - **Hard fail.** A named file that cannot be used (missing, unreadable, relative path, over 64 KiB, empty after removing the trailing newline) stops startup with an error naming the variable and path, never the content. There is no fallback to `MCPPROXY_API_KEY`, the config file or a generated key, and nothing is saved. A real run with a missing file exits 4 and leaves the config byte-identical. - **Never written back.** The key from the file is a process-only override, like the env key. - **Tray.** When the key is in a file, the tray does not inject it into the core as `MCPPROXY_API_KEY` (that would put it back in the core's environment); the core inherits `MCPPROXY_API_KEY_FILE` and reads the file itself. The tray exits on an unusable file instead of generating a key. - `secret.ReadFile` holds the read rules (absolute path, regular file after following symlinks, 64 KiB cap, exactly one trailing LF or CRLF removed, no NUL, errors never include content) so the `${file:}` provider in the follow-up behaves identically. - `Config.EnsureAPIKey` now also returns an error (it is internal; the five callers are updated). - Spec 111 FR-014, FR-030 and US7 are amended so the spec covers the proxy's own key. Docs and CHANGELOG updated. @Dumbris, one question on precedence. When both `MCPPROXY_API_KEY` and `MCPPROXY_API_KEY_FILE` are set I made the file win and log a warning, since an explicit file is the more deliberate choice and keeps the key out of the environment. The alternatives are to prefer the env var (the order the issue first described) or to refuse to start with both set, which is what some official images do. Happy to switch; it is one condition in `ResolveEnvAPIKey` plus its tests. ## Testing - [x] I have tested these changes locally - [x] I have added/updated tests that prove my fix is effective or my feature works - [x] All existing tests pass `go test ./internal/config ./internal/secret ./cmd/mcpproxy ./internal/httpapi` pass. By hand with a built binary: key from the file authenticates (200, no key 401), the key is absent from `/proc/<pid>/environ` and the config, a missing file exits 4 with the config unchanged, `mcpproxy status` finds the key. Not run here: the tray is built only for macOS/Windows. It compiles for Windows; the new `TestBuildCoreEnvironmentAPIKey` in `cmd/mcpproxy-tray/main_test.go` is darwin-only and I could not compile or run it on this Linux host. The macOS native app's `CoreProcessManager.swift` is untouched (it removes `MCPPROXY_API_KEY` and passes its own key). --------- Co-authored-by: Algis Dumbris <a.dumbris@gmail.com>
1 parent 81c9206 commit 97c8c8f

20 files changed

Lines changed: 878 additions & 36 deletions

‎CHANGELOG.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,8 @@ Releases follow [Semantic Versioning](https://semver.org/).
7979

8080
### Features
8181

82+
- **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)
83+
8284
- **catalog:** a catalog source whose live search times out or fails is now answered from the listing
8385
the daemon last saw from it (at most 24 hours old, kept in memory). Those results are marked
8486
`from_cache` ("From cached list" in the Web UI and macOS, `(cached)` in the CLI) and the source

‎cmd/mcpproxy-tray/main.go‎

Lines changed: 30 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -156,14 +156,30 @@ func main() {
156156
// Determine if we're using socket/pipe communication (which doesn't need API key)
157157
usingSocketCommunication := isSocketEndpoint(coreURL)
158158

159+
// Read the API key the environment names (MCPPROXY_API_KEY_FILE or
160+
// MCPPROXY_API_KEY), for consistency with the core. Resolve it even when the
161+
// tray talks over a socket: the core it launches inherits the same
162+
// environment and refuses to start when both variables are set or the file
163+
// cannot be used, so the tray stops here with the reason instead of
164+
// generating a key that would hide it. The error never contains a key.
165+
envKey, envErr := config.ResolveEnvAPIKey()
166+
if envErr != nil {
167+
logger.Error("Cannot read the API key named by the environment", zap.Error(envErr))
168+
_ = logger.Sync()
169+
os.Exit(1)
170+
}
171+
159172
// Setup API key for secure communication between tray and core
160173
// Skip API key generation for socket/pipe connections as they're trusted by default
161174
if trayAPIKey == "" && !usingSocketCommunication {
162-
// Check environment variable first (for consistency with core behavior)
163-
if envAPIKey := os.Getenv("MCPPROXY_API_KEY"); envAPIKey != "" {
164-
trayAPIKey = envAPIKey
165-
logger.Info("Using API key from environment variable for tray-core communication",
166-
zap.String("api_key_source", "MCPPROXY_API_KEY environment variable"),
175+
if envKey.Set && envKey.Key != "" {
176+
trayAPIKey = envKey.Key
177+
source := "MCPPROXY_API_KEY environment variable"
178+
if envKey.FromFile {
179+
source = config.EnvAPIKeyFile + " file"
180+
}
181+
logger.Info("Using API key from environment for tray-core communication",
182+
zap.String("api_key_source", source),
167183
zap.String("api_key_prefix", maskAPIKey(trayAPIKey)))
168184
} else {
169185
trayAPIKey = generateAPIKey()
@@ -1935,9 +1951,15 @@ func (cpl *CoreProcessLauncher) buildCoreEnvironment() []string {
19351951
}
19361952

19371953
// Add our environment variables
1938-
filtered = append(filtered,
1939-
"MCPPROXY_ENABLE_TRAY=false",
1940-
fmt.Sprintf("MCPPROXY_API_KEY=%s", trayAPIKey))
1954+
filtered = append(filtered, "MCPPROXY_ENABLE_TRAY=false")
1955+
// When the key lives in a file (MCPPROXY_API_KEY_FILE, which the core
1956+
// inherits), let the core read it from there: injecting it as
1957+
// MCPPROXY_API_KEY would put it in the core's environment, which is what
1958+
// the file form exists to avoid. The tray read the same file, so both
1959+
// sides hold the same key.
1960+
if os.Getenv(config.EnvAPIKeyFile) == "" {
1961+
filtered = append(filtered, fmt.Sprintf("MCPPROXY_API_KEY=%s", trayAPIKey))
1962+
}
19411963

19421964
// Tell the core it was launched by the tray, so telemetry's launch_source
19431965
// can say so. Without this a tray-spawned core is unclassifiable: its parent

‎cmd/mcpproxy-tray/main_test.go‎

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -425,3 +425,43 @@ func TestShouldTerminateCore(t *testing.T) {
425425
})
426426
}
427427
}
428+
429+
// With MCPPROXY_API_KEY_FILE the core reads the key from the file itself; the
430+
// tray must not copy it into the core's environment.
431+
func TestBuildCoreEnvironmentAPIKey(t *testing.T) {
432+
prev := trayAPIKey
433+
t.Cleanup(func() { trayAPIKey = prev })
434+
trayAPIKey = "tray-key"
435+
launcher := &CoreProcessLauncher{}
436+
437+
count := func(env []string, prefix string) (n int, last string) {
438+
for _, e := range env {
439+
if strings.HasPrefix(e, prefix) {
440+
n++
441+
last = e
442+
}
443+
}
444+
return n, last
445+
}
446+
447+
t.Run("key injected when no file is named", func(t *testing.T) {
448+
t.Setenv("MCPPROXY_API_KEY_FILE", "")
449+
t.Setenv("MCPPROXY_API_KEY", "stale")
450+
n, last := count(launcher.buildCoreEnvironment(), "MCPPROXY_API_KEY=")
451+
if n != 1 || last != "MCPPROXY_API_KEY=tray-key" {
452+
t.Fatalf("want exactly the tray key injected, got %d entries, last %q", n, last)
453+
}
454+
})
455+
456+
t.Run("key left in the file when one is named", func(t *testing.T) {
457+
t.Setenv("MCPPROXY_API_KEY_FILE", "/run/secrets/mcpproxy_key")
458+
t.Setenv("MCPPROXY_API_KEY", "")
459+
env := launcher.buildCoreEnvironment()
460+
if n, _ := count(env, "MCPPROXY_API_KEY="); n != 0 {
461+
t.Fatalf("the key must not be copied into the core's environment, found %d entries", n)
462+
}
463+
if n, _ := count(env, "MCPPROXY_API_KEY_FILE="); n != 1 {
464+
t.Fatalf("the core must inherit MCPPROXY_API_KEY_FILE, found %d entries", n)
465+
}
466+
})
467+
}

‎cmd/mcpproxy/daemon_client.go‎

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ package main
1212

1313
import (
1414
"context"
15+
"fmt"
1516
"net"
1617
"os"
1718
"strings"
@@ -30,11 +31,21 @@ import (
3031
const daemonProbeTimeout = 2 * time.Second
3132

3233
// resolveAPIKey returns the API key the daemon would accept, mirroring
33-
// Config.EnsureAPIKey precedence (env > config file) WITHOUT ever generating
34+
// Config.EnsureAPIKey precedence (MCPPROXY_API_KEY_FILE or MCPPROXY_API_KEY,
35+
// which may not both be set, then the config file) WITHOUT ever generating
3436
// a new key — a fabricated key cannot match a running daemon's.
3537
func resolveAPIKey(cfg *config.Config) string {
36-
if envAPIKey, exists := os.LookupEnv("MCPPROXY_API_KEY"); exists && envAPIKey != "" {
37-
return envAPIKey
38+
envKey, err := config.ResolveEnvAPIKey()
39+
if err != nil {
40+
// Both variables are set, or MCPPROXY_API_KEY_FILE names a file that
41+
// cannot be used. Do not fall back to another key (it would be the
42+
// wrong one, or none): say so and let the command take its no-daemon
43+
// path. The error text never contains a key.
44+
fmt.Fprintf(os.Stderr, "Warning: %v; not using any API key\n", err)
45+
return ""
46+
}
47+
if envKey.Set && envKey.Key != "" {
48+
return envKey.Key
3849
}
3950
if cfg != nil {
4051
return cfg.APIKey
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
package main
2+
3+
import (
4+
"os"
5+
"path/filepath"
6+
"testing"
7+
8+
"github.com/stretchr/testify/assert"
9+
"github.com/stretchr/testify/require"
10+
11+
"github.com/smart-mcp-proxy/mcpproxy-go/internal/config"
12+
)
13+
14+
func TestResolveAPIKey_Precedence(t *testing.T) {
15+
keyFile := filepath.Join(t.TempDir(), "key")
16+
require.NoError(t, os.WriteFile(keyFile, []byte("file-key\n"), 0o600))
17+
cfg := &config.Config{APIKey: "config-key"}
18+
19+
t.Run("config only", func(t *testing.T) {
20+
t.Setenv(config.EnvAPIKey, "")
21+
require.NoError(t, os.Unsetenv(config.EnvAPIKey))
22+
t.Setenv(config.EnvAPIKeyFile, "")
23+
assert.Equal(t, "config-key", resolveAPIKey(cfg))
24+
})
25+
t.Run("env beats config", func(t *testing.T) {
26+
t.Setenv(config.EnvAPIKey, "env-key")
27+
t.Setenv(config.EnvAPIKeyFile, "")
28+
assert.Equal(t, "env-key", resolveAPIKey(cfg))
29+
})
30+
t.Run("file beats config, as the daemon resolves it", func(t *testing.T) {
31+
t.Setenv(config.EnvAPIKey, "")
32+
t.Setenv(config.EnvAPIKeyFile, keyFile)
33+
assert.Equal(t, "file-key", resolveAPIKey(cfg))
34+
})
35+
t.Run("both variables set gives no key, as the daemon refuses to start", func(t *testing.T) {
36+
t.Setenv(config.EnvAPIKey, "env-key")
37+
t.Setenv(config.EnvAPIKeyFile, keyFile)
38+
assert.Empty(t, resolveAPIKey(cfg))
39+
})
40+
t.Run("an unusable file gives no key rather than a wrong one", func(t *testing.T) {
41+
t.Setenv(config.EnvAPIKey, "")
42+
t.Setenv(config.EnvAPIKeyFile, filepath.Join(t.TempDir(), "missing"))
43+
assert.Empty(t, resolveAPIKey(cfg))
44+
})
45+
}

‎cmd/mcpproxy/main.go‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -549,8 +549,13 @@ func runServer(cmd *cobra.Command, _ []string) error {
549549
zap.Bool("enable_prompts", cfg.EnablePrompts))
550550

551551
// Ensure API key is configured
552-
apiKey, wasGenerated, source := cfg.EnsureAPIKey()
553-
552+
apiKey, wasGenerated, source, keyErr := cfg.EnsureAPIKey()
553+
if keyErr != nil {
554+
// Both MCPPROXY_API_KEY and MCPPROXY_API_KEY_FILE are set, or the file
555+
// cannot be used. Stop here: generating and saving a key instead would
556+
// replace the credential the operator meant to run with.
557+
return keyErr
558+
}
554559
// SECURITY: API key is always required - empty keys are never allowed
555560
if apiKey == "" {
556561
return fmt.Errorf("API key is required but could not be generated")

‎cmd/mcpproxy/serve_flag_persistence_test.go‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -200,7 +200,8 @@ func TestServeSaverDoesNotPersistEnvAPIKey(t *testing.T) {
200200

201201
cfg, saver, err := loadConfig(newServeFlagTestCmd())
202202
require.NoError(t, err)
203-
apiKey, wasGenerated, _ := cfg.EnsureAPIKey()
203+
apiKey, wasGenerated, _, keyErr := cfg.EnsureAPIKey()
204+
require.NoError(t, keyErr)
204205
require.Equal(t, "mcp_env_secret", apiKey)
205206
require.False(t, wasGenerated)
206207

‎cmd/mcpproxy/status_cmd.go‎

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -132,7 +132,9 @@ func runStatus(cmd *cobra.Command, _ []string) error {
132132
}
133133

134134
// Ensure API key exists
135-
cfg.EnsureAPIKey()
135+
if _, _, _, keyErr := cfg.EnsureAPIKey(); keyErr != nil {
136+
return keyErr
137+
}
136138

137139
configPath := config.GetConfigPath(cfg.DataDir)
138140

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

151153
// Check if env var overrides
152-
if envKey, exists := os.LookupEnv("MCPPROXY_API_KEY"); exists && envKey != "" {
153-
fmt.Fprintln(os.Stderr, "Warning: MCPPROXY_API_KEY environment variable is set and will override the config file key.")
154+
if envKey, envErr := config.ResolveEnvAPIKey(); envErr == nil && envKey.Set && envKey.Key != "" {
155+
if envKey.FromFile {
156+
fmt.Fprintf(os.Stderr, "Warning: %s is set and the key in %s will override the config file key.\n", config.EnvAPIKeyFile, envKey.Path)
157+
} else {
158+
fmt.Fprintln(os.Stderr, "Warning: MCPPROXY_API_KEY environment variable is set and will override the config file key.")
159+
}
154160
fmt.Fprintln(os.Stderr)
155161
}
156162

‎docs/configuration.md‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -827,7 +827,7 @@ With `require_mcp_auth` off, a change that would let a client bound to a profile
827827
| `allow_server_remove` | boolean | `true` | Allow removing servers via API/tools |
828828

829829
**Security Notes:**
830-
- **API Key**: Set via `--api-key` flag, `MCPPROXY_API_KEY` environment variable, or config file
830+
- **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
831831
- **Empty API Key**: Empty values are replaced with an auto-generated key; authentication is always enforced
832832
- **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`
833833
- **Tray Integration**: Tray app automatically manages API keys for core communication
@@ -1858,6 +1858,7 @@ Many configuration options can be overridden via environment variables:
18581858
|----------------------|--------------|-------------|
18591859
| `MCPPROXY_LISTEN` / `MCPP_LISTEN` | `listen` | Network binding address |
18601860
| `MCPPROXY_API_KEY` | `api_key` | API key for authentication (empty values trigger auto-generation; auth remains enabled) |
1861+
| `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) |
18611862
| `MCPPROXY_TRUSTED_HOSTS` | `trusted_hosts` | Comma-separated `Host` allowlist for loopback listeners behind a reverse proxy |
18621863
| `MCPPROXY_TRUSTED_PROXIES` | `trusted_proxies` | Comma-separated CIDRs/IPs whose `X-Forwarded-*` headers are honoured |
18631864
| `MCPPROXY_AUDIT_LOG_ENABLED` | `audit_log.enabled` | Turn audit logging on/off |

‎docs/configuration/environment-variables.md‎

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,9 +30,46 @@ Environment variables are useful for CI/CD environments or temporary overrides d
3030
|----------|-------------|---------|
3131
| `MCPPROXY_LISTEN` | Override listen address | `127.0.0.1:8080` or `:8080` |
3232
| `MCPPROXY_API_KEY` | Set API key for authentication | `my-secret-key` |
33+
| `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` |
3334
| `MCPPROXY_DATA_DIR` | Override data directory | `/var/lib/mcpproxy` |
3435
| `MCPPROXY_DATA` | Override data directory (backward compatibility, prefer `MCPPROXY_DATA_DIR`) | `/var/lib/mcpproxy` |
3536

37+
### API key from a file
38+
39+
`MCPPROXY_API_KEY_FILE` is the `*_FILE` form used by Docker Compose and Swarm secrets
40+
(`/run/secrets/<name>`), Kubernetes secret volumes and systemd `LoadCredential=`. The key is read
41+
from the file at start, so it is not in the process environment (`docker inspect`,
42+
`/proc/<pid>/environ`, crash dumps) and is never written to `mcp_config.json`.
43+
44+
- The path must be absolute. A symlink is followed (Kubernetes secret volumes are symlinks) but must
45+
point at a regular file. At most 64 KiB is read.
46+
- One trailing newline (LF or CRLF) is removed. Everything else, including inner whitespace, is kept.
47+
- An empty `MCPPROXY_API_KEY_FILE` is treated as unset.
48+
- Set `MCPPROXY_API_KEY` or `MCPPROXY_API_KEY_FILE`, never both. If both are non-empty, mcpproxy
49+
refuses to start (and the CLI commands and the tray stop) with an error that names both
50+
variables: `MCPPROXY_API_KEY and MCPPROXY_API_KEY_FILE are both set; set only one of them`. The
51+
check does not open the file, and the error never contains a key or file contents. An empty
52+
variable counts as unset.
53+
- A file that is named but cannot be used (missing, unreadable, not absolute, too large, empty) stops
54+
startup with an error that names the variable and path. mcpproxy does **not** fall back to
55+
the config file or a generated key, so a typo in a secret path cannot start the
56+
proxy under a different credential.
57+
- `mcpproxy status`, the other CLI commands and the tray read the same variable, so they find the key
58+
the daemon uses. A tray-launched core reads the file itself rather than receiving the key in its
59+
environment.
60+
61+
```yaml
62+
# docker-compose.yml
63+
services:
64+
mcpproxy:
65+
environment:
66+
MCPPROXY_API_KEY_FILE: /run/secrets/mcpproxy_api_key
67+
secrets: [mcpproxy_api_key]
68+
secrets:
69+
mcpproxy_api_key:
70+
file: ./mcpproxy_api_key.txt
71+
```
72+
3673
### Security Settings
3774
3875
| Variable | Description | Default |

0 commit comments

Comments
 (0)