secret-vault-mcp
by n8nfelipe
README.md
# secret-vault-mcp
Local, encrypted password vault exposed as a [Model Context Protocol (MCP)](https://modelcontextprotocol.io/) server. Secrets live on disk in AES-256-GCM ciphertext, unlocked by a master password derived via PBKDF2-HMAC-SHA256 (600,000 iterations). The master password is never written, never logged, and never transmitted.
Designed to be consumed by any MCP client (opencode, Claude Desktop, Cursor, etc.) via stdio transport.
## Why
- **Local-first**: no network, no cloud, no telemetry.
- **Strong crypto**: AES-256-GCM with fresh 12-byte nonce per write; PBKDF2 with OWASP-recommended 600k iterations.
- **MCP-native**: all 8 vault operations are first-class tools the LLM can call.
- **Auditable**: ~87% test coverage, small surface, no exotic dependencies.
## Tools
| Tool | Description | Inputs |
|---------------------|--------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------|
| `add_secret` | Add a new entry | `name`, `username`, `password`, `url?`, `notes?` |
| `get_secret` | Fetch an entry; password revealed only when `reveal_password=true` | `name`, `reveal_password=false` |
| `list_secrets` | List all entries without exposing passwords | — |
| `update_secret` | Update one or more fields of an existing entry | `name`, `username?`, `password?`, `url?`, `notes?` |
| `delete_secret` | Remove an entry by name | `name` |
| `generate_password` | Generate a strong random password | `length=20`, `upper=true`, `lower=true`, `digits=true`, `symbols=true`, `exclude_ambiguous=false` |
| `search_secrets` | Case-insensitive substring search across `name`/`url`/`username`/`notes` | `query` |
| `vault_status` | Vault metadata: entry count, last access timestamp, version | — |
All tools return JSON. Errors are returned as `{"error": "..."}` without stack traces or internal state.
### Examples
Once registered in your MCP client, the 8 tools are available to the LLM. You invoke them with natural language — the client translates into MCP `tools/call`.
**In opencode / Claude Desktop / Cursor** (natural language):
```text
# Store a secret
> add a secret called github with username felipe and password xK9!mP2qR8vN4wL7
# List everything (no passwords exposed)
> show me all my saved credentials
# Retrieve one (without exposing the password)
> get the github entry
# Retrieve and reveal
> get the github entry and show me the password
# Generate a strong password
> generate a 40-character password with symbols, no ambiguous characters
# Search across all fields
> find any entry that mentions "opencode"
# Update (rotate a password)
> rotate the password for the github entry to a new generated one
# Delete
> delete the entry called old-mailbox
# Status
> how many entries are in the vault and when was it last accessed?
```
**Equivalent raw tool calls** (what the client sends over JSON-RPC):
```json
{ "method": "tools/call", "params": { "name": "add_secret",
"arguments": { "name": "github", "username": "felipe", "password": "xK9!mP2qR8vN4wL7" } } }
{ "method": "tools/call", "params": { "name": "list_secrets" } }
{ "method": "tools/call", "params": { "name": "get_secret",
"arguments": { "name": "github", "reveal_password": true } } }
{ "method": "tools/call", "params": { "name": "generate_password",
"arguments": { "length": 40, "symbols": true, "exclude_ambiguous": true } } }
{ "method": "tools/call", "params": { "name": "search_secrets",
"arguments": { "query": "opencode" } } }
{ "method": "tools/call", "params": { "name": "update_secret",
"arguments": { "name": "github", "password": "<new-generated>" } } }
{ "method": "tools/call", "params": { "name": "delete_secret",
"arguments": { "name": "old-mailbox" } } }
{ "method": "tools/call", "params": { "name": "vault_status" } }
```
**Calling from a Python script** (using the official `mcp` client):
```python
import asyncio, os
from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client
async def main():
params = StdioServerParameters(
command="/home/felipe/infra/secret-vault-mcp/.venv/bin/python3",
args=["-u", "/home/felipe/infra/secret-vault-mcp/server.py"],
env={**os.environ, "VAULT_MASTER_PASSWORD": "your-long-passphrase"},
)
async with stdio_client(params) as (read, write):
async with ClientSession(read, write) as session:
await session.initialize()
await session.call_tool("add_secret", {
"name": "github", "username": "felipe", "password": "xK9!mP2qR8vN4wL7",
"url": "https://github.com",
})
result = await session.call_tool("get_secret", {
"name": "github", "reveal_password": True,
})
print(result.content[0].text)
asyncio.run(main())
```
**Typical workflows:**
```text
# Onboarding: create a few entries
> store these credentials: github/felipe/<gen-32>, aws-root/admin/<gen-40>, redis-local/redis/<gen-24>
# Daily use: look up a password to paste into another tool
> get the aws-root entry and reveal the password
# Rotation: replace a weak/old password
> generate a 32-character password, then update the github entry with it
# Audit: what do I have stored?
> list all entries and group them by URL domain
# Cleanup: remove obsolete entries
> delete entries named "test-1", "test-2", and "demo"
```
## Security Model
- **Cipher**: AES-256-GCM, 12-byte random nonce per encryption operation.
- **KDF**: PBKDF2-HMAC-SHA256, 600,000 iterations, 16-byte random salt.
- **Storage layout** (XDG Base Directory):
- `vault.enc` — ciphertext, mode `0600`
- `vault.salt` — salt, mode `0600`
- `vault.lock` — `flock` for inter-process serialization
- **Concurrency**: `flock` (inter-process) + `threading.RLock` (intra-process re-entrant).
- **Atomic writes**: temp file + `os.replace` — no partial writes.
- **Master password**: required at server start via `VAULT_MASTER_PASSWORD`; never logged, never printed, never cached to disk.
- **Passwords in responses**: `list_secrets` always returns `password=""`. `get_secret` returns `password=""` unless `reveal_password=true`.
> ⚠️ **Threat model**: protects against at-rest disclosure of the vault files (e.g. disk theft, backup leakage). Does **not** protect against a compromised runtime or a malicious MCP client that calls `reveal_password=true` on your behalf. Treat the master password as the root of trust.
## Setup
```bash
cd ~/infra/secret-vault-mcp
python3 -m venv .venv
source .venv/bin/activate
pip install -e ".[dev]"
cp .env.example .env
# edit .env and set VAULT_MASTER_PASSWORD
```
## Run
```bash
# via the MCP stdio transport (used by opencode/Claude Desktop/Cursor)
python server.py
# or with the master password inline
VAULT_MASTER_PASSWORD="my-long-passphrase" python server.py
```
## Tests
```bash
pytest # 47 tests
pytest --cov=auth --cov=client --cov=models --cov=server
```
Current coverage: **~87%** (target ≥80%).
## File Layout (Runtime)
```
$XDG_DATA_HOME/secret-vault-mcp/ (default: ~/.local/share/secret-vault-mcp/)
├── vault.enc # AES-256-GCM ciphertext
├── vault.salt # 16-byte salt (0600)
└── vault.lock # flock file
```
Override the directory with `XDG_DATA_HOME`.
## Project Layout
```
secret-vault-mcp/
├── auth.py # VaultConfig + constants (KDF, cipher, paths)
├── client.py # VaultClient (encrypt/decrypt, CRUD, lock, search, generate)
├── models.py # Pydantic models (SecretEntry, SecretUpdate, VaultState)
├── server.py # FastMCP server + tool definitions
├── tests/
│ ├── conftest.py
│ ├── test_auth.py
│ ├── test_client.py
│ └── test_server.py
├── pyproject.toml
├── .env.example
├── .gitignore
├── LICENSE
├── .github/workflows/ci.yml
└── README.md
```
## Register in opencode
Add the following to `~/.config/opencode/opencode.jsonc`:
```jsonc
{
"mcp": {
"secret-vault": {
"type": "stdio",
"command": "/home/felipe/infra/secret-vault-mcp/.venv/bin/python3",
"args": ["-u", "/home/felipe/infra/secret-vault-mcp/server.py"],
"env": {
"VAULT_MASTER_PASSWORD": "your-long-passphrase-here"
},
"enabled": true
}
}
}
```
> 🔐 Keep `VAULT_MASTER_PASSWORD` out of version control. Consider using a secret loader (1Password CLI, `pass`, system keyring) to inject it at startup.
## CI
GitHub Actions runs `ruff` + `pytest` on every push/PR and creates a GitHub release on push to `main` using `ncipollo/release-action`.
## License
[MIT](LICENSE) © 2026 Felipe Moura (@n8nfelipe)
This server cannot be deployed
Maintenance
ActivityStale
ResponsivenessNo issues