aboutsummaryrefslogtreecommitdiff
path: root/mcp/install.py
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 /mcp/install.py
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).
Diffstat (limited to 'mcp/install.py')
-rwxr-xr-xmcp/install.py109
1 files changed, 104 insertions, 5 deletions
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)