---
title: Secret Injection
description: Inject tokens into MCP configs at sync time with ${kst_...} placeholders — never committed, never in the lock.
---

import { Callout } from 'fumadocs-ui/components/callout';

**When you need this:** an MCP server needs a token, password, or API key, and you don't want to commit it into the pack you share.

**What you'll learn:**

- How `${kst_...}` placeholders are written and resolved
- Where values come from, and how to pin a specific source
- What happens when a secret is missing, and how rotation works

## Placeholders

Reference a secret anywhere inside an MCP server definition — a header, an `env` value, a URL. At sync time Kasetto resolves it and writes the value into the agent's settings file. There are two forms.

The **chain form** `${kst_<name>}` resolves against an ordered fallback (env, then credentials):

```json
{
  "mcpServers": {
    "vercel": {
      "url": "https://mcp.vercel.com",
      "type": "http",
      "headers": {
        "Authorization": "Bearer ${kst_vercel_token}"
      }
    }
  }
}
```

The lowercase `kst_` (or `kst:`) sentinel is the opt-in. A bare `${HOME}` or `${PATH}` is left untouched, so placeholders the agent or shell expands at launch time pass straight through — Kasetto only resolves its own sentinel.

### Nested keys

In the chain form, a double underscore (`__`) descends into a nested credentials path. A single underscore stays within one segment.

| Placeholder              | `credentials.yaml` path |
| ------------------------ | ----------------------- |
| `${kst_vercel_token}`    | `kst_vercel_token` (flat) |
| `${kst_vercel__token}`   | `vercel.token` (nested) |
| `${kst_org__ci__token}`  | `org.ci.token` (nested) |

Keys match case-insensitively, so `vercel.token` resolves `${kst_vercel__token}`, and the chain also tries the env var uppercased — `${kst_vercel_token}` reads `KST_VERCEL_TOKEN` from the environment.

## The chain (env → credentials)

The chain form resolves against an ordered set of sources — the first that has the value wins:

1. **Environment variables** — the placeholder name as written, then uppercased, e.g. `${kst_vercel_token}` → `kst_vercel_token`, then `KST_VERCEL_TOKEN`.
2. **`credentials.yaml`** — `$XDG_CONFIG_HOME/kasetto/credentials.yaml` (`~/.config/kasetto/credentials.yaml`), plus any extra files listed in `secrets.files`.

Environment wins over the file, so one shared pack works in CI (env vars) and on a laptop (`credentials.yaml`) without editing.

```yaml
# ~/.config/kasetto/credentials.yaml — never commit this
kst_github_token: ghp_xxx        # flat key
vercel:
  token: xxxxxxxx                # nested → ${kst_vercel__token}
```

<Callout type="warn">

`credentials.yaml` holds plaintext secrets. Keep it out of version control and lock it down with `chmod 600` — Kasetto warns when the file is group- or world-readable.

</Callout>

## Pinning a source

When you want to bypass the fallback and name exactly one source, use the **tagged form** `${kst:<source>:<ref>}`. Each tag pins exactly one provider.

### `env` — a specific environment variable

```text
${kst:env:VERCEL_TOKEN}
```

Looks up `VERCEL_TOKEN` **verbatim** (no `kst_` prefix, no uppercasing) — point it at any env var you already export.

### `crd` — a credentials.yaml path

```text
${kst:crd:vercel/token}
```

Descends the `/`-separated path in `credentials.yaml` (`vercel.token`), case-insensitively. Unlike the chain form, this never falls back to the environment.

### `op` — 1Password

```text
${kst:op:<vault>/<item>/<field>}
${kst:op://<vault>/<item>/<field>}
```

Both forms run `op read op://<vault>/<item>/<field>` (the [1Password CLI](https://developer.1password.com/docs/cli/)). Example: `${kst:op:Private/GitHub/token}`.

### `vault` — HashiCorp Vault

```text
${kst:vault:<kv-path>#<field>}
```

Runs `vault kv get -field=<field> <kv-path>` (the [Vault CLI](https://developer.hashicorp.com/vault/docs/commands)). Example: `${kst:vault:secret/myapp#token}`.

### `kp` — KeePass

```text
${kst:kp:<entry>#<attr>}
```

Runs `keepassxc-cli show -s -a <attr> <database> <entry>` (the [KeePassXC CLI](https://keepassxc.org/docs/KeePassXC_UserGuide#_command_line_tool)). The attribute defaults to `Password` when `#<attr>` is omitted. Example: `${kst:kp:GitHub/PAT}` or `${kst:kp:GitHub/PAT#Token}`.

Unlike `op`/`vault`, KeePass has no ambient session, so it needs the database location in config:

```yaml
secrets:
  keepass:
    database: ~/secrets.kdbx
    # key_file: ~/secrets.keyx   # optional
```

The database is unlocked with a **key-file** (`secrets.keepass.key_file`, if set) and/or a **master password** read from the `KST_KEEPASS_PASSWORD` environment variable. With neither, `kst` adds `--no-password` and closes stdin, so the CLI fails fast instead of waiting on a prompt.

### `aws` — AWS Secrets Manager

```text
${kst:aws:<secret-id>#<json-key>}
```

Runs `aws secretsmanager get-secret-value` (the [AWS CLI](https://aws.amazon.com/cli/)) against your active profile/region. AWS secrets are often JSON, so an optional `#<json-key>` extracts a top-level field. Example: `${kst:aws:prod/db}` or `${kst:aws:prod/db#password}`.

### `gcp` — Google Cloud Secret Manager

```text
${kst:gcp:<name>#<json-key>}
```

Runs `gcloud secrets versions access latest --secret=<name>` (the [gcloud CLI](https://cloud.google.com/sdk/gcloud)) against the active project. An optional `#<json-key>` extracts a top-level field when the secret is a JSON document. Example: `${kst:gcp:db-password}` or `${kst:gcp:db#password}`.

### `az` — Azure Key Vault

```text
${kst:az:<vault>/<name>#<json-key>}
```

Runs `az keyvault secret show --vault-name <vault> --name <name>` (the [Azure CLI](https://learn.microsoft.com/cli/azure/)). An optional `#<json-key>` extracts a top-level field when the secret is a JSON document. Example: `${kst:az:my-vault/db-password}` or `${kst:az:my-vault/db#password}`.

### `pass` — pass / gopass

```text
${kst:pass:<path>}
```

Runs `pass show <path>` (the [pass](https://www.passwordstore.org/) password store; gopass is compatible) and returns the first line — the password, by the store's convention. Example: `${kst:pass:work/vercel}`.

### `keychain` — macOS Keychain

```text
${kst:keychain:<service>#<account>}
```

Runs `security find-generic-password -s <service> [-a <account>] -w` against the macOS Keychain; the `#<account>` is optional. Example: `${kst:keychain:vercel-token}`.

<Callout type="info">

`op`, `vault`, `kp`, `aws`, `gcp`, `az`, `pass`, and `keychain` all shell out to the provider's own CLI at sync time, inheriting whatever session and auth you already have — no tokens in any kasetto file. If the CLI is missing or the lookup fails (item not found, not authenticated, locked database), the entry is a hard failure with the CLI's error, regardless of `--allow-missing-secrets`.

</Callout>

## Missing Secrets

By default a missing required secret is a hard failure: the MCP entry is marked broken, **nothing is written** for it, and `kst sync` exits non-zero. Other assets still sync.

```bash
kst sync --allow-missing-secrets
```

With `--allow-missing-secrets`, Kasetto warns instead and leaves the literal placeholder in place. The same policy can be set in config:

```yaml
secrets:
  on_missing: warn   # error (default) | warn
```

## Rotation

<Callout type="warn">

**Rotating a secret requires `--update`.** A plain `kst sync` will report the server `unchanged` and leave the **old** value in place — it does not re-resolve secrets on its own. When a secret-bearing server is skipped, kasetto prints a tip reminding you to run `--update`.

</Callout>

A plain `kst sync` is stable: an MCP server already present in the agent's settings is left exactly as is, so a rotated secret in `credentials.yaml` or the environment does **not** propagate on its own. This is the same zero-churn guarantee the lock provides everywhere else.

To push a rotated secret, run an update — it re-resolves and replaces the managed server block:

```bash
kst sync --update            # rotate everything
kst sync --update vercel     # rotate one server
```

<Callout type="info">

`--update` overwrites only the servers Kasetto installed. Servers you added by hand, and your edits to other entries, are never touched.

</Callout>

## What Never Gets Stored

Injection happens in memory, only on the value written to the agent destination. The plaintext secret never reaches:

- **`kasetto.lock`** — the lock hashes the placeholder source file, not the resolved value.
- **The source cache** and stage directory — they hold the pack exactly as authored.

So `kasetto.lock` stays commit-safe even when the synced MCP config carries a live token.

## Config Reference

The `secrets:` block is optional and carries no values — it is safe to commit.

| Key          | Required | Description                                                                 |
| ------------ | -------- | --------------------------------------------------------------------------- |
| `on_missing` | no       | `error` (default) — fail; or `warn` — leave the placeholder and continue.   |
| `files`      | no       | Extra credential files (relative to the config dir, or absolute), searched after the default `credentials.yaml`. |
| `keepass`    | no       | KeePass database for `${kst:kp:...}` refs — `database` (path) and optional `key_file`. No password (that comes from `KST_KEEPASS_PASSWORD`). |

```yaml
secrets:
  on_missing: error
  files:
    - ./team-credentials.yaml
  keepass:
    database: ~/secrets.kdbx
    key_file: ~/secrets.keyx
```

<Callout type="info">

Secret injection currently applies to **MCP configs**. An unknown tag (anything other than `env`, `crd`, `op`, `vault`, `kp`, `aws`, `gcp`, `az`, `pass`, or `keychain`) errors clearly rather than silently passing through.

</Callout>
