aboutsummaryrefslogtreecommitdiff

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:

  1. Plain secrets referenced by ${VAR} in servers.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.
  2. Base64-bundled OAuth artifacts the installer decodes and writes to disk (they are not referenced by servers.json; install.py consumes them directly):
    • GCP_OAUTH_KEYS_JSON_B64 → decoded to mcp/gcp-oauth.keys.json (mode 600). The installer then sets GOOGLE_OAUTH_CREDENTIALS_PATH to that path, which servers.json does reference for google-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 around

Decryption 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:

  1. Checks gpg and claude are on PATH.
  2. Decrypts secrets.env.gpg into an in-memory env dict.
  3. Materializes OAuth artifacts: writes gcp-oauth.keys.json (mode 600) and the two Google Docs token.json caches (mode 600), popping those base64 vars from the env.
  4. Reads claude mcp list to find already-registered servers.
  5. For each server in servers.json not already registered: expands ${VAR} placeholders against the secrets env (errors loudly if a placeholder is undefined), builds the right claude mcp add --scope user command for the transport, and runs it.
  6. Prints an Added / Skipped / Failed tally; 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-deepsat is an sse server on 127.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=value flags placed before the -- separator and the command, because commander.js parses -e as 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:

  1. On one machine, run the server's npx command with the right env so it performs the interactive OAuth dance, follow the browser URL, then kill the process once token.json is written.
  2. Base64-encode the new token.json.
  3. Decrypt secrets.env.gpg, replace the matching *_TOKEN_B64 var, re-encrypt (see above), recommit secrets.env.gpg.
  4. make install-mcp on 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

  1. Add the server block to servers.json with its type and any ${VAR} placeholders.
  2. Add the secret values for those vars to secrets.env and re-encrypt the bundle.
  3. 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:

  1. Install signal-cli (AUR: signal-cli; needs a JRE 21+).
  2. Register the dedicated pager account. Signal requires a captcha for registration: run signal-cli -a +<number> register, follow the printed signalcaptchas.org link, solve it, and pass the resulting token to signal-cli -a +<number> register --captcha <token> within a minute or two before it expires. Signal then texts a code to the number; verify with signal-cli -a +<number> verify <code>.
  3. make install-mcp registers signal-mcp pointed 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).