aboutsummaryrefslogtreecommitdiff
path: root/mcp
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-05-06 23:11:15 -0500
committerCraig Jennings <c@cjennings.net>2026-05-06 23:11:15 -0500
commit07c2c5ccf288e6ecc25808784ea407821df3d433 (patch)
tree1d46d819e04320b81313219bd1642c464cefe690 /mcp
parentd81b23ad6b6e437dfe3c338a00a4be39bc555146 (diff)
downloadrulesets-07c2c5ccf288e6ecc25808784ea407821df3d433.tar.gz
rulesets-07c2c5ccf288e6ecc25808784ea407821df3d433.zip
feat(mcp): add user-scope MCP install pipeline
I needed a single source of truth for MCP server registration so a fresh machine boots with the full set instead of being rebuilt by hand. install.py decrypts mcp/secrets.env.gpg, expands ${VAR} placeholders in mcp/servers.json, and runs claude mcp add --scope user for anything not already registered. Idempotent. The encrypted bundle carries six values: the Google client id and secret, the Figma API key, the GCP OAuth keys JSON (base64), and the two @a-bonus/google-docs-mcp token caches (personal and work, base64). install.py writes the keys file and the two token files to the paths each package reads at startup, all mode 600. Bundling the Google Docs tokens lets a new machine connect google-docs-personal and google-docs-work without the interactive OAuth flow. Without the cached token, the package falls back to a browser-redirect flow that Claude Code's stdio MCP loader can't drive, so it shows "Failed to connect" until the user runs the npx command manually. Make target: install-mcp. Plaintext secrets and the decrypted keys file are gitignored.
Diffstat (limited to 'mcp')
-rw-r--r--mcp/install.py165
-rw-r--r--mcp/secrets.env.gpgbin0 -> 999 bytes
-rw-r--r--mcp/servers.json54
3 files changed, 219 insertions, 0 deletions
diff --git a/mcp/install.py b/mcp/install.py
new file mode 100644
index 0000000..5e43832
--- /dev/null
+++ b/mcp/install.py
@@ -0,0 +1,165 @@
+#!/usr/bin/env python3
+"""Install 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.
+"""
+from __future__ import annotations
+
+import base64
+import json
+import os
+import re
+import subprocess
+import sys
+from pathlib import Path
+from typing import Any
+
+REPO = Path(__file__).resolve().parent.parent
+SERVERS = REPO / "mcp" / "servers.json"
+SECRETS_GPG = REPO / "mcp" / "secrets.env.gpg"
+GCP_KEYS_OUT = REPO / "mcp" / "gcp-oauth.keys.json"
+GOOGLE_DOCS_TOKENS_DIR = Path.home() / ".config" / "google-docs-mcp"
+GOOGLE_DOCS_PROFILES = ("personal", "work")
+
+
+def expand(value: Any, env: dict[str, str]) -> Any:
+ pattern = re.compile(r"\$\{(\w+)\}")
+ if isinstance(value, str):
+ def repl(m: re.Match) -> str:
+ key = m.group(1)
+ if key in env:
+ return env[key]
+ if key in os.environ:
+ return os.environ[key]
+ raise SystemExit(f"ERROR: ${{{key}}} not defined in secrets.env or process env")
+ return pattern.sub(repl, value)
+ if isinstance(value, list):
+ return [expand(v, env) for v in value]
+ if isinstance(value, dict):
+ return {k: expand(v, env) for k, v in value.items()}
+ return value
+
+
+def decrypt_secrets() -> dict[str, str]:
+ if not SECRETS_GPG.is_file():
+ raise SystemExit(f"ERROR: {SECRETS_GPG} missing")
+ print(f"Decrypting {SECRETS_GPG.relative_to(REPO)}...")
+ res = subprocess.run(
+ ["gpg", "-d", "--quiet", str(SECRETS_GPG)],
+ capture_output=True, text=True
+ )
+ if res.returncode != 0:
+ raise SystemExit(f"ERROR: gpg decryption failed:\n{res.stderr}")
+ env: dict[str, str] = {}
+ for raw in res.stdout.splitlines():
+ line = raw.strip()
+ if not line or line.startswith("#"):
+ continue
+ key, _, val = line.partition("=")
+ env[key.strip()] = val.strip()
+ return env
+
+
+def already_registered() -> set[str]:
+ res = subprocess.run(["claude", "mcp", "list"], capture_output=True, text=True)
+ names: set[str] = set()
+ for line in res.stdout.splitlines():
+ if ":" not in line:
+ continue
+ head = line.split(":", 1)[0].strip()
+ if head and head[0].isalnum():
+ names.add(head)
+ return names
+
+
+def build_add_cmd(name: str, cfg: dict[str, Any]) -> list[str] | None:
+ base = ["claude", "mcp", "add", "--scope", "user"]
+ transport = cfg.get("type")
+ if transport in ("http", "sse"):
+ return base + ["--transport", transport, name, cfg["url"]]
+ if transport == "stdio":
+ # commander.js parses -e as variadic; placing it before the positional
+ # name lets the parser eat the name as another -e value. Putting -e
+ # after the name (and before --) avoids that.
+ env_flags: list[str] = []
+ for k, v in cfg.get("env", {}).items():
+ env_flags += ["-e", f"{k}={v}"]
+ return base + [name] + env_flags + ["--", cfg["command"], *cfg.get("args", [])]
+ return None
+
+
+def main() -> int:
+ 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)
+ return 1
+
+ if not SERVERS.is_file():
+ print(f"ERROR: {SERVERS} missing", file=sys.stderr)
+ return 1
+
+ env = decrypt_secrets()
+
+ # Extract bundled OAuth keys JSON, write to disk at the path
+ # google-calendar-mcp will read at runtime
+ if "GCP_OAUTH_KEYS_JSON_B64" in env:
+ GCP_KEYS_OUT.write_bytes(base64.b64decode(env.pop("GCP_OAUTH_KEYS_JSON_B64")))
+ GCP_KEYS_OUT.chmod(0o600)
+ env["GOOGLE_OAUTH_CREDENTIALS_PATH"] = str(GCP_KEYS_OUT.resolve())
+ print(f" wrote {GCP_KEYS_OUT.relative_to(REPO)} (mode 600)")
+
+ # Extract bundled @a-bonus/google-docs-mcp OAuth tokens, write each to the
+ # per-profile path the package reads at startup. Without this, a fresh
+ # machine has no token cache and the server falls back to interactive
+ # OAuth — which Claude Code's stdio MCP loader can't drive, so it shows
+ # "Failed to connect" until the user runs the npx command manually.
+ for profile in GOOGLE_DOCS_PROFILES:
+ var = f"GOOGLE_DOCS_{profile.upper()}_TOKEN_B64"
+ if var not in env:
+ continue
+ target_dir = GOOGLE_DOCS_TOKENS_DIR / profile
+ target_dir.mkdir(parents=True, exist_ok=True)
+ target_dir.chmod(0o700)
+ target = target_dir / "token.json"
+ target.write_bytes(base64.b64decode(env.pop(var)))
+ target.chmod(0o600)
+ print(f" wrote {target} (mode 600)")
+
+ existing = already_registered()
+ servers = json.loads(SERVERS.read_text())
+
+ print("\nRegistering MCP servers (user scope):")
+ added = skipped = failed = 0
+ for name, raw_cfg in servers.items():
+ if name in existing:
+ print(f" skip {name} (already registered)")
+ skipped += 1
+ continue
+
+ cfg = expand(raw_cfg, env)
+ cmd = build_add_cmd(name, cfg)
+ if cmd is None:
+ print(f" FAIL {name} (unknown transport: {cfg.get('type')!r})")
+ failed += 1
+ continue
+
+ res = subprocess.run(cmd, capture_output=True, text=True)
+ if res.returncode == 0:
+ transport = cfg["type"]
+ tail = cfg.get("url") or f"{cfg.get('command', '')} {' '.join(cfg.get('args', []))[:60]}"
+ print(f" add {name} ({transport}: {tail})")
+ added += 1
+ else:
+ err = (res.stderr or res.stdout).strip().splitlines()[-1][:120]
+ print(f" FAIL {name}: {err}")
+ failed += 1
+
+ print(f"\nDone. Added: {added} Skipped: {skipped} Failed: {failed}")
+ return 0 if failed == 0 else 1
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/mcp/secrets.env.gpg b/mcp/secrets.env.gpg
new file mode 100644
index 0000000..2041539
--- /dev/null
+++ b/mcp/secrets.env.gpg
Binary files differ
diff --git a/mcp/servers.json b/mcp/servers.json
new file mode 100644
index 0000000..84050cb
--- /dev/null
+++ b/mcp/servers.json
@@ -0,0 +1,54 @@
+{
+ "google-calendar": {
+ "type": "stdio",
+ "command": "npx",
+ "args": ["-y", "@cocal/google-calendar-mcp"],
+ "env": {
+ "GOOGLE_OAUTH_CREDENTIALS": "${GOOGLE_OAUTH_CREDENTIALS_PATH}"
+ }
+ },
+ "linear": {
+ "type": "http",
+ "url": "https://mcp.linear.app/mcp"
+ },
+ "drawio": {
+ "type": "stdio",
+ "command": "npx",
+ "args": ["-y", "@drawio/mcp"],
+ "env": {}
+ },
+ "notion": {
+ "type": "http",
+ "url": "https://mcp.notion.com/mcp"
+ },
+ "google-docs-personal": {
+ "type": "stdio",
+ "command": "npx",
+ "args": ["-y", "@a-bonus/google-docs-mcp"],
+ "env": {
+ "GOOGLE_CLIENT_ID": "${GOOGLE_CLIENT_ID}",
+ "GOOGLE_CLIENT_SECRET": "${GOOGLE_CLIENT_SECRET}",
+ "GOOGLE_MCP_PROFILE": "personal"
+ }
+ },
+ "google-docs-work": {
+ "type": "stdio",
+ "command": "npx",
+ "args": ["-y", "@a-bonus/google-docs-mcp"],
+ "env": {
+ "GOOGLE_CLIENT_ID": "${GOOGLE_CLIENT_ID}",
+ "GOOGLE_CLIENT_SECRET": "${GOOGLE_CLIENT_SECRET}",
+ "GOOGLE_MCP_PROFILE": "work"
+ }
+ },
+ "figma": {
+ "type": "stdio",
+ "command": "npx",
+ "args": ["-y", "figma-developer-mcp", "--figma-api-key=${FIGMA_API_KEY}", "--stdio"],
+ "env": {}
+ },
+ "slack-deepsat": {
+ "type": "sse",
+ "url": "http://127.0.0.1:13080/sse"
+ }
+}