Registers MCP servers at Claude Code's user scope (machine-wide, every project) from a single encrypted secrets bundle. One command — make install-mcp — decrypts the secrets, materializes the OAuth artifacts each server needs, expands placeholders in the server definitions, and registers anything not already present. Idempotent: re-running only adds what's missing.
Layout
| File | Tracked? | Purpose |
|---|---|---|
install.py |
tracked | The installer. Decrypt → materialize OAuth artifacts → expand → register. |
servers.json |
tracked | Server definitions with ${VAR} placeholders for secrets. Safe to commit (no secrets inline). |
secrets.env.gpg |
tracked | The secrets bundle, symmetrically encrypted (AES256). The only place secret values live. |
gcp-oauth.keys.json |
gitignored | Google OAuth client keys for google-calendar-mcp. Regenerated at install from a base64 var in the bundle; never committed. |
The Google Docs token caches are written outside the repo at ~/.config/google-docs-mcp/<profile>/token.json (mode 600), where @a-bonus/google-docs-mcp reads them at startup.
Secrets bundle shape
secrets.env.gpg decrypts to a plain KEY=value file (one per line, # comments allowed). Two kinds of entry:
- Plain secrets referenced by
${VAR}inservers.json:GOOGLE_CLIENT_ID,GOOGLE_CLIENT_SECRET— Google Docs OAuth client (both profiles share one client).FIGMA_API_KEY.GOOGLE_KEEP_EMAIL,GOOGLE_KEEP_MASTER_TOKEN.
- Base64-bundled OAuth artifacts the installer decodes and writes to disk (they are not referenced by
servers.json;install.pyconsumes them directly):GCP_OAUTH_KEYS_JSON_B64→ decoded tomcp/gcp-oauth.keys.json(mode 600). The installer then setsGOOGLE_OAUTH_CREDENTIALS_PATHto that path, whichservers.jsondoes reference forgoogle-calendar.GOOGLE_DOCS_PERSONAL_TOKEN_B64,GOOGLE_DOCS_WORK_TOKEN_B64→ decoded to~/.config/google-docs-mcp/{personal,work}/token.json(mode 600). These are the refresh-token caches — without them a fresh machine has no token and the stdio server falls back to interactive OAuth, which Claude Code's loader can't drive (it shows "Failed to connect").
Re-encrypting the bundle
Symmetric, AES256, passphrase-only (no key recipient):
gpg -c --cipher-algo AES256 secrets.env # produces secrets.env.gpg
rm secrets.env # never leave the plaintext aroundDecryption at install time goes through gpg-agent (pinentry prompts once, then caches per gpg-agent.conf).
Install flow
make install-mcp runs python3 mcp/install.py, which:
- Checks
gpgandclaudeare onPATH. - Decrypts
secrets.env.gpginto an in-memory env dict. - Materializes OAuth artifacts: writes
gcp-oauth.keys.json(mode 600) and the two Google Docstoken.jsoncaches (mode 600), popping those base64 vars from the env. - Reads
claude mcp listto find already-registered servers. - For each server in
servers.jsonnot already registered: expands${VAR}placeholders against the secrets env (errors loudly if a placeholder is undefined), builds the rightclaude mcp add --scope usercommand for the transport, and runs it. - Prints an
Added / Skipped / Failedtally; exits non-zero if any server failed.
Idempotent — a server already in claude mcp list is skipped, so re-running after adding one new server only registers that one.
Transports
servers.json keys each server by name; type selects the registration path:
http/sse(linear,notion,slack-deepsat) →claude mcp add --scope user --transport <type> <name> <url>. No local process; the URL is the endpoint.slack-deepsatis ansseserver on127.0.0.1(a locally-running bridge).stdio(google-calendar,drawio,google-docs-personal,google-docs-work,figma,google-keep) → a local command (npx/uvx) launched per session. Env vars pass via repeated-e KEY=valueflags placed before the--separator and the command, because commander.js parses-eas variadic and would otherwise eat the server name.
Token rotation (revoked Google refresh token)
A Google refresh token dies when scopes are re-granted, the Connected App is removed, or the account password resets. Recovery is manual today:
- On one machine, run the server's
npxcommand with the right env so it performs the interactive OAuth dance, follow the browser URL, then kill the process oncetoken.jsonis written. - Base64-encode the new
token.json. - Decrypt
secrets.env.gpg, replace the matching*_TOKEN_B64var, re-encrypt (see above), recommitsecrets.env.gpg. make install-mcpon each machine re-materializes the cache from the bundle.
(A mcp/refresh-google-docs-token.sh <profile> helper to chain this into one command is a tracked follow-up.)
Adding a new server
- Add the server block to
servers.jsonwith itstypeand any${VAR}placeholders. - Add the secret values for those vars to
secrets.envand re-encrypt the bundle. make install-mcp— it skips the already-registered servers and adds the new one.
HTTP vs stdio OAuth, and the gotchas
- HTTP/SSE servers (
linear,notion) carry their own hosted OAuth — the first tool call in a session triggers an in-client auth flow; nothing local to cache. If a connection fails, it is usually an expired in-client grant, re-authorized from the client, not from this pipeline. - stdio servers needing OAuth (
google-docs-*,google-calendar) cannot run an interactive browser flow under Claude Code's stdio loader. They depend on a pre-seeded token/keys file on disk — which is exactly why the installer materializes them from the bundle. A "Failed to connect" on one of these almost always means the on-disk token cache is missing or stale; rotate it (above) rather than re-running the server by hand.
Signal MCP — local signal-cli dependency
The signal-mcp server (rymurr/signal-mcp, cloned to ~/.local/share/signal-mcp with a uv-built .venv) wraps a locally-installed signal-cli. Unlike the OAuth servers, it carries no secret in the bundle — its only config is the --user-id it runs as, which is set in servers.json.
Prerequisites on a fresh machine, in order:
- Install
signal-cli(AUR:signal-cli; needs a JRE 21+). - Register the dedicated pager account. Signal requires a captcha for registration: run
signal-cli -a +<number> register, follow the printedsignalcaptchas.orglink, solve it, and pass the resulting token tosignal-cli -a +<number> register --captcha <token>within a minute or two before it expires. Signal then texts a code to the number; verify withsignal-cli -a +<number> verify <code>. make install-mcpregisterssignal-mcppointed at that--user-id.
The --user-id is a dedicated number (a Google Voice number registered to signal-cli), not Craig's primary. Signal mobile won't push-notify a message an account sends to itself, so paging from the primary to itself is silent; a distinct sender account reaching Craig's account notifies normally. Craig's account hides its phone number under phone-number privacy, so the destination is its stable account UUID rather than a number. The MCP server is the two-way path (receive_message to listen for replies).
