aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.gitignore5
-rw-r--r--Makefile7
-rw-r--r--mcp/install.py165
-rw-r--r--mcp/secrets.env.gpgbin0 -> 999 bytes
-rw-r--r--mcp/servers.json54
5 files changed, 230 insertions, 1 deletions
diff --git a/.gitignore b/.gitignore
index 4c66e3b..94b983f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -15,3 +15,8 @@
# Claude config — settings.local.json is per-machine, never commit
.claude/settings.local.json
+
+# MCP plaintext secrets and decrypted-at-runtime credentials
+# (only the .gpg counterpart is safe to commit)
+mcp/secrets.env
+mcp/gcp-oauth.keys.json
diff --git a/Makefile b/Makefile
index c69cef0..c14f22b 100644
--- a/Makefile
+++ b/Makefile
@@ -62,7 +62,7 @@ endef
.PHONY: help install uninstall list install-hooks uninstall-hooks \
install-lang install-elisp install-python list-languages \
- diff lint deps
+ install-mcp diff lint deps
##@ General
@@ -337,6 +337,11 @@ install-elisp: ## Install Elisp bundle ([PROJECT=<path>] [FORCE=1])
install-python: ## Install Python bundle ([PROJECT=<path>] [FORCE=1])
@$(MAKE) install-lang LANG=python PROJECT="$(PROJECT)" FORCE="$(FORCE)"
+##@ MCP servers (user scope)
+
+install-mcp: ## Decrypt mcp/secrets.env.gpg and register MCP servers at user scope (idempotent)
+ @python3 mcp/install.py
+
##@ Compare & validate
diff: ## Show drift between installed ruleset and repo source ([LANG=<lang>] [PROJECT=<path>])
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"
+ }
+}