From 07c2c5ccf288e6ecc25808784ea407821df3d433 Mon Sep 17 00:00:00 2001 From: Craig Jennings Date: Wed, 6 May 2026 23:11:15 -0500 Subject: 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. --- mcp/install.py | 165 ++++++++++++++++++++++++++++++++++++++++++++++++++++ mcp/secrets.env.gpg | Bin 0 -> 999 bytes mcp/servers.json | 54 +++++++++++++++++ 3 files changed, 219 insertions(+) create mode 100644 mcp/install.py create mode 100644 mcp/secrets.env.gpg create mode 100644 mcp/servers.json (limited to 'mcp') 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 Binary files /dev/null and b/mcp/secrets.env.gpg 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" + } +} -- cgit v1.2.3