Guide · MCP Secrets Management Integration
MCP Server HashiCorp Vault — read secrets, manage leases, and AppRole auth
HashiCorp Vault is the standard secrets management platform for cloud-native infrastructure. This guide covers building TypeScript MCP tools that interact with Vault's HTTP API: reading and writing KV v2 secrets with proper data wrappers, listing secret paths without reading values, AppRole authentication for service accounts, token renewal, and a /health/vault endpoint so AliveMCP detects Vault seal events and token expiry before your agent workflows lose access to credentials.
TL;DR
Use Vault's HTTP API directly with X-Vault-Token header — avoid the node-vault npm package (unmaintained). KV v2 paths use a /data/ prefix for reads/writes and /metadata/ for listing and deleting — this trips up most first-time integrations. Never log secret values; only log the path and key names. Use AppRole auth (role_id + secret_id → token) for production service accounts. Wire AliveMCP to GET /v1/sys/health — Vault's health endpoint returns distinct HTTP status codes for sealed, standby, and degraded states.
HTTP client setup and KV v2 path conventions
Vault's API is a REST interface over HTTPS. Authenticate with the X-Vault-Token header. The KV v2 secrets engine (the default in modern Vault installations) stores secrets under a /data/ sub-path, wrapping the actual secret in a data envelope. This is the most common source of confusion for developers coming from KV v1.
import axios, { AxiosInstance } from 'axios';
const VAULT_ADDR = process.env.VAULT_ADDR ?? 'http://127.0.0.1:8200';
const VAULT_TOKEN = process.env.VAULT_TOKEN!; // initial token; rotated via AppRole in prod
const VAULT_NAMESPACE = process.env.VAULT_NAMESPACE; // Vault Enterprise only; omit for OSS
const vaultHttp: AxiosInstance = axios.create({
baseURL: `${VAULT_ADDR}/v1`,
headers: {
'X-Vault-Token': VAULT_TOKEN,
...(VAULT_NAMESPACE ? { 'X-Vault-Namespace': VAULT_NAMESPACE } : {})
},
timeout: 10_000
});
// KV v2 path conventions:
// Mount: 'secret' (default) — configurable per Vault deployment
// Read/write: /secret/data/:path (wraps secret in { data: {...} })
// List keys: /secret/metadata/:path (returns only key names, not values)
// Delete: /secret/data/:path with { versions: [...] }
// Permanent delete: /secret/metadata/:path (deletes all versions)
const KV_MOUNT = process.env.VAULT_KV_MOUNT ?? 'secret';
| Operation | KV v1 path | KV v2 path |
|---|---|---|
| Read secret | /secret/:path |
/secret/data/:path |
| Write secret | PUT /secret/:path |
POST /secret/data/:path with {"data": {...}} |
| List keys | LIST /secret/:path/ |
LIST /secret/metadata/:path |
| Delete | DELETE /secret/:path |
DELETE /secret/data/:path with versions array |
read_secret and list_secrets tools
import { z } from 'zod';
import { McpError, ErrorCode } from '@modelcontextprotocol/sdk/types.js';
server.tool(
'read_vault_secret',
{
path: z.string().min(1), // e.g. 'myapp/database' (without mount prefix)
key: z.string().optional(), // return only this key from the secret; omit for all keys
version: z.number().int().positive().optional() // omit for latest version
},
async ({ path, key, version }) => {
try {
const params = version ? { version } : {};
const res = await vaultHttp.get(`/${KV_MOUNT}/data/${path}`, { params });
// KV v2 wraps the secret under .data.data
const secretData: Record = res.data.data?.data ?? {};
const metadata = {
version: res.data.data?.metadata?.version,
created_time: res.data.data?.metadata?.created_time,
deletion_time: res.data.data?.metadata?.deletion_time || null
};
// Return only requested key, or list of key names (never all values blindly)
if (key) {
if (!(key in secretData)) {
throw new McpError(ErrorCode.InvalidParams, `Key '${key}' not found in secret at ${path}`);
}
return {
content: [{
type: 'text',
text: JSON.stringify({
path,
key,
value: secretData[key],
metadata
}, null, 2)
}]
};
}
// Without a key, return the key names only — not the values
// This lets callers discover what keys exist without exposing all secrets
return {
content: [{
type: 'text',
text: JSON.stringify({
path,
keys: Object.keys(secretData),
metadata,
note: 'Specify a key parameter to retrieve a specific value'
}, null, 2)
}]
};
} catch (err) {
const axErr = err as { response?: { status?: number } };
if (axErr.response?.status === 404) {
throw new McpError(ErrorCode.InvalidParams, `Secret not found at path: ${path}`);
}
if (axErr.response?.status === 403) {
throw new McpError(ErrorCode.InvalidRequest, `Access denied to secret at: ${path}`);
}
throw err;
}
}
);
server.tool(
'list_vault_secrets',
{
path: z.string().default('') // directory path; '' lists the top level
},
async ({ path }) => {
try {
// LIST method via axios — note LIST is not a standard axios shorthand
const res = await vaultHttp.request({
method: 'LIST',
url: `/${KV_MOUNT}/metadata/${path}`
});
return {
content: [{
type: 'text',
text: JSON.stringify({
path: path || '(root)',
keys: res.data.data?.keys ?? [],
count: res.data.data?.keys?.length ?? 0
}, null, 2)
}]
};
} catch (err) {
const axErr = err as { response?: { status?: number } };
if (axErr.response?.status === 404) {
return {
content: [{ type: 'text', text: JSON.stringify({ path, keys: [], count: 0 }) }]
};
}
throw err;
}
}
);
The read_vault_secret tool above returns only key names by default (not values) — requiring an explicit key parameter to retrieve a value. This is an intentional design decision: returning all secret values to a calling agent exposes the entire secret bundle in one tool response, which may be logged or stored by the agent framework. Requiring explicit key selection limits the blast radius of each tool call.
write_secret and delete_secret tools
server.tool(
'write_vault_secret',
{
path: z.string().min(1),
data: z.record(z.string()), // key-value pairs to store
confirm: z.literal(true)
},
async ({ path, data }) => {
// KV v2 write requires { data: { ...yourData } } wrapper
const res = await vaultHttp.post(`/${KV_MOUNT}/data/${path}`, { data });
return {
content: [{
type: 'text',
text: JSON.stringify({
path,
version: res.data.data?.version,
created_time: res.data.data?.created_time
}, null, 2)
}]
};
}
);
server.tool(
'delete_vault_secret_versions',
{
path: z.string().min(1),
versions: z.array(z.number().int().positive()).min(1),
confirm: z.literal(true)
},
async ({ path, versions }) => {
// Soft-delete specific versions (recoverable with undelete)
await vaultHttp.post(`/${KV_MOUNT}/delete/${path}`, { versions });
return {
content: [{
type: 'text',
text: JSON.stringify({
deleted: true,
path,
versions,
note: 'Soft-deleted; recoverable via undelete'
})
}]
};
}
);
AppRole authentication for service accounts
Production MCP servers should authenticate with Vault using AppRole, not a static token. AppRole uses a role_id (public, like a username) and a secret_id (private, like a one-time password) to obtain a short-lived token. The token renews itself automatically while the MCP server is running.
interface VaultToken {
token: string;
leaseDuration: number; // seconds
expiresAt: number; // Date.now() + leaseDuration * 1000
}
let currentToken: VaultToken | null = null;
async function getValidToken(): Promise {
// Renew if token has less than 30 seconds remaining
if (currentToken && currentToken.expiresAt - Date.now() > 30_000) {
return currentToken.token;
}
// Login via AppRole
const res = await axios.post(`${VAULT_ADDR}/v1/auth/approle/login`, {
role_id: process.env.VAULT_ROLE_ID!,
secret_id: process.env.VAULT_SECRET_ID!
});
const auth = res.data.auth;
currentToken = {
token: auth.client_token,
leaseDuration: auth.lease_duration,
expiresAt: Date.now() + auth.lease_duration * 1000
};
// Update the axios instance's default header with the new token
vaultHttp.defaults.headers['X-Vault-Token'] = currentToken.token;
return currentToken.token;
}
// Call getValidToken() at the start of each tool handler
// that talks to Vault — ensures the token is always fresh
The secret_id is single-use by default. Vault generates a new one each time you log in with the role, so the production pattern is: store role_id permanently (it's public) and rotate secret_id periodically from your secrets bootstrapping system. Vault's secret_id_num_uses and secret_id_ttl role parameters control how long a secret_id can be used — set them appropriately for your security policy.
Health endpoint: /health/vault
Vault's own GET /v1/sys/health endpoint is the canonical health check. It returns distinct HTTP status codes for different Vault states — not just 200/503. AliveMCP treats any non-200 response as a downtime event; you should understand what each status means.
import express from 'express';
const app = express();
app.get('/health/vault', async (_req, res) => {
const start = Date.now();
try {
// Vault's health endpoint does NOT require authentication
const vaultHealth = await axios.get(`${VAULT_ADDR}/v1/sys/health`, {
validateStatus: () => true // don't throw on non-2xx — Vault uses 429, 472, 473, 501, 503
});
const status = vaultHealth.status;
const body = vaultHealth.data;
res.status(status === 200 ? 200 : 503).json({
status: status === 200 ? 'ok' : 'degraded',
vault_http_status: status,
vault_status: body.sealed ? 'sealed' : (body.standby ? 'standby' : 'active'),
version: body.version,
initialized: body.initialized,
sealed: body.sealed,
latency_ms: Date.now() - start
});
} catch (err) {
res.status(503).json({
status: 'error',
error: (err as Error).message,
latency_ms: Date.now() - start
});
}
});
app.listen(3001);
| Vault /sys/health status | Meaning | Action |
|---|---|---|
| 200 | Initialized, unsealed, active | Normal operation |
| 429 | Unsealed, standby (HA replica) | Reads work; writes redirect to active node |
| 472 | Data Recovery Mode (Disaster Recovery) | DR mode — limited API available |
| 473 | Performance standby | Reads work; writes redirect |
| 501 | Not initialized | Vault needs to be initialized (first-time setup) |
| 503 | Sealed | Unseal required before any secrets are accessible |
Frequently asked questions
How do I know if my Vault installation uses KV v1 or KV v2?
Check GET /v1/sys/mounts (requires admin token) — look for your mount name (usually secret/) and inspect the options.version field. Version "2" = KV v2. If you're unsure, try a KV v2 read (/secret/data/mypath) and a KV v1 read (/secret/mypath) — one will return data, the other a 404. Modern Vault installations (1.0+) default to KV v2 for new mounts. Older mounts may still be KV v1. You can check from the CLI with vault secrets list -detailed and look for the Version column.
How do I prevent Vault tokens from expiring while the MCP server runs?
If your token has a TTL, renew it proactively. Call POST /v1/auth/token/renew-self when the token has less than 20% of its TTL remaining. The getValidToken() pattern above with a 30-second renewal threshold handles this for AppRole tokens. For static tokens (dev/test), use a token with period set (Vault Enterprise) or set an explicit renewal schedule. For production: always use AppRole or Kubernetes auth — static tokens are a security anti-pattern for service accounts.
Should I use the node-vault npm package?
No — node-vault is effectively unmaintained (last release 2021) and lacks KV v2 support in its high-level API. Use axios or node-fetch against Vault's HTTP API directly. The API is well-documented at https://developer.hashicorp.com/vault/api-docs and is stable — you won't need an SDK abstraction layer. The raw HTTP approach also makes it easier to handle Vault Enterprise namespaces, custom mount paths, and the distinct error codes that node-vault often obscures.
How do I handle Vault Enterprise namespaces?
Pass the namespace in the X-Vault-Namespace HTTP header. The namespace must match the Vault Enterprise namespace path exactly (case-sensitive). Sub-namespaces are expressed as path segments: parent/child. Set this in the axios instance headers alongside the token header, as shown in the setup section above. If you omit the namespace header, requests go to the root namespace — which may return 404s or 403s depending on your ACL policies, making it appear as though secrets don't exist when they're actually in a child namespace.