Skip to content

Architecture

Keyholder builds on OpenBao instead of a vault of its own. OpenBao stores and encrypts the secrets, issues short-lived tokens, keeps leases, enforces policies, offers TOTP login and a web UI. Keyholder adds the one missing piece: an MCP server and runner that use a credential for the agent without returning it to the agent.

Components

flowchart TB
    subgraph agentside ["Agent side"]
      client["MCP client<br/>(Claude Code, Cursor, Codex)"]
      kh["keyholder-mcp<br/>token in memory only"]
      child["allowed command<br/>(child process)"]
    end
    subgraph vaultside ["Vault side"]
      bao["OpenBao<br/>KV v2, tokens, leases, audit"]
    end
    human["You<br/>(web UI or bao CLI, TOTP)"]
    client -- "stdio: tools, names only" --> kh
    kh -- "HTTP: unwrap, read, list" --> bao
    kh -- "exec, no shell,<br/>clean env" --> child
    child --> ext["API / cloud"]
    human -- "store secrets,<br/>issue wrapped token" --> bao
Component Origin Role
OpenBao existing (openbao.org) Secrets, tokens with TTL, leases, policies, TOTP, web UI, audit
keyholder-mcp this project MCP tools, late binding, masking, allow list, scope
khcall this project Debugging client and tutorial driver

Access flow

sequenceDiagram
    actor You
    participant Bao as OpenBao
    participant KH as keyholder-mcp
    participant Agent
    You->>Bao: login (userpass/OIDC + TOTP)
    You->>Bao: token create -ttl -wrap-ttl
    Bao-->>You: wrapped token (single use)
    You->>KH: start with BAO_WRAPPING_TOKEN
    KH->>Bao: unwrap
    Bao-->>KH: client token (kept in memory)
    Agent->>KH: keyholder_run {{vault://...}}
    KH->>Bao: read field
    KH->>KH: exec command, mask output
    KH-->>Agent: masked output
    Note over KH,Bao: TTL ends: reads return 403,<br/>tools say "renew outside the chat"

Packages

Path Responsibility Depends on
cmd/keyholder-mcp Reads configuration, opens the session, registers the MCP tools everything below, MCP Go SDK
cmd/khcall Minimal MCP client MCP Go SDK
internal/runner Allow list, placeholder expansion, exec without shell, environment filter, limits ref, mask
internal/bao OpenBao client: unwrap, KV v2 read, listing, status, clean errors ref, OpenBao client
internal/ref vault://path/KEY and {{vault://...}} parsing, scope check standard library
internal/mask Replaces known values with [hidden:<ref>] standard library

internal/runner, internal/ref and internal/mask never import OpenBao or MCP. The runner receives a runner.Resolver function, and only cmd/keyholder-mcp wires bao.Store.Resolve into it. A new backend (another vault, dynamic cloud credentials) is a new resolver, not a change to the runner. The code review enforces this direction (solid#solid-d, see Code review).

Stack

Layer Choice Why
Language Go 1.25 One static binary per system; OpenBao itself is Go
MCP modelcontextprotocol/go-sdk v1.8.0 Official SDK, stdio transport
Vault client openbao/openbao/api/v2 v2.7.1 Official client: unwrap, KV v2, token lookup
Execution os/exec Argument list, no shell
Vault OpenBao, KV v2 Open source (MPL-2.0), Linux Foundation