Skip to content

Configuration

keyholder-mcp has no config file and no flags besides -version. Everything comes from environment variables set by the MCP client that starts it.

Environment variables

Variable Default Meaning
BAO_ADDR https://127.0.0.1:8200 (OpenBao client default) Vault address
BAO_WRAPPING_TOKEN — Wrapped, single-use token. Preferred. Unwrapped once at startup
BAO_TOKEN — Plain token. Accepted for local testing; prefer the wrapped token
KEYHOLDER_KV_MOUNT secret KV v2 mount that holds the secrets
KEYHOLDER_SCOPE empty (everything) Path prefix of the project. References outside it are refused and not listed. Leaving it empty logs a warning
KEYHOLDER_ALLOW empty (nothing) Commands the agent may run, comma separated, by exact name: curl,aws,gcloud
KEYHOLDER_DYNAMIC empty (none) OpenBao paths of dynamic secrets the agent may use, comma separated, by exact path: aws/creds/deploy,gcp/roleset/viewer/token
KEYHOLDER_TIMEOUT 5m Maximum time per run (Go duration: 30s, 2m)

TLS and other client settings follow the OpenBao CLI variables read by the official Go client (BAO_CACERT, BAO_CLIENT_CERT, BAO_CLIENT_KEY, BAO_NAMESPACE...). VAULT_* names are accepted as fallbacks by that client.

At startup the server removes BAO_WRAPPING_TOKEN, BAO_TOKEN and VAULT_TOKEN from its own environment, and every child process gets an environment without BAO_*, VAULT_* and KEYHOLDER_*.

Never allow shells or interpreters

KEYHOLDER_ALLOW must not contain sh, bash, zsh, cmd, powershell, python, python3, node, env, printenv or anything that runs code it receives. With them the agent can print or encode a secret; see Masking and its limit.

References

A reference names one field of one secret in the KV:

vault://my-project/dev/API_TOKEN
        └─── path ───┘ └─ key ─┘
  • path: the secret path inside the mount (secret/my-project/dev → my-project/dev).
  • key: the field inside that secret.
  • Segments use A-Z a-z 0-9 _ . -; empty segments, . and .. are refused.
  • The value must be text.

In keyholder_run, a reference goes inside a placeholder, {{vault://path/KEY}}, anywhere inside an argument, an environment value or a file value. Several placeholders can appear in one string.

A dynamic reference names one field of a secret OpenBao creates when it is read, such as an AWS credential:

bao://aws/creds/deploy#access_key
      └── path ─────┘ └─ field ─┘

The path is relative to the OpenBao root, not to the KV mount, and must be listed in KEYHOLDER_DYNAMIC. Within one run each path is read once, so #access_key and #secret_key come from the same credential. See Dynamic credentials.

Tools

keyholder_status

No input. Output:

Field Meaning
addr Vault address
initialized, sealed, server_version From sys/health
kv_mount, scope The configuration in use
token_ttl_seconds, token_expire_time Remaining life of the session token
token_renewable, token_policies From the token lookup
session_error Why there is no usable session: no token, unwrap refused, expired, or no permission to look itself up

Never returns the token or its accessor.

keyholder_list

No input. Output: scope, entries (each with ref and version), dynamic (each allowed dynamic path with a use hint, for example credentials: ["aws:aws/creds/deploy"]; listing never reads it) and a note for the agent. It walks the scope (up to 500 entries), reading each secret to learn its field names and discarding the values.

keyholder_run

Input:

Field Type Meaning
command string Name of an allowed command. No path, no placeholder
args list of strings Arguments; may contain {{vault://...}} and {{bao://...}}
env map Extra variables for the child; values may contain placeholders. Names BAO_*, VAULT_*, KEYHOLDER_* are refused
files map Variables that receive the path of a temporary file holding the value (mode 0600, deleted after the run), for tools that read a credential from a file
credentials list of strings Cloud credentials set the way each CLI expects: aws:<path> or gcp:<path>. See Dynamic credentials

Output:

Field Meaning
exit_code The child's exit code
stdout, stderr Up to 64 KiB each, with every resolved value replaced by [hidden:<reference>]
refs_used References resolved in this run
truncated Output went over 64 KiB
timed_out The run hit KEYHOLDER_TIMEOUT
notes What happened after the run, for example revoked 1 dynamic credential lease(s) after the run

The command runs without a shell: exec with the argument list, stdin from the null device. Pipes, redirections and $VAR are not interpreted, and special characters inside a secret are never treated as code.

Errors (not allowed, out of scope, missing reference, access denied) come back as tool errors with a message the agent can act on, without URLs or vault response bodies.

Dynamic credentials

With OpenBao's AWS and Google Cloud secrets plugins, a command can run with a cloud credential created for that run only.

credentials entry Reads Sets in the child
aws:<path>, for example aws:aws/creds/deploy access_key, secret_key, session_token AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, and AWS_SESSION_TOKEN when the credential type has one
gcp:<path>, for example gcp:gcp/roleset/viewer/token token CLOUDSDK_AUTH_ACCESS_TOKEN_FILE (a temporary file for gcloud) and GOOGLE_OAUTH_ACCESS_TOKEN

A preset also drops the inherited variables of that cloud (AWS_*; CLOUDSDK_AUTH_*, GOOGLE_OAUTH_ACCESS_TOKEN, GOOGLE_APPLICATION_CREDENTIALS), so the fresh credential never mixes with a profile or token from the server's environment.

When the command ends, Keyholder revokes every lease the run created. What that does depends on the credential:

Credential After the run
AWS iam_user Revoked: OpenBao deletes the IAM user and its key
AWS assumed_role, federation_token, session_token (STS) Cannot be revoked; valid until the TTL of the role (AWS minimum is 15 minutes)
GCP roleset or account access token Not leased by the plugin; valid until it expires (up to one hour)

Prefer iam_user roles when the credential must die with the command. A lease that cannot be revoked is reported in notes. Full walkthrough: Cloud credentials tutorial.

Policy

The example policy grants exactly what the three tools need for one project:

Path Capabilities Used by
secret/data/my-project/* read keyholder_run (resolve), keyholder_list (field names)
secret/metadata/my-project, .../* list, read keyholder_list
auth/token/lookup-self read keyholder_status (already in OpenBao's default policy)

For dynamic credentials, add a second policy like deploy/policy-cloud.hcl: read on each path in KEYHOLDER_DYNAMIC and update on sys/leases/revoke, which Keyholder uses to revoke after the run.

Without lookup-self, keyholder_status estimates the TTL from the unwrap response and says so in session_error.

Audit trail

Each keyholder_run writes one line to the server's stderr, which MCP clients keep in their server logs:

2026/10/13 22:35:40 audit cmd="curl" refs=[vault://my-project/dev/API_TOKEN] exit=0 dur=8ms status=ok

It records the command name, the references, the exit code, the duration and the result. Never values, never arguments. OpenBao's own audit devices record every read on the vault side.

khcall

khcall is a minimal MCP client for debugging and for the tutorials:

khcall [-server keyholder-mcp] -tools
khcall [-server keyholder-mcp] <tool> ['<json arguments>'] [+wait=<duration>] [<tool> ...]

All calls run in one session (one server, one unwrap). +wait=7s pauses between calls. Exit status: 0 when every call succeeds, 1 when a tool reports an error, 2 on a usage or protocol error.