diff options
| -rw-r--r-- | Makefile | 6 | ||||
| -rw-r--r-- | README.org | 29 | ||||
| -rwxr-xr-x | mcp/install.py | 109 | ||||
| -rw-r--r-- | todo.org | 6 |
4 files changed, 143 insertions, 7 deletions
@@ -412,6 +412,12 @@ install-team: ## Install a team publishing overlay into one project ([TEAM=<team install-mcp: ## Decrypt mcp/secrets.env.gpg and register MCP servers at user scope (idempotent) @python3 mcp/install.py +uninstall-mcp: ## Remove every server listed in mcp/servers.json from `claude mcp list` (idempotent) + @python3 mcp/install.py --uninstall + +check-mcp: ## Dry-run drift report of mcp/servers.json vs registered MCP servers + @python3 mcp/install.py --check + ##@ Compare & validate diff: ## Show drift between installed ruleset and repo source ([LANG=<lang>] [PROJECT=<path>]) @@ -49,6 +49,35 @@ What gets installed: The install is re-runnable. Running it again refreshes files in place; personal tweaks live in =.claude/settings.local.json= and are not touched. +** MCP servers (user scope) + +Registers MCP servers globally (=user= scope) so every Claude Code project +sees them. Reads structure from =mcp/servers.json= (placeholders =${VAR}=), +decrypts secrets from =mcp/secrets.env.gpg= via gpg-agent, expands the +placeholders, then registers anything not already present in +=claude mcp list=. Idempotent — re-running is safe. + +#+begin_src bash +make install-mcp # decrypt + register everything in servers.json +make uninstall-mcp # remove every server listed in servers.json +make check-mcp # dry-run drift report (no decryption, no writes) +#+end_src + +=check-mcp= classifies each server as =ok= (in both), =MISSING= (configured +but not registered — run =install-mcp=), or =EXTRA= (registered but not +configured — usually intentional manual additions like the claude.ai web +servers). Exit code is non-zero only on =MISSING=, since =EXTRA= entries +are often deliberate. + +What lands on disk during =install-mcp=: +- =mcp/gcp-oauth.keys.json= (mode 600) — extracted for google-calendar-mcp +- =~/.config/google-docs-mcp/{personal,work}/token.json= (mode 600) — + per-profile OAuth tokens for =@a-bonus/google-docs-mcp= + +Secrets never touch disk in plain form outside the OAuth artifacts above. +The =.gpg= file is the source of truth; rotate via =gpg --edit-key= and +re-encrypt. See [[file:mcp/README.org][mcp/README.org]] for the full pipeline. + * Available languages | Language | Path | Notes | diff --git a/mcp/install.py b/mcp/install.py index 5e43832..606dcb8 100755 --- a/mcp/install.py +++ b/mcp/install.py @@ -1,13 +1,26 @@ #!/usr/bin/env python3 -"""Install MCP servers at user scope. +"""Install / uninstall / check MCP servers at user scope. -Reads structure from mcp/servers.json (placeholders ${VAR} for secrets), decrypts -mcp/secrets.env.gpg via gpg-agent (pinentry will prompt; cached per gpg-agent.conf), -expands placeholders, then registers anything not already in `claude mcp list`. -Idempotent — re-running is safe. +Three modes: + + install (default) + Reads structure from mcp/servers.json (placeholders ${VAR} for secrets), + decrypts mcp/secrets.env.gpg via gpg-agent (pinentry will prompt; cached + per gpg-agent.conf), expands placeholders, registers anything not already + in `claude mcp list`. Idempotent — re-running is safe. + + --uninstall + Iterates over servers.json and runs `claude mcp remove <name> -s user` + for each. "Not registered" errors are ignored. Idempotent. + + --check + Dry-run drift report. Lists each server in servers.json as MISSING + (in config, not registered) or ok (in both); lists each registered + server not in servers.json as EXTRA. No secrets decryption, no writes. """ from __future__ import annotations +import argparse import base64 import json import os @@ -91,7 +104,93 @@ def build_add_cmd(name: str, cfg: dict[str, Any]) -> list[str] | None: return None +def check_mode() -> int: + """Drift report: compare servers.json against claude mcp list.""" + if subprocess.run(["which", "claude"], capture_output=True).returncode != 0: + print("ERROR: claude not found in PATH", file=sys.stderr) + return 1 + if not SERVERS.is_file(): + print(f"ERROR: {SERVERS} missing", file=sys.stderr) + return 1 + + configured = set(json.loads(SERVERS.read_text()).keys()) + registered = already_registered() + + print("MCP server drift (servers.json vs `claude mcp list`):") + missing = sorted(configured - registered) + extra = sorted(registered - configured) + in_both = sorted(configured & registered) + + for name in in_both: + print(f" ok {name}") + for name in missing: + print(f" MISSING {name} (in servers.json, not registered)") + for name in extra: + print(f" EXTRA {name} (registered, not in servers.json)") + + print( + f"\nSummary: {len(in_both)} ok, {len(missing)} missing, {len(extra)} extra" + ) + # Exit non-zero only on MISSING (since `make install-mcp` would fix it). + # EXTRA entries are often intentional manual additions (web-app servers, + # one-off experiments) and shouldn't fail the check. + return 1 if missing else 0 + + +def uninstall_mode() -> int: + """Remove every server listed in servers.json from `claude mcp list`.""" + if subprocess.run(["which", "claude"], capture_output=True).returncode != 0: + print("ERROR: claude not found in PATH", file=sys.stderr) + return 1 + if not SERVERS.is_file(): + print(f"ERROR: {SERVERS} missing", file=sys.stderr) + return 1 + + configured = json.loads(SERVERS.read_text()).keys() + registered = already_registered() + + print("Uninstalling MCP servers (user scope):") + removed = skipped = 0 + for name in configured: + if name not in registered: + print(f" skip {name} (not registered)") + skipped += 1 + continue + res = subprocess.run( + ["claude", "mcp", "remove", name, "-s", "user"], + capture_output=True, text=True + ) + if res.returncode == 0: + print(f" rm {name}") + removed += 1 + else: + err = (res.stderr or res.stdout).strip().splitlines()[-1][:120] + print(f" FAIL {name}: {err}") + + print(f"\nDone. Removed: {removed} Skipped: {skipped}") + return 0 + + def main() -> int: + parser = argparse.ArgumentParser( + description="Install, uninstall, or check MCP servers (user scope)." + ) + mode = parser.add_mutually_exclusive_group() + mode.add_argument( + "--check", action="store_true", + help="Dry-run drift report (no decryption, no writes)." + ) + mode.add_argument( + "--uninstall", action="store_true", + help="Remove every server listed in servers.json from claude mcp list." + ) + args = parser.parse_args() + + if args.check: + return check_mode() + if args.uninstall: + return uninstall_mode() + for tool in ("gpg", "claude"): if subprocess.run(["which", tool], capture_output=True).returncode != 0: print(f"ERROR: {tool} not found in PATH", file=sys.stderr) @@ -1047,7 +1047,8 @@ having a skill to generate or check OV-1-shaped artifacts. Don't build speculatively — defense-specific notations are narrow enough that each skill should be driven by a concrete contract need, not aspiration. -** TODO [#C] Add =make uninstall-mcp= + =mcp/install.py --check= for symmetry :feature:solo:quick: +** DONE [#C] Add =make uninstall-mcp= + =mcp/install.py --check= for symmetry :feature:solo:quick: +CLOSED: [2026-05-28 Thu] :PROPERTIES: :LAST_REVIEWED: 2026-05-28 :END: @@ -1068,7 +1069,8 @@ Dry-run mode. Decrypt secrets, but instead of registering, print the drift repor Useful for diagnosing connection failures and for the eventual =make doctor= integration. -** TODO [#C] Update =README.org= with MCP install pipeline section :chore:solo:quick: +** DONE [#C] Update =README.org= with MCP install pipeline section :chore:solo:quick: +CLOSED: [2026-05-28 Thu] :PROPERTIES: :LAST_REVIEWED: 2026-05-28 :END: |
