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 @@ -88,6 +88,8 @@ Releases follow [Semantic Versioning](https://semver.org/).
document per profile, in mcpproxy's own profile format. Also `GET /api/v1/access/suggest` and the
`suggest` operation of the `profiles` MCP tool (administrators only).
- **`mcp_reject_query_apikey` refuses query-parameter credentials on MCP endpoints.** Set it to `true` and every MCP endpoint (`/mcp`, `/mcp/p/<slug>`, `/mcp/all`, `/mcp/code`, `/mcp/call` and the legacy `/v1/tool_code` aliases) answers a request carrying `?apikey=` with a 401 instead of honouring it, which forces every client onto `Authorization: Bearer` or `X-API-Key`. It cannot stop a URL that was already sent from being logged by whatever sits in front of the proxy; it makes sure such a client stops working until it is migrated, so the token stops appearing in access logs, proxy logs and browser history from then on. The refusal is explicit rather than "no credential" because, with `require_mcp_auth` off, an unrecognised credential would otherwise be served as the anonymous administrator. Default `false`, so nothing changes unless you opt in; the REST API, the Web UI and its `/events` stream are not affected. Live, no restart. **Caveat:** the Connect wizard writes Codex (and the generic fallback) entries with `?apikey=<credential>` in the URL, so a client connected that way gets a 401 once the option is on: remove the query parameter and carry the credential in a header instead (`env_http_headers` or `http_headers`, see the Connect clients guide); Connect itself is not yet aware of the option.
- **secrets: `${file:/absolute/path}` references (#1706, spec 111 FR-030).** A secret can be read from a file, as Docker/Compose secrets, Kubernetes secret volumes and systemd `LoadCredential=` provide it, wherever `${env:}` and `${keyring:}` are accepted: an upstream server's `env`, `args` and `headers`, `data_dir`, and the proxy's own `api_key`. The path must be absolute, a symlink must lead to a regular file, at most 64 KiB is read, one trailing LF or CRLF is removed, and a missing, unreadable or empty file is an error that names the reference, never the content. A server shows a missing-secret error and the proxy stays up; an `api_key` reference that does not resolve stops startup (no key is generated over it). The stored config keeps the reference: the value is resolved on a copy, and the `api_key` secret is a process-only override that no save, including `PATCH /api/v1/config`, writes back. (refs #1706)

- **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
Expand Down
2 changes: 2 additions & 0 deletions docs/configuration/environment-variables.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,8 @@ from the file at start, so it is not in the process environment (`docker inspect
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.
- The same file can instead be referenced from the config as `"api_key": "${file:/run/secrets/mcpproxy_api_key}"`
(see [File references](../features/keyring-integration.md#file-references)).
- 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
Expand Down
3 changes: 2 additions & 1 deletion docs/configuration/upstream-servers.md
Original file line number Diff line number Diff line change
Expand Up @@ -304,13 +304,14 @@ endpoint that backs the "Convert to secret" affordance.

### Secret references

`headers` and `env` values support two reference shapes that get resolved
`headers` and `env` values support three reference shapes that get resolved
at request time rather than stored in plaintext:

| Reference | Source | Lifetime |
|---|---|---|
| `${keyring:NAME}` | OS keyring entry called `NAME` | Persists across restarts; managed via the Secrets page or `mcpproxy secrets` CLI |
| `${env:VAR}` | The `VAR` environment variable on the mcpproxy process | Tied to the shell/launcher that started mcpproxy |
| `${file:/absolute/path}` | The contents of a file (Docker/Kubernetes secret, systemd credential), one trailing newline removed | Read each time the server's connection is set up, so a rotated secret is picked up when the server is next restarted |

When mcpproxy connects to an upstream it substitutes the reference with
the actual secret. The reference itself never reaches the upstream MCP
Expand Down
33 changes: 32 additions & 1 deletion docs/features/keyring-integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,12 +40,43 @@ All secrets are stored under the service name **`mcpproxy`**.

## Secret Reference Syntax

MCPProxy supports two types of secret references that can be used anywhere in your configuration where a string value is expected:
MCPProxy supports three types of secret references that can be used anywhere in your configuration where a string value is expected:

| Syntax | Provider | Description |
|--------|----------|-------------|
| `${keyring:name}` | OS Keyring | Resolves from macOS Keychain, Linux Secret Service, or Windows Credential Manager |
| `${env:VAR_NAME}` | Environment | Resolves from environment variables |
| `${file:/absolute/path}` | File | Reads the secret from a file, as Docker/Compose secrets (`/run/secrets/<name>`), Kubernetes secret volumes and systemd `LoadCredential=` provide it |

### File references

`${file:/absolute/path}` keeps a secret out of the config file and out of the process environment
(`docker inspect`, `/proc/<pid>/environ`). The same rules apply wherever it is used:

- The path must be absolute. A symlink is followed (Kubernetes secret volumes are symlinks) but must
point at a regular file. On Windows an absolute path has a drive letter (`C:\ProgramData\mcpproxy\key`
or `C:/ProgramData/mcpproxy/key`); a path that starts with `/` and has no drive is not absolute. In
`mcp_config.json` a backslash must be escaped (`"${file:C:\\ProgramData\\mcpproxy\\key}"`), so forward
slashes are easier.
- At most 64 KiB is read. A larger file is an error, never a truncated secret.
- One trailing newline is removed, LF or CRLF, so a file written on Windows resolves to the same value
as one written on Linux. Leading and inner whitespace are kept.
- A NUL byte, an empty file, a missing file and an unreadable file are errors. The error names the
reference and the reason, never the file's content.
- The file is read each time the reference is resolved, which for an upstream server is when its
connection is set up. A rotated secret is picked up when the server is next restarted.
- The stored config keeps the reference; the value exists only in memory, on a copy.

The proxy's own `api_key` accepts the same reference (`"api_key": "${file:/run/secrets/mcpproxy_api_key}"`),
and so does the `MCPPROXY_API_KEY_FILE` environment variable (see
[API key from a file](../configuration/environment-variables.md#api-key-from-a-file)). An `api_key`
reference that cannot be resolved stops startup: mcpproxy does not generate a key over it. The
reference is resolved when the config is loaded and the key then stays fixed while the process runs (as for
every `api_key` change), so a rotated key file takes effect on the next restart. An API key from the environment overrides the config file's, and then the reference
is not read.

A `${file:}` reference reads any file the mcpproxy process can read, so treat who can edit server
configuration as you would for `${env:}`.

References can be used in:

Expand Down
62 changes: 62 additions & 0 deletions internal/config/api_key_env.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package config

import (
"context"
"errors"
"fmt"
"os"
Expand Down Expand Up @@ -93,3 +94,64 @@ func ResolveEnvAPIKey() (EnvAPIKeyResult, error) {

return EnvAPIKeyResult{Set: envSet, Key: envKey}, nil
}

// resolveAPIKeyRef replaces an api_key that is a secret reference with the
// value it points at, as a process-only override: the effective config runs
// with the secret, while every save writes the reference (never the secret)
// back, whatever else the save is about (see process_overrides.go).
//
// The reference is resolved here, once per load, and a reference that cannot be
// resolved fails the load. Generating a key instead would start the proxy
// under a credential the operator did not choose and save it over the
// reference.
//
// A key from the environment (MCPPROXY_API_KEY_FILE or MCPPROXY_API_KEY)
// overrides the config file's key, so with one present the reference is left
// as written, unread, for EnsureAPIKey's override to replace.
func resolveAPIKeyRef(c *Config) error {
if !secret.IsSecretRef(c.APIKey) {
return nil
}
envKey, err := ResolveEnvAPIKey()
if err != nil {
return err
}
if envKey.Set && envKey.Key != "" {
return nil
}
resolved, err := secret.NewResolver().ExpandSecretRefs(context.Background(), c.APIKey)
if err != nil {
// The error is deliberately a fixed sentence: ExpandSecretRefs' own
// text quotes the reference, and the api_key field is the one value
// no error or log line may carry. The operator knows which field it is.
return errors.New("api_key: could not resolve the secret reference; check that the referenced file, environment variable or keyring entry exists and is readable")
}
if resolved == "" {
return fmt.Errorf("api_key: the reference resolved to an empty value")
}
// api_key is fixed for the life of the process (a reload pins the running
// value), and this load may be one that never becomes the live config, such
// as the disk re-read of a server restart. Once a reference has been
// resolved, later loads carry the key the process runs with, not what the
// file holds now: recording a rotated value as the process value would make
// the running key look like an edit, and the next save would write it over
// the reference. The file is still read above, so an unusable reference
// fails the load; a rotated key takes effect on the next start.
if running, ok := runningAPIKeyFromReference(); ok {
resolved = running
}
OverrideForProcess(c, FieldAPIKey, OverrideSourceEnv, resolved)
return nil
}

// runningAPIKeyFromReference returns the api_key this process runs with when it
// was resolved from a secret reference.
func runningAPIKeyFromReference() (string, bool) {
processOverridesMu.RLock()
defer processOverridesMu.RUnlock()
o, ok := processOverrides[overrideKey{FieldAPIKey.Name, OverrideSourceEnv}].(typedOverride[string])
if !ok || !secret.IsSecretRef(o.loaded) {
return "", false
}
return o.process, true
}
Loading
Loading