Skip to main content
Workflows Library MCP Directory Realtime AI News Sponsor Tier 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

CEO, SaaSNext

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

CEO, SaaSNext

Deepak Bagada is the CEO of SaaSNext and founder of Daily AI World. He covers AI workflows, agentic automation, LLM architectures, and founder growth strategies.

Related Intelligence Analysis

Briefing AI Tools

Vercel AI SDK Tool Calling React: 5 Steps (2026)

Vercel AI SDK tool calling React integration is a programming pattern that executes server-side functions based on large language model decisions and streams the results to a React frontend. By combining streamText with...

Deepak Bagada Deepak Bagada
12m read
Breaking AI Tools

Fact-Density vs. Word Count: The New SEO for 2026

Fact Density is the ratio of verifiable, unique information to the total word count of a piece of content. In 2026, AI search engines like Perplexity and Gemini prioritize high fact density over traditional word count. A...

Deepak Bagada Deepak Bagada
4m read
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