Skip to main content
Glama
n8nfelipe

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)