vault-mcp
README.md
# vault-mcp
A personal secrets vault your AI coding agent can use **without ever seeing your keys**.
Store your API keys once. When the agent needs a Stripe key, a Google Maps key or a database URL, it searches
the vault, finds the metadata, and writes the value straight into your `.env`. The value goes to disk; the
conversation only ever sees the variable name.
```
You: "Add a map to the dashboard showing user locations."
Agent: [vault_search "google maps"] -> finds google-maps/default/api_key
[vault_inject_env] -> writes NEXT_PUBLIC_GOOGLE_MAPS_API_KEY into .env
"I set NEXT_PUBLIC_GOOGLE_MAPS_API_KEY in .env and wired up the map component."
```
No more pasting keys into chat.
Works with **Claude Code, Claude Desktop, Cursor, Windsurf, Antigravity, VS Code, Zed** — anything that
speaks MCP over stdio.
---
## What it does
- **Your agent stops asking you for keys.** It searches the vault first and injects what it finds.
- **It never sees the values.** All 29 tools return metadata and status only. Values go to a file, a process
environment or a remote server, never back through the conversation.
- **Groups and accounts.** `stripe/acme` and `stripe/personal` stay apart, each with its own secret key,
publishable key and webhook secret.
- **Projects.** A project holds its own secrets and links to shared ones, so one call sets up a whole `.env`.
- **Four injection targets.** A local `.env`, any config file through `{{vault:path}}` placeholders, a
command's environment with the output redacted, or a remote server's env file over SSH.
- **Two ways to add a key.** Tell the agent the value, or have it open the vault in your browser so you type
the key in yourself behind your access code.
- **Encrypted at rest, no password to remember.** AES-256-GCM, with the master key in Windows Credential
Manager or the macOS Keychain.
- **An access code for what matters.** Gates the web UI, and individually gates any secret marked
`--protected`.
- **Backups.** Local snapshots for undoing mistakes, portable passphrase-sealed backups for a drive or
another machine.
- **Guard rails.** It refuses to write a secret into a git-tracked file, warns when a server-only key is
headed for a browser variable, and logs every read and injection.
- **15 service presets.** Stripe, Google Maps, AWS, OpenAI, Anthropic, Cloudflare, Supabase, Firebase,
Twilio, SendGrid, Resend, GitHub, a database URL, an SSH server, and a generic catch-all.
## Install
```bash
git clone https://github.com/MHasnainJafri/vault-mcp.git
cd vault-mcp
npm install
npm run build
npm link # makes `vault` available everywhere (optional)
vault init # creates ~/.vault-mcp and the master key
```
Then register it with your editor or agent. `vault install` shows which clients it found on your machine:
```bash
vault install # list clients, and mark the ones detected
vault install --client cursor # write that client's config
vault install --all # every client it detected
vault install --print # just show the JSON to paste
```
The config it writes is merged into whatever is already there, and the original is copied to
`<config>.vault-backup` first, so no other server or setting is disturbed.
| Client | Config it writes |
|---|---|
| Claude Code | prints `claude mcp add vault -s user -- node <path> serve` |
| Claude Desktop | `%APPDATA%\Claude\claude_desktop_config.json`, or `~/Library/Application Support/Claude/…` |
| Cursor | `~/.cursor/mcp.json`, or `.cursor/mcp.json` with `--project` |
| Windsurf | `~/.codeium/windsurf/mcp_config.json` |
| Antigravity (IDE and CLI) | `~/.gemini/config/mcp_config.json`, or `.agents/mcp_config.json` with `--project` |
| VS Code (Copilot) | `.vscode/mcp.json` with `--project` |
| Zed | use `--print` and paste under `context_servers` |
Any other MCP client takes the standard block:
```json
{
"mcpServers": {
"vault": { "command": "node", "args": ["/abs/path/to/vault-mcp/dist/cli.js", "serve"] }
}
}
```
VS Code names that block `servers` instead of `mcpServers`. A newly registered server appears the next time
the client starts.
Then set your access code, which you type yourself and never share with the agent:
```bash
vault set-pin
```
## Add your keys
Three ways, none of which put the value in a chat window.
**Guided, per service** — walks the fields a service needs, hidden prompts:
```bash
vault add stripe/acme # secret_key, publishable_key, webhook_secret
vault add google-maps/default # api_key
vault setup # first-run wizard: init + first service + install
```
**One key at a time:**
```bash
vault add openai/personal/api_key --env OPENAI_API_KEY --tags ai
cat key.pem | vault add ssh/prod/private_key --stdin
```
**Import a file you already have** — a `.env`, or a JSON object of named fields:
```bash
vault import ./my-app/.env --to projects/my-app
vault import ./server.json --to vps/my-server
```
JSON is the quickest way to add something with several fields at once:
```json
{ "host": "198.51.100.20", "username": "deploy", "password": "…", "port": 22 }
```
Each field becomes its own secret. Nested objects are flattened, so `{"db":{"user":"root"}}` becomes
`db/user`, and an array is kept whole as one value. The format is picked from the file extension, or sniffed
from the content if the name does not say.
**Or use the local web UI:**
```bash
vault ui
```
Opens `http://127.0.0.1:5177` on loopback only. If you have set an access code it asks for that first. Add,
edit, tag, import, back up, and reveal values (reveal works only in this local page, never through the agent).
### Two ways to add a key mid-conversation
When the agent needs a key you do not have stored yet, you pick which way it goes in:
| | What happens | When to use it |
|---|---|---|
| **You tell the agent the value** | It calls `vault_add_secret` and stores it | Test keys, throwaways. The value is in the transcript, so rotate anything live. |
| **"Open the vault for me"** | It calls `vault_open_ui`; your browser opens on the vault's lock screen; you type the key in yourself | Production credentials, anything that matters. The agent never sees it. |
For the second one, the agent is deliberately not given the URL token, so it cannot read that page. Your
access code is what unlocks it.
```bash
vault set-pin # set the access code once
```
## Organize
Paths are `group/account/field`, so multiple accounts of the same service stay apart:
```
stripe/
acme/ secret_key, publishable_key, webhook_secret
personal/ secret_key
google-maps/
default/ api_key
projects/
my-shop/ database_url <- project-specific
```
Link a shared key into a project instead of copying it:
```bash
vault link projects/my-shop stripe/acme/secret_key
vault link projects/my-shop stripe/acme/secret_key --env PAYMENTS_KEY # rename per project
```
Then one call injects everything that project needs:
```bash
vault inject --project projects/my-shop --file .env
```
## What the agent can do
The server registers as **`vault`**, so its tools appear as `vault_*`. All 29, with their parameters, are in
[TOOLS.md](TOOLS.md). The ones that matter day to day:
**Finding a key**
| Tool | What it does |
|---|---|
| `vault_search` | Find a credential by service, purpose or env var name. Metadata only. |
| `vault_list` | Browse the tree of groups, accounts and projects. |
| `vault_info` | One secret in full; optional heavily masked preview (`sk_l…9Xq`). |
| `vault_project` | Everything one project needs, with the env var name for each. |
| `vault_presets` | The 15 service templates and their fields. |
| `vault_status` | Counts, and whether the access code is set or unlocked. |
| `vault_audit` | Recent activity: which secret went where. |
**Using a key**
| Tool | What it does |
|---|---|
| `vault_inject_env` | Write secrets into a `.env`. Refuses git-tracked files. |
| `vault_render_file` | Fill `{{vault:stripe/acme/secret_key}}` placeholders in any config file. |
| `vault_run` | Run a command with secrets in its environment, output redacted. |
| `vault_ssh_inject` | Write secrets into a remote server's env file over SSH. |
| `vault_check_env` | Compare a `.env` against the vault; report what is missing. |
| `vault_write_env_example` | Generate a committable `.env.example`. |
| `vault_gitignore_check` | Confirm a secrets file is git-ignored, and fix it if not. |
**Adding and organizing**
| Tool | What it does |
|---|---|
| `vault_open_ui` | Open the vault in your browser so you type the key in yourself. |
| `vault_add_secret` | Store a value you gave the agent. The value lands in the transcript. |
| `vault_import_env` | Pull an existing `.env` or JSON file into the vault; values never surface. |
| `vault_update_meta` | Change env var name, notes, tags, environment or expiry. |
| `vault_create_container` | Make a group, account or project by hand. |
| `vault_link` / `vault_unlink` | Let a project use a shared key without copying it. |
| `vault_delete` | Remove a secret or a whole container. Needs `confirm`. |
| `vault_unlock` / `vault_lock` | Unlock protected secrets for 15 minutes. |
**Backups**
| Tool | What it does |
|---|---|
| `vault_backup` | Encrypted local snapshot, sealed with this machine's key. |
| `vault_list_backups` | What backups exist, read from their headers alone. |
| `vault_backup_info` | Inspect one backup without decrypting it. |
| `vault_restore` | Put a local backup back. Takes a safety snapshot first. |
| `vault_delete_backup` | Remove a backup file. |
Plus one resource, `vault://catalog`: every secret as metadata in a single document.
### Placeholders
For anything that is not a `.env`, write a placeholder and let the vault fill it:
```yaml
# docker-compose.yml
environment:
STRIPE_KEY: "{{vault:stripe/acme/secret_key}}"
SA_JSON: "{{vault:firebase/prod/service_account|base64}}"
```
```bash
vault_render_file { filePath: "docker-compose.yml" }
```
Transforms: `|base64`, `|json`, `|uri`.
### Running commands
```
vault_run {
command: "npx",
args: ["wrangler", "secret", "put", "STRIPE_KEY"],
secrets: [{ path: "stripe/acme/secret_key", varName: "STRIPE_KEY" }],
stdinSecret: "stripe/acme/secret_key"
}
```
The child process gets the real value. Every occurrence of it — plain, base64, or URL-encoded — is stripped
from the stdout and stderr you get back.
## How the keys are protected
- The vault is one AES-256-GCM encrypted file at `~/.vault-mcp/vault.enc`.
- The master key lives in your **OS keychain**: Windows Credential Manager or macOS Keychain, via
`@napi-rs/keyring`. Nothing to type, nothing to remember, and another user account on the same machine
cannot read it.
- Headless or CI: set `VAULT_MASTER_PASSWORD` and the key is derived from it instead.
- **Access code (optional).** `vault set-pin` sets a 4-12 digit code. It does two jobs: it unlocks the web UI,
and it guards individual secrets marked `--protected`. Secrets added with `--protected` are sealed
with `sha256(masterKey || scrypt(pin))`, so they need both the keychain **and** the PIN. Give the PIN to
the agent when it needs one; it unlocks for 15 minutes. Five wrong tries locks it for ten.
The PIN is a second gate on top of the keychain, not the primary encryption — a 4-digit PIN alone would be
trivial to brute-force, which is why the master key is doing the real work.
### Rules the code enforces
1. No MCP tool result contains a secret value. A test drives all 29 tools and scans every response for known
plaintexts, including base64 and URL-encoded forms.
2. `vault_run` redacts secret values from command output.
3. Injecting into a git-tracked file, or an un-ignored file inside a repo, is refused unless you pass `force`.
`addToGitignore: true` fixes it properly instead.
4. Nothing may be written inside `~/.vault-mcp` itself.
5. Every read and injection is appended to `~/.vault-mcp/audit.log` — paths and destinations, never values.
6. The web UI is loopback-only and needs a token generated at startup. When an access code is set, the page
also asks for it before showing anything, and five wrong tries lock it for ten minutes.
7. `vault_open_ui` starts that server but is not given the token, so the agent cannot read the page it opened.
8. A portable backup refuses a passphrase under 10 characters, and no tool accepts a passphrase as an argument.
## Backups
Everything in the vault, in one encrypted file. Two kinds, and the difference matters.
| | Sealed with | Restores on | Use it for |
|---|---|---|---|
| **Local** | this machine's master key | this machine only | Undoing a bad delete. No passphrase to remember. |
| **Portable** | a passphrase you type | any machine | A drive, cloud storage, moving Windows to Mac. |
```bash
vault backup # portable: asks for a passphrase (10+ characters)
vault backup --local # local snapshot, no passphrase
vault backups # list what you have
vault restore <file> # merge into the vault
vault restore <file> --replace # make the vault match the backup exactly
```
Or use the **Backup** panel in `vault ui`, which creates the file and hands it straight to your browser's
downloads so you can save it to a drive. The passphrase is typed in the page and never leaves it.
The agent can take local snapshots itself (`vault_backup`) and does so automatically before a restore or a
delete from the UI. It cannot make a portable backup, because that needs a passphrase and a passphrase must
never be typed into a conversation.
A few things worth knowing:
- It is **one encrypted file**, not a zip of the vault folder. A zip would reveal how many keys you have and
what they are called; this reveals only a date and a count.
- Copying `vault.enc` on its own to another machine will not work — the master key lives in that machine's
keychain. Portable backups exist precisely for this.
- The file carries a checksum, so a truncated or tampered copy is refused rather than half-restored.
- A restore always takes a safety snapshot first, so even `--replace` is undoable.
- Lose the passphrase and the portable backup is gone. There is no recovery path, by design.
## Commands
```
vault init Create the vault and master key
vault setup Guided first run
vault add <path> Add a secret or a whole service preset
vault ls [path] List what is stored
vault info <path> Show one secret's metadata (--preview for a mask)
vault get <path> Print a value to stdout (for piping)
vault rm <path> Delete (-r for a container)
vault import <file> --to Import a .env or JSON file
vault link <project> <path> Link a shared secret into a project
vault inject --project ... Write secrets into a local .env
vault set-pin Set the access code (web UI + protected secrets)
vault backup [--local] Portable backup with a passphrase, or a local snapshot
vault restore <file> Restore a backup (--replace to mirror it exactly)
vault backups List backups on this machine
vault export / import-bundle Older bundle format; backup/restore supersedes it
vault ui Local web UI
vault ui-url / ui-stop Link to, or stop, a UI an agent started
vault serve Run the MCP server (what your client launches)
vault install Register with Cursor, Windsurf, Antigravity, Claude…
vault status / audit / presets
```
## Where things live
| | |
|---|---|
| MCP server name | `vault` (tools appear as `vault_*`) |
| Registered by | `vault install --client <id>`, or the standard `mcpServers` JSON block |
| CLI command | `vault` after `npm link`, otherwise `node dist/cli.js` |
| Vault directory | `~/.vault-mcp` (override with `VAULT_MCP_DIR`) |
| Encrypted secrets | `~/.vault-mcp/vault.enc` |
| Backups | `~/.vault-mcp/backups/*.vaultbak` |
| Activity log | `~/.vault-mcp/audit.log` |
| Master key | Windows Credential Manager / macOS Keychain, service `vault-mcp` |
| Web UI | `http://127.0.0.1:5177`, loopback only, token plus access code |
## Development
```bash
npm run build # tsup -> dist/
npm test # vitest, 83 tests
npm run typecheck
```
Layout:
```
src/core/ crypto, keychain, store, envfile, importer, template, presets, git guards, audit, backup
src/mcp/ server + tools (read, inject, run, manage, ssh, backup, open-ui)
src/cli/ commander CLI
src/ui/ loopback web UI with the access-code lock screen
test/ core, vault, backup, git-guard, and the no-leak suite
```
## How much fits
There is no hard limit. Measured on this machine:
| Secrets | Vault file | Search | Read one |
|---|---|---|---|
| 100 | 0.09 MB | 11 ms | 2 ms |
| 1,000 | 0.91 MB | 11 ms | 45 ms |
| 2,000 | 1.83 MB | 5 ms | 21 ms |
Thousands of keys are fine. A single value can be as large as you like within reason — a PEM private key or a
Firebase service-account JSON is normal. The whole vault is held in memory while the server runs, so this is
built for a person's keys, not for a company-wide store.
## Known limits
- `vault get` prints a value by design; do not run it on a shared screen.
- The web UI can reveal values. It is local-only, token-gated and code-gated, but it is a deliberate hole in
the "never show a value" rule, for the human, not for the agent.
- The audit log is append-only but not tamper-proof; anything that can read the vault file can also rewrite it.
- The "the agent never sees a value" property covers the MCP tool surface. It keeps keys out of your
transcript; it is not a sandbox against an agent that also has shell access to your machine.
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessNo issues