Skip to main content

MCP servers

One non-secret source of truth — ~/.config/dotfiles/mcp-servers.json — is merged into both apps by run_onchange_after_claude-{code,desktop}-mcp-merge.sh:

  • Repo-authoritative for the managed servers; preserves every other top-level key (OAuth tokens, session state) and any hand-added servers. Only .mcpServers is rewritten, and the merge aborts if any top-level key would drop.
  • Per-app shape: Code tags each server with type and reaches the remote servers (github, outline, cairn, switchboard) over native http; Desktop omits type and reaches them through the npx mcp-remote stdio bridge.
  • No Docker. Every server is a plain stdio launcher (npx/go) or a remote HTTP endpoint — nothing here needs a container runtime.
  • Secrets never land in the repo. They're read from OpenBao at apply time and written into the spawned server's env block. See mcp_secret / mcp_merge in ~/.config/dotfiles/mcp-merge-lib.sh.
  • Service base URLs come from OpenBao too (mcp_env), not from the repo — so moving a service is one vault kv put, and Crush, Code and Desktop all follow. Switchboard's minted /mcp/<client> slug is a per-user credential-path, so it rides OpenBao as well, as a full per-client URL (SWITCHBOARD_CRUSH_URL / SWITCHBOARD_CLAUDE_CODE_URL) — committed data renders identically on every box, which is exactly what a per-identity value must never do.

The servers

ServerWhat it isTransportLaunched byOS
awsAWS account access — the GA AWS MCP Serverremote, via a local SigV4 stdio proxyuvx mcp-proxy-for-awsboth
cairnCairn artifacts/trajectories ($CAIRN_BASE_URL)http (Code) · mcp-remote (Desktop)native / npx mcp-remoteboth
chrome-devtoolsDrive a local Chrome for DevTools/automationstdionpx chrome-devtools-mcpboth
giteaSelf-hosted Gitea API (gitea.stump.rocks)stdiogo run …/gitea-mcpboth
githubGitHub API (remote hosted MCP)http (Code) · mcp-remote (Desktop)api.githubcopilot.comboth
karakeepKarakeep bookmarks (karakeep.stump.rocks)stdionpx @karakeep/mcpboth
outlineOutline wiki (outline.stump.rocks)http (Code) · mcp-remote (Desktop)native / npx mcp-remoteboth
signalSignal send/receive/reactstdiouv run → signal-cli daemonboth · setup →
switchboardDurable webhook→todo queue ($SWITCHBOARD_CLAUDE_CODE_URL)httpnativeCode only

Where each token comes from

Six servers need a credential; each is sourced differently so nothing secret is ever written to the chezmoi repo. aws is the exception that proves the rule — it needs credentials but stores none, because it signs with SigV4 from the standard boto chain (see below).

OpenBao credentials are written as secret/users/<you>/<category>:<FIELD> — the KV path, then :, then the field (env-var) name. e.g. secret/users/<you>/karakeep:KARAKEEP_API_KEY.

ServerCredentialSource
githubAuthorization: Bearer …secret/users/<you>/github:GITHUB_PERSONAL_ACCESS_TOKEN, baked as a Bearer header — like outline
karakeepKARAKEEP_API_KEYsecret/users/<you>/karakeep:KARAKEEP_API_KEY, baked into env
giteaGITEA_TOKENNot in the config — gitea-mcp inherits it from the login shell (env.zsh, from secret/users/<you>/gitea:GITEA_TOKEN)
outlineAuthorization: Bearer …secret/users/<you>/outline:OUTLINE_API_TOKEN, via the Vault-Agent-rendered secrets-static.env, baked as a static header (Code can't expand ${VAR} in HTTP headers)
cairnAuthorization: Bearer …secret/users/<you>/cairn:CAIRN_API_TOKEN, baked as a static header — same reason as outline. The endpoint comes from OpenBao too (CAIRN_BASE_URL)
switchboardAuthorization: Bearer …secret/users/<you>/switchboard:SWITCHBOARD_CLAUDE_CODE_API_KEY, baked as a static header. The endpoint is the per-client SWITCHBOARD_CLAUDE_CODE_URL from the same bag (full URL incl. the minted /mcp/<client> slug)

aws — the one with no token

The AWS MCP Server is remote and managed by AWS; mcp-proxy-for-aws is a local stdio bridge that exists because the remote endpoint authenticates with IAM SigV4 rather than OAuth, which MCP clients cannot speak natively.

That means there is nothing to bake in and nothing to rotate here: it uses the standard boto credential chain, so it picks up whatever is already in the environment. Rotating the underlying key changes nothing in this repo.

This used to be broken everywhere but an interactive shell

secrets-aws.env is sourced by 00-secrets.zsh, an Oh My Zsh custom file — so it only ever reached interactive zsh. Claude Desktop (a GUI app) and every harness-launched agent (systemd/launchd) got no AWS credentials at all and failed on the first tool call. ~/.aws wasn't a fallback either: the [default] profile on the Mac held a dead 2025-era key, and a Linux agent box had no ~/.aws at all.

Fixed in dotfiles#136 by giving the non-interactive contexts a credential source they can actually read. Vault Agent now renders a second template, secrets-aws.credentials.ctmpl, to ~/.config/aws/credentials in INI form with two profiles:

ProfileFromFor
[default]secret/users/<you>/awsJoe's admin key — CLI use
[agent-readonly]secret/users/<you>/aws-readonlyThe read-only agent identity — MCP servers and harness-launched agents

The server entry points AWS_SHARED_CREDENTIALS_FILE at that file and selects the profile with AWS_PROFILE, so the boto chain resolves identically from a login shell, from Claude Desktop, and from a systemd-supervised agent.

Note the shape of the guard in that template: it ranges the KV metadata listing and only reads a bag that's actually present. A fixed with secret read 404-loops for a user without the bag and leaves a stale file in place; ranging the metadata means such a user renders cleanly to an empty file.

Two things worth knowing:

  • The version is pinned (mcp-proxy-for-aws==1.6.4). An unpinned uvx resolves to whatever is newest at launch, which makes a config that is supposed to be reproducible depend on release timing. Bump it deliberately.
  • The two AWS entries are different servers. aws-knowledge (Crush only, knowledge-mcp.global.api.aws) is public documentation and needs no auth. aws reaches your actual account — it exposes aws___call_aws and aws___run_script, so it can change real resources.

The endpoint region (us-east-1) is the service's and is independent of AWS_REGION in --metadata, which is the region the tools operate on (us-west-2, matching ~/.aws/config).

Rotating any of these is just vault kv put … then chezmoi apply (the merge re-reads OpenBao every run).

Runtime dependencies

A server only connects if its launcher is present on the box:

ServerNeedsNotes
chrome-devtoolsNode (npx) + Chrome
giteaGo toolchaingo run …@latest recompiles (~12 s) on a cold cache and overruns Claude's startup window, so go-tools.txt warms the build cache (go install gitea-mcp)
githubnothing localRemote hosted at api.githubcopilot.com — just the PAT Bearer (Desktop adds the npx mcp-remote bridge)
karakeepNode (npx)@karakeep/mcp from npm — no container, no registry login
outlineNode (npx)Desktop only (the bridge); Code is native HTTP
cairnNode (npx)Desktop only (the bridge); Code is native HTTP
switchboardnothing localCode only — native HTTP, no bridge
signalsignal-cli daemon + uv + ~/src/signal-mcpSee Signal

Add or change a server

$EDITOR ~/src/dotfiles/dot_config/dotfiles/mcp-servers.json # the non-secret defs
vault kv put secret/users/<you>/<svc> <FIELD>=# only if it needs a secret
chezmoi apply --source ~/src/dotfiles ~/.claude.json # try it before merging

Reference a secret as secret/users/<you>/<svc>:<FIELD> — the KV path, a colon, then the field name. Merge the change and czu to propagate it; the apply re-merges both apps.

Then restart Claude Code / Desktop to reload. Check what's live with claude mcp list (shows ✔ connected / ✘ failed per server).

Crush does not read this file — it has its own MCP block in crush.json. See Crush.

Five servers aren't in mcp-servers.json — the merge scripts inject them because their shape varies per app or per OS: github, outline, cairn and switchboard are remote HTTP with a baked Bearer (native http for Code, npx mcp-remote for Desktop — except switchboard, which is Code-only), and signal's uv path + PATH differ macOS vs Linux. To change those, edit the merge scripts (not the JSON). Full Signal story on the Signal page.

Keeping it in sync

The merge scripts are run_onchange_: chezmoi embeds a hash of mcp-servers.json (and the merge lib) in each script, so chezmoi apply only re-runs the merge when those files actually change — but tokens are re-read from OpenBao every time the merge fires, so a rotated secret propagates on the next apply that touches the config. To force a re-merge after only a secret change, chezmoi apply --force.