aboutsummaryrefslogtreecommitdiff
path: root/mcp/README.org
blob: ac65ffde6d3cc0f837d1c24669017fdbb7cdab5b (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
#+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.

* 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).