aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-05-28 09:20:08 -0500
committerCraig Jennings <c@cjennings.net>2026-05-28 09:20:08 -0500
commit814695eae81dd1c63d75cae87375e703bb388243 (patch)
treeebdb631f5d4a93cabe92fbb99c1362876fc32fca
parent5c0c7a6f213609f5be8258f07b763201ad182876 (diff)
downloadrulesets-814695eae81dd1c63d75cae87375e703bb388243.tar.gz
rulesets-814695eae81dd1c63d75cae87375e703bb388243.zip
feat(mcp): add uninstall + --check + README section for MCP pipeline
Three coupled additions close the MCP pipeline thread. mcp/install.py grew --uninstall and --check modes via argparse. The default install behavior is unchanged. --uninstall iterates over servers.json and runs `claude mcp remove <name> -s user` for each, skipping anything not registered. Idempotent. --check is the dry-run drift report. For each server, classify as ok (in both servers.json and `claude mcp list`), MISSING (configured but not registered), or EXTRA (registered but not in servers.json). Exit non-zero only on MISSING since EXTRA entries are often deliberate (the claude.ai web servers register out-of-band). Smoke test against the live config: 9 ok, 0 missing, 3 EXTRA, exit 0. Two new Makefile targets: - make uninstall-mcp invokes the --uninstall mode. - make check-mcp invokes the --check mode. README.org gained an MCP section under Two install modes covering all three targets, the OAuth-token-on-disk story, and a pointer to mcp/README.org for the full pipeline. Closes TODO #7 (uninstall + --check) and TODO #8 (README MCP section).
-rw-r--r--Makefile6
-rw-r--r--README.org29
-rwxr-xr-xmcp/install.py109
-rw-r--r--todo.org6
4 files changed, 143 insertions, 7 deletions
diff --git a/Makefile b/Makefile
index 2b2cb2d..abf28c1 100644
--- a/Makefile
+++ b/Makefile
@@ -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>])
diff --git a/README.org b/README.org
index 91e9804..067a2a1 100644
--- a/README.org
+++ b/README.org
@@ -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)
diff --git a/todo.org b/todo.org
index 9df8049..912d387 100644
--- a/todo.org
+++ b/todo.org
@@ -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: