aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--mcp/README.org81
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.