secrets-mcp-server
# secrets-mcp-server
An MCP server for storing secrets (API keys, tokens, credentials) encrypted
at rest on your own machine, built so an AI agent can manage them **without
the plaintext value ever entering its context**. There is no `get_secret`
tool that returns a value as text — that was a deliberate design decision,
not an oversight. Using a secret is always indirect: run a command with the
value injected into its environment, or write the value straight into a
destination file.
## Why
Giving an agent free-form shell access to your `.env` files means every
secret it touches can end up echoed back into its own transcript, its logs,
or a chat history — anywhere from a debugging session to a support ticket.
This server keeps secrets in one encrypted store and only exposes them
through two narrow, auditable operations: inject-into-subprocess and
write-to-file. The agent can use a secret to authenticate a request or
populate a config file; it can never see, print, or leak the raw value
through a normal tool call.
## Tools
| Category | Tool | Description |
|---|---|---|
| CRUD | `create_secret(name, value, description=None)` | Creates a secret; fails if `name` already exists |
| CRUD | `create_secrets_batch(items)` | Creates several at once (`items: [{name, value, description?}]`). Does not abort on the first error — each item reports its own status (`created`/`skipped_exists`/`error`) |
| CRUD | `import_secrets_from_file(source_path, name_pattern=None, prefix=None, overwrite=False)` | Reads a `.env`-style file (`KEY=VALUE` per line, `export KEY=VALUE` and `#` comments supported) and imports each key as a secret. `name_pattern` (regex) filters which keys get imported; `prefix` is prepended to the name; `overwrite` controls whether existing secrets get updated |
| CRUD | `update_secret(name, value)` | Overwrites the value; fails if the secret doesn't exist |
| CRUD | `delete_secret(name)` | Removes a secret |
| CRUD | `list_secrets()` | Lists name/description/timestamps for every secret. **Never includes the value** |
| Opaque use | `run_with_secret(secret_name, command, env_var_name=None, cwd=None, timeout=None)` | Runs `command` (a list of args, no shell) with the value injected as an environment variable (`env_var_name`, or the secret name upper-cased by default) only inside that subprocess. Returns `exit_code`/`stdout`/`stderr`, with the value redacted if the command happens to print it |
| Opaque use | `apply_secrets_to_file(names, dest_path, key_names=None, format="env")` | Writes one or more secrets straight into `dest_path` (`env`-style `.env`, or `json`), never returning any value as text. `names` is always an explicit list — there is no "export everything" option |
`run_with_secret` never uses a shell (`shell=False`, `command` is a list of
args) — this avoids command injection even if an argument comes from
untrusted text.
`apply_secrets_to_file` only writes inside directories listed in
`SECRETS_MCP_ALLOWED_WRITE_DIRS` — without that configured, every write is
rejected.
`import_secrets_from_file` can read any file the process has OS permission
to read — this is a deliberate choice, with no read-side allowlist (unlike
writes). If you need to restrict that, add a
`SECRETS_MCP_ALLOWED_READ_DIRS` check following the same pattern as
`validate_dest_path` in `security.py`.
## Environment variables
| Variable | Default | Purpose |
|---|---|---|
| `SECRETS_MCP_MASTER_PASSPHRASE` | (required) | Passphrase used to derive the store's encryption key. Without it, the server refuses every operation |
| `SECRETS_MCP_STORE_PATH` | `~/.secrets-mcp/store.enc` | Where the encrypted store file lives |
| `SECRETS_MCP_ALLOWED_WRITE_DIRS` | (empty) | Comma-separated list of directories `apply_secrets_to_file` may write into. Empty means no writes are allowed |
| `SECRETS_MCP_COMMAND_TIMEOUT_SECONDS` | `30` | Timeout for `run_with_secret` (1-300) |
## Security model
- The store is a single file (`salt` + ciphertext) encrypted as a whole
with Fernet (`cryptography`); the key is derived from the passphrase via
Scrypt with a fresh random salt on every write. A wrong passphrase or a
corrupted file fails loudly — there is no silent fallback.
- The store file and any file written by `apply_secrets_to_file` end up
with `0600` permissions.
- `run_with_secret` never uses `shell=True`; `command` is always a list of
args, never a shell string.
- `apply_secrets_to_file` resolves the destination path (`realpath`,
following symlinks) and rejects anything outside
`SECRETS_MCP_ALLOWED_WRITE_DIRS`.
- No tool logs or returns a raw value. `run_with_secret` does a best-effort
redaction (`security.redact`) that strips literal occurrences of the
value from `stdout`/`stderr` in case the command echoes it by accident —
this is not a guarantee against every leak (e.g. a command that writes
the value to a file outside this tool's control), but it covers the
common case.
## Requirements
- Python 3.11+
- [`uv`](https://docs.astral.sh/uv/) to install dependencies and run the
server
## Installation
From PyPI, no clone needed:
```
uvx secrets-mcp-server
```
or install it as a persistent CLI tool:
```
uv tool install secrets-mcp-server
# or: pipx install secrets-mcp-server
```
From source, for local development:
```
git clone https://github.com/KauaLealz/secrets-mcp-server.git
cd secrets-mcp-server
uv sync
```
## Registering with Claude Code
Using the published package (no clone required):
```
claude mcp add --scope user secrets \
--env SECRETS_MCP_MASTER_PASSPHRASE=<your-passphrase> \
--env SECRETS_MCP_ALLOWED_WRITE_DIRS=/path/to/your/projects \
-- uvx secrets-mcp-server
```
Using a local clone instead:
```
claude mcp add --scope user secrets \
--env SECRETS_MCP_MASTER_PASSPHRASE=<your-passphrase> \
--env SECRETS_MCP_ALLOWED_WRITE_DIRS=/path/to/your/projects \
-- uv run --directory /path/to/secrets-mcp-server secrets-mcp-server
```
Replace `/path/to/your/projects` with whichever directories
`apply_secrets_to_file` should be allowed to write into (comma-separated for
more than one), and `/path/to/secrets-mcp-server` with wherever you cloned
the repo, if using the local-clone form.
`--scope user` makes it available in every Claude Code session. Changing
the passphrase between registrations produces a different store (the
encryption key depends on it) — keep the same passphrase to keep accessing
an existing store, and store it somewhere safe (a password manager). There
is no recovery if you lose it.
A `.env.example` is included as a reference for every variable below — it
is not auto-loaded, it just documents the shape a `.env` for this project
would take if you build tooling around it.
## Registering with other MCP clients
Any MCP client that supports stdio servers can run this the same way:
launch `uvx secrets-mcp-server` (or `uv run --directory /path/to/secrets-mcp-server secrets-mcp-server`
for a local clone) with the environment variables above set in its process
environment. Check your client's documentation for how it declares stdio
MCP servers (e.g. a `mcpServers` entry in its config file).
## Tests
```
uv run pytest
```
## Contributing
Issues and pull requests are welcome. See `AGENTS.md` for the design
constraint this project is built around (no tool ever returns a raw secret
value) — please keep new tools consistent with it, or open an issue to
discuss before changing it.
## License
MIT — see `LICENSE`.
TDQS
Scored across 8 tools
Each tool targets a clearly distinct operation: individual CRUD, batch creation, file import, and two separate consumption modes (file substitution vs command execution). There is no meaningful overlap or ambiguity between tool responsibilities.
Core operations follow a consistent verb_noun pattern such as create_secret, update_secret, delete_secret, and list_secrets. The batch, import, and application tools use slightly different phrasal forms, but they remain readable and predictable.
Eight tools is well-scoped for a secrets management server. It covers single operations, batch creation, file-based import, and secure usage without unnecessary redundancy or bloat.
The toolset provides full lifecycle coverage for secrets: create, update, delete, and list, with batch and import variants for efficiency. The absence of a plain get_secret action appears intentional for security and does not create a workflow dead end.