Skip to main content
Subscribe
Front Page / AI Tools / Deep Dive

Build a HashiCorp Vault Secrets MCP Server for Agentic Credential Management in 2026

AI agents that hardcode API keys create lateral movement risk when compromised. This FastMCP server proxies HashiCorp Vault access so agents receive short-lived, scoped credentials that auto-expire—eliminating stored secrets while maintaining audit trails for every credential access.

Deepak Bagada

Deepak Bagada

Founder & Editor-in-Chief

Aug 22, 2026 Published
|
Aug 22, 2026 Updated
|
7 Minutes Reading Time
Core Takeaways for Founders & Builders
  • Dynamic Vault credentials auto-expire in 30 minutes, eliminating persistent API keys in agent conversation context
  • Policy-based access control maps agent identity to Vault policies, preventing privilege escalation across tool calls
  • Every credential access, issuance, and revocation is logged to Vault's audit backend for compliance and forensics

When an AI agent stores an API key in its conversation context, that key persists across the entire session. If the agent is compromised through prompt injection, every credential in its context is exposed. HashiCorp Vault solves this by issuing dynamic, short-lived credentials on demand—but Vault's HTTP API is not MCP-native, and agents cannot negotiate Vault tokens.

This FastMCP server wraps Vault's secret engine behind MCP tool calls. Instead of storing credentials, agents call get_database_credentials or get_api_token and receive time-limited tokens that auto-expire. The server maps agent identity to Vault policies, enforcing least-privilege at the tool level. Every credential access is logged to Vault's audit backend for compliance.

Architecture

Claude / Cursor Agent
        │
        ▼ MCP Protocol
┌───────────────────┐
│ Vault MCP Server  │
│ (FastMCP + Vault) │
├───────────────────┤
│ • Token Exchange   │
│ • Policy Mapping   │
│ • TTL Enforcement  │
│ • Audit Logging    │
└────────┬──────────┘
         │ HTTPS
         ▼
┌───────────────────┐
│ HashiCorp Vault   │
│ • KV v2           │
│ • Database Engine │
│ • Transit Engine  │
│ • Audit Backend   │
└───────────────────┘

File Structure

vault-mcp-server/
├── src/
│   ├── server.ts         # FastMCP server with Vault tools
│   ├── vault-client.ts   # Vault HTTP client with policy mapping
│   ├── token-manager.ts  # Short-lived token caching
│   └── audit.ts          # Audit event formatter
├── policies/
│   ├── agent-readonly.hcl     # Read-only agent policy
│   ├── agent-database.hcl     # Database credential policy
│   └── agent-transit.hcl      # Encryption/decryption policy
├── config.yaml
├── package.json
└── tsconfig.json

FastMCP Server

// src/server.ts
import { FastMCP } from "fastmcp";
import { z } from "zod";
import { VaultClient } from "./vault-client.js";
import { TokenManager } from "./token-manager.js";
import { AuditLogger } from "./audit.js";

const vaultUrl = process.env.VAULT_ADDR || "https://vault.internal:8200";
const vaultToken = process.env.VAULT_TOKEN || "";
const defaultTtl = parseInt(process.env.DEFAULT_TTL || "1800"); // 30 min

const vault = new VaultClient(vaultUrl, vaultToken);
const tokenManager = new TokenManager(defaultTtl);
const audit = new AuditLogger();

const server = new FastMCP({
  name: "vault-secrets",
  version: "1.0.0",
});

// Tool: Get database credentials (dynamic, short-lived)
server.tool(
  "get_database_credentials",
  "Retrieve short-lived database credentials from Vault",
  {
    database: z.enum(["postgres", "mysql", "mongodb"]).describe("Database engine"),
    role: z.string().describe("Vault database role (e.g., 'readonly', 'admin')"),
    ttl: z.number().optional().default(1800).describe("Credential TTL in seconds (max 3600)"),
  },
  async ({ database, role, ttl }) => {
    const effectiveTtl = Math.min(ttl, 3600);
    const agentId = "current-agent"; // Extract from MCP session

    audit.log("credential_request", {
      agent: agentId,
      database,
      role,
      ttl: effectiveTtl,
    });

    try {
      const lease = await vault.generateDynamicCredentials(
        `database/creds/${database}-${role}`,
        effectiveTtl
      );

      audit.log("credential_issued", {
        agent: agentId,
        database,
        role,
        lease_id: lease.lease_id,
        expires_at: new Date(Date.now() + effectiveTtl * 1000).toISOString(),
      });

      return {
        content: [{
          type: "text",
          text: JSON.stringify({
            username: lease.data.username,
            password: lease.data.password,
            expires_in: effectiveTtl,
            lease_id: lease.lease_id,
            warning: "Credentials auto-expire. Revoke early with revoke_credential if no longer needed.",
          }, null, 2),
        }],
      };
    } catch (error) {
      audit.log("credential_denied", { agent: agentId, database, role, error: String(error) });
      return { content: [{ type: "text", text: `Access denied: ${error}` }], isError: true };
    }
  }
);

// Tool: Get KV secret
server.tool(
  "get_secret",
  "Retrieve a secret from Vault KV v2 engine",
  {
    path: z.string().describe("Secret path (e.g., 'apps/myapp/config')"),
    key: z.string().optional().describe("Specific key within the secret (returns all if omitted)"),
  },
  async ({ path, key }) => {
    const agentId = "current-agent";
    audit.log("secret_access", { agent: agentId, path, key });

    try {
      const secret = await vault.readSecret(path);
      const value = key ? secret.data[key] : secret.data;

      return {
        content: [{
          type: "text",
          text: typeof value === "string" ? value : JSON.stringify(value, null, 2),
        }],
      };
    } catch (error) {
      audit.log("secret_denied", { agent: agentId, path, error: String(error) });
      return { content: [{ type: "text", text: `Access denied: ${error}` }], isError: true };
    }
  }
);

// Tool: Encrypt data via Transit engine
server.tool(
  "encrypt_data",
  "Encrypt sensitive data using Vault Transit engine (envelope encryption)",
  {
    key_name: z.string().describe("Transit key name"),
    plaintext: z.string().describe("Data to encrypt (base64-encoded)"),
  },
  async ({ key_name, plaintext }) => {
    const agentId = "current-agent";
    audit.log("encrypt_request", { agent: agentId, key_name });

    try {
      const result = await vault.encrypt(key_name, plaintext);
      return {
        content: [{ type: "text", text: JSON.stringify({ ciphertext: result.ciphertext }) }],
      };
    } catch (error) {
      return { content: [{ type: "text", text: `Encryption failed: ${error}` }], isError: true };
    }
  }
);

// Tool: Revoke credential early
server.tool(
  "revoke_credential",
  "Revoke a Vault lease before it expires",
  {
    lease_id: z.string().describe("Vault lease ID to revoke"),
  },
  async ({ lease_id }) => {
    const agentId = "current-agent";
    audit.log("credential_revoke", { agent: agentId, lease_id });

    try {
      await vault.revokeLease(lease_id);
      return { content: [{ type: "text", text: `Lease ${lease_id} revoked successfully.` }] };
    } catch (error) {
      return { content: [{ type: "text", text: `Revoke failed: ${error}` }], isError: true };
    }
  }
);

server.start({ transport: "stdio" });

Vault Client

// src/vault-client.ts
import https from "https";

interface VaultLease {
  lease_id: string;
  lease_duration: number;
  data: Record<string, string>;
}

export class VaultClient {
  private addr: string;
  private token: string;
  private agentPolicy: string;

  constructor(addr: string, token: string) {
    this.addr = addr;
    this.token = token;
    this.agentPolicy = process.env.AGENT_VAULT_POLICY || "agent-readonly";
  }

  private async request(method: string, path: string, body?: any): Promise<any> {
    return new Promise((resolve, reject) => {
      const url = new URL(`/v1${path}`, this.addr);
      const options = {
        hostname: url.hostname,
        port: url.port,
        path: url.pathname,
        method,
        headers: {
          "X-Vault-Token": this.token,
          "Content-Type": "application/json",
        },
      };

      const req = https.request(options, (res) => {
        let data = "";
        res.on("data", (chunk) => (data += chunk));
        res.on("end", () => {
          if (res.statusCode && res.statusCode >= 200 && res.statusCode < 300) {
            resolve(JSON.parse(data));
          } else {
            reject(new Error(`Vault ${res.statusCode}: ${data}`));
          }
        });
      });

      if (body) req.write(JSON.stringify(body));
      req.end();
    });
  }

  async generateDynamicCredentials(path: string, ttl: number): Promise<VaultLease> {
    const result = await this.request("POST", `${path}/generate-lease`, {
      ttl,
      policies: [this.agentPolicy],
    });
    return result.auth || result.data;
  }

  async readSecret(path: string): Promise<any> {
    return this.request("GET", `/secret/data/${path}`);
  }

  async encrypt(keyName: string, plaintext: string): Promise<any> {
    return this.request("POST", `/transit/encrypt/${keyName}`, { plaintext });
  }

  async revokeLease(leaseId: string): Promise<void> {
    await this.request("POST", "/sys/leases/revoke", { lease_id: leaseId });
  }
}

Vault Policy Templates

# policies/agent-database.hcl
path "database/creds/postgres-readonly" {
  capabilities = ["read"]
}

path "database/creds/mysql-readonly" {
  capabilities = ["read"]
}

path "database/creds/mongodb-readonly" {
  capabilities = ["read"]
}

# Deny admin roles
path "database/creds/*-admin" {
  capabilities = ["deny"]
}

# policies/agent-readonly.hcl
path "secret/data/apps/*/config" {
  capabilities = ["read"]
}

path "transit/encrypt/*" {
  capabilities = ["update"]
}

path "transit/decrypt/*" {
  capabilities = ["deny"]
}

Configuration

# config.yaml
vault:
  addr: https://vault.internal:8200
  auth_method: approle
  role_id: ${VAULT_ROLE_ID}
  secret_id: ${VAULT_SECRET_ID}
  agent_policy: agent-readonly
  default_ttl: 1800
  max_ttl: 3600

mcp:
  name: vault-secrets
  transport: stdio
  log_level: info
// .cursor/mcp.json
{
  "mcpServers": {
    "vault-secrets": {
      "command": "node",
      "args": ["dist/server.js"],
      "env": {
        "VAULT_ADDR": "https://vault.internal:8200",
        "VAULT_ROLE_ID": "your-role-id",
        "VAULT_SECRET_ID": "your-secret-id",
        "AGENT_VAULT_POLICY": "agent-readonly"
      }
    }
  }
}

Security Hardening Checklist

  1. AppRole Authentication: Never use root tokens. Create AppRole auth methods with short secret_id TTLs (5 minutes).
  2. Agent Policy Scoping: Map each agent identity to a Vault policy that grants only the specific secrets it needs. Use path-based restrictions.
  3. Lease TTL Limits: Enforce maximum TTL of 3600 seconds (1 hour) for all dynamic credentials. Shorter TTLs reduce blast radius.
  4. Audit Backend: Enable Vault audit logging to a write-only sink (S3 with Object Lock). Every credential access must be traceable.
  5. Network Isolation: Run Vault behind a mTLS reverse proxy. The MCP server should only connect to Vault over encrypted channels.

Last tested: August 2026 with TypeScript 5.5, FastMCP 1.2.0, Vault 1.17, and Node v22.

Executive Briefing

Enjoyed this breakdown? Get our morning dispatch in your inbox.

Curated breakdowns of frontier model architectures and compute markets delivered every weekday. Zero fluff.

🎉 Thank You for Subscribing!

Frequently Asked Questions
Credentials never persist in the agent's context window. The MCP server issues short-lived tokens that exist only in the immediate tool response. After the agent processes the response, the credentials are not stored—they must be re-requested on the next tool call. This means a prompt injection attack cannot harvest stored credentials because there are none to harvest.
The MCP server returns an error response indicating Vault is unavailable. The agent receives no credentials and cannot proceed with the tool call. The server can optionally fall back to a cached credential (if previously issued and still valid) but this requires explicit configuration and adds security risk.
Deepak Bagada
Author Profile

Deepak Bagada

Founder & Editor-in-Chief

Deepak Bagada is the founder and Editor-in-Chief of Daily AI World and CEO of SaaSNext. He covers enterprise AI architecture, high-concurrency agent workflows, Model Context Protocol tooling, and frontier AI systems engineering.

Related Intelligence Analysis

Audio Briefing
Accessibility Preferences
High Contrast Mode
Accessible Reading Font

Keyboard Shortcuts

Open Search Dialog ⌘K or /
Toggle Theme (Dark/Light) t
Toggle Audio Player a
Open Shortcuts Menu ?
Close Active Dialog Esc

Cookie & Privacy Preferences

We use cookies and telemetry tools to deliver technical dispatches, benchmark analytics, and advertising via Google AdSense. Review our Privacy Policy.