diff options
| author | Craig Jennings <c@cjennings.net> | 2026-05-22 17:41:58 -0500 |
|---|---|---|
| committer | Craig Jennings <c@cjennings.net> | 2026-05-22 17:41:58 -0500 |
| commit | d5abd5b493a166ab82ea5ec29213794a94f7f547 (patch) | |
| tree | bd253fc85f92a2ba6348f9247e43f3f0280fb112 | |
| parent | 34453f69696cd0a6e6eab13ad02915cb7a7e0e3e (diff) | |
| download | rulesets-d5abd5b493a166ab82ea5ec29213794a94f7f547.tar.gz rulesets-d5abd5b493a166ab82ea5ec29213794a94f7f547.zip | |
docs(mcp): document the install pipeline in mcp/README.org
mcp/ had install.py, servers.json, and the encrypted secrets bundle but no README, so the structure and the token-rotation flow were a re-discovery every few months. Added mcp/README.org covering the file layout (tracked vs gitignored), the secrets-bundle shape (plain ${VAR} secrets plus base64-bundled OAuth artifacts, AES256 symmetric encryption), the install flow (decrypt, materialize the OAuth keys and the Google Docs token caches at mode 600, expand placeholders, register the unregistered servers idempotently), the http/sse-vs-stdio transport split, the recovery steps when a Google refresh token is revoked, and how to add a server. Written against a read of the actual install.py and servers.json, not from memory.
| -rw-r--r-- | mcp/README.org | 81 |
1 files changed, 81 insertions, 0 deletions
diff --git a/mcp/README.org b/mcp/README.org new file mode 100644 index 0000000..c8cb49a --- /dev/null +++ b/mcp/README.org @@ -0,0 +1,81 @@ +#+TITLE: MCP Install Pipeline +#+AUTHOR: Craig Jennings + +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): + +#+begin_src bash +gpg -c --cipher-algo AES256 secrets.env # produces secrets.env.gpg +rm secrets.env # never leave the plaintext around +#+end_src + +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. |
