#!/usr/bin/env sh # mcp-sync — refresh the shared LiteLLM-gateway opencode config on the laptop, # auto-discover the gateway's models, deploy the shared skills, and export the # user's LiteLLM key. # # Sourced from the user's shell rc (installed by install.sh) so it runs at the # start of every interactive shell ("session start"). It is intentionally: # - FAST and SILENT on success (no output unless something is wrong) # - NON-FATAL: a network/Gitea hiccup must never break the user's shell, so # every failure path just returns, leaving the last-good config in place. # # What it does: # 1. Fetch the shared opencode.gateway.json from Gitea (the single source of # truth: the covert-gpt provider + the client-side MCP servers). The file # is identical for all users — EVERY credential (LiteLLM key, Gitea PAT, # Proxmox token) is injected via {env:VAR} and never baked into the file. # 2. Auto-discover models: query the gateway's /v1/models with the user's # key and fill the provider's "models" map. New models exposed on the # gateway appear on every box at the next sync — no config edit anywhere. # (opencode cannot list models from a custom provider itself; the map # must be materialized client-side.) MCP servers need no discovery step: # they are declared in the config and run locally under `npx`, each with # the user's OWN credentials. The gateway hosts no MCP servers at all — # see mcp-config/README.md for why (user-scoped APIs, no shared tokens). # 3. Deploy the repo's skills/ tree to ~/.config/mcp-sync/skills. Skills are # plain files, so there is nothing on the gateway to discover — the repo # IS the source of truth, and the tree is fetched whole (repo tarball). # They land in mcp-sync's OWN directory, not ~/.config/opencode/skills, # so the atomic replace below can never eat a skill the user wrote by # hand; opencode picks them up via skills.paths in the shared config. # 4. Drop the result at ~/.config/opencode/opencode.gateway.json and export # OPENCODE_CONFIG pointing at it. opencode does NOT auto-load extra files # in its config dir — it only merges known locations, and OPENCODE_CONFIG # is merged between the global opencode.json and any project config. The # user's own opencode.json and local-only MCP servers are never touched. # 5. Export LITELLM_KEY from ~/.config/mcp-sync/key so {env:LITELLM_KEY} # resolves. # --- config (overridable via env before sourcing) ------------------------- MCP_SYNC_RAW_URL="${MCP_SYNC_RAW_URL:-https://git.nic-oconnor.com/public/dotfiles/raw/branch/main/mcp-config/opencode.gateway.json}" MCP_SYNC_HOME="${MCP_SYNC_HOME:-$HOME/.config/mcp-sync}" MCP_SYNC_DEST="${MCP_SYNC_DEST:-$HOME/.config/opencode/opencode.gateway.json}" MCP_SYNC_KEYFILE="${MCP_SYNC_KEYFILE:-$MCP_SYNC_HOME/key}" # Provider whose models are auto-discovered, and the gateway base URL. MCP_SYNC_PROVIDER="${MCP_SYNC_PROVIDER:-covert-gpt}" MCP_SYNC_GATEWAY="${MCP_SYNC_GATEWAY:-https://llm.covert-gpt.com}" # Skills. A skill is a folder of files, so unlike the config there is no single # raw URL to pull — we take the repo tarball and lift out its skills/ subtree. # Set MCP_SYNC_SKILLS_SRC to a local directory (or file://) to sync from a # working checkout instead; see zsh/zshrc.local.example. MCP_SYNC_SKILLS_SRC="${MCP_SYNC_SKILLS_SRC:-https://git.nic-oconnor.com/public/dotfiles/archive/main.tar.gz}" MCP_SYNC_SKILLS_SUBDIR="${MCP_SYNC_SKILLS_SUBDIR:-skills}" MCP_SYNC_SKILLS_DEST="${MCP_SYNC_SKILLS_DEST:-$MCP_SYNC_HOME/skills}" # Throttle: only re-sync if the deployed copy is older than this many seconds # (default 1h). Keeps every new shell from hammering Gitea/the gateway. MCP_SYNC_TTL="${MCP_SYNC_TTL:-3600}" # --- export the per-user key (first: model discovery needs it) ------------- _mcp_sync_export_key() { [ -f "$MCP_SYNC_KEYFILE" ] || return 0 # Strip whitespace/newline; export so {env:LITELLM_KEY} resolves in opencode. LITELLM_KEY="$(tr -d ' \t\r\n' < "$MCP_SYNC_KEYFILE" 2>/dev/null)" [ -n "$LITELLM_KEY" ] && export LITELLM_KEY } # --- refresh config + discover models (throttled, non-fatal) --------------- _mcp_sync_refresh() { mkdir -p "$(dirname "$MCP_SYNC_DEST")" 2>/dev/null || return 0 # Skip if we synced recently (mtime within TTL). `find -mmin` is portable # enough; fall back to always-sync if find is unavailable. if [ -f "$MCP_SYNC_DEST" ] && command -v find >/dev/null 2>&1; then if find "$MCP_SYNC_DEST" -mmin -"$(( MCP_SYNC_TTL / 60 ))" 2>/dev/null | grep -q .; then return 0 fi fi command -v curl >/dev/null 2>&1 || return 0 # 1. Fetch the shared base config. On failure fall back to the currently # deployed file so model discovery can still refresh it. _base="$(mktemp 2>/dev/null)" || return 0 # -f: fail on HTTP error; -s: silent; -m: hard timeout so a hung network # can't stall the shell. if ! curl -fsS -m 8 "$MCP_SYNC_RAW_URL" -o "$_base" 2>/dev/null || [ ! -s "$_base" ]; then rm -f "$_base"; _base="" [ -f "$MCP_SYNC_DEST" ] || return 0 fi # 2. Discover the gateway's models with the user's key (best-effort). _models="" if [ -n "${LITELLM_KEY:-}" ]; then _models="$(curl -fsS -m 8 -H "Authorization: Bearer $LITELLM_KEY" \ "$MCP_SYNC_GATEWAY/v1/models" 2>/dev/null)" || _models="" fi # 3. Merge and deploy. python3 validates the base (don't clobber good config # with an error page), injects the discovered models, and — when discovery # failed — carries over the previous deployed models map so a gateway # hiccup never empties the model list. Without python3, deploy the raw # base as-is (no discovery). if command -v python3 >/dev/null 2>&1; then MCP_SYNC_BASE="$_base" MCP_SYNC_MODELS="$_models" \ MCP_SYNC_PY_DEST="$MCP_SYNC_DEST" MCP_SYNC_PY_PROVIDER="$MCP_SYNC_PROVIDER" \ python3 - <<'PYEOF' 2>/dev/null || true import json, os, tempfile dest = os.environ["MCP_SYNC_PY_DEST"] provider = os.environ["MCP_SYNC_PY_PROVIDER"] def load(path): with open(path) as f: return json.load(f) base_path = os.environ.get("MCP_SYNC_BASE") or dest try: cfg = load(base_path) except Exception: raise SystemExit(0) # invalid base: keep last-good deployed config models = {} try: data = json.loads(os.environ.get("MCP_SYNC_MODELS") or "{}") models = {m["id"]: {} for m in data.get("data", []) if m.get("id")} except Exception: pass if not models: try: # discovery failed: carry over the previous deployed map models = load(dest)["provider"][provider]["models"] except Exception: pass prov = cfg.get("provider", {}).get(provider) if prov is not None and models: prov["models"] = models fd, tmp = tempfile.mkstemp(dir=os.path.dirname(dest)) with os.fdopen(fd, "w") as f: json.dump(cfg, f, indent=2) os.replace(tmp, dest) PYEOF elif [ -n "$_base" ]; then mv "$_base" "$MCP_SYNC_DEST" 2>/dev/null || true _base="" fi [ -n "$_base" ] && rm -f "$_base" } # --- deploy the shared skills tree (throttled, non-fatal) ------------------ _mcp_sync_skills() { # Same TTL as the config, tracked on the deployed directory's own mtime. if [ -d "$MCP_SYNC_SKILLS_DEST" ] && command -v find >/dev/null 2>&1; then if find "$MCP_SYNC_SKILLS_DEST" -maxdepth 0 -mmin -"$(( MCP_SYNC_TTL / 60 ))" 2>/dev/null | grep -q .; then return 0 fi fi mkdir -p "$MCP_SYNC_HOME" 2>/dev/null || return 0 # Stage inside MCP_SYNC_HOME (not /tmp) so the publish below is a same- # filesystem rename rather than a copy that can be seen half-finished. _work="$(mktemp -d "$MCP_SYNC_HOME/.skills.XXXXXX" 2>/dev/null)" || return 0 _tree="" case "$MCP_SYNC_SKILLS_SRC" in /*|file://*) # Working checkout: the source is the skills/ dir itself. _tree="${MCP_SYNC_SKILLS_SRC#file://}" ;; *) if command -v curl >/dev/null 2>&1 && command -v tar >/dev/null 2>&1 && curl -fsS -m 15 "$MCP_SYNC_SKILLS_SRC" -o "$_work/repo.tgz" 2>/dev/null && [ -s "$_work/repo.tgz" ] && mkdir -p "$_work/x" && tar -xzf "$_work/repo.tgz" -C "$_work/x" 2>/dev/null then # Gitea wraps the archive in a single top-level dir whose name we don't # want to hardcode; find the skills/ subtree instead of assuming depth. _tree="$(find "$_work/x" -maxdepth 2 -type d -name "$MCP_SYNC_SKILLS_SUBDIR" 2>/dev/null | head -1)" fi ;; esac if [ -z "$_tree" ] || [ ! -d "$_tree" ]; then rm -rf "$_work"; return 0; fi mkdir -p "$_work/stage" 2>/dev/null || { rm -rf "$_work"; return 0; } cp -R "$_tree/." "$_work/stage/" 2>/dev/null || { rm -rf "$_work"; return 0; } # Never replace a good tree with an empty one (truncated fetch, empty subdir). if [ -z "$(ls -A "$_work/stage" 2>/dev/null)" ]; then rm -rf "$_work"; return 0; fi # Publish: swap the new tree in, keeping the old one until the move lands so a # failure leaves the previous skills intact rather than no skills at all. rm -rf "$MCP_SYNC_SKILLS_DEST.old" 2>/dev/null if [ -d "$MCP_SYNC_SKILLS_DEST" ]; then mv "$MCP_SYNC_SKILLS_DEST" "$MCP_SYNC_SKILLS_DEST.old" 2>/dev/null || { rm -rf "$_work"; return 0; } fi if ! mv "$_work/stage" "$MCP_SYNC_SKILLS_DEST" 2>/dev/null; then mv "$MCP_SYNC_SKILLS_DEST.old" "$MCP_SYNC_SKILLS_DEST" 2>/dev/null fi rm -rf "$MCP_SYNC_SKILLS_DEST.old" "$_work" 2>/dev/null } _mcp_sync_export_key _mcp_sync_refresh _mcp_sync_skills # opencode only merges config from known locations; point OPENCODE_CONFIG at # the synced file so it is layered on top of the user's global opencode.json. [ -f "$MCP_SYNC_DEST" ] && export OPENCODE_CONFIG="$MCP_SYNC_DEST" unset -f _mcp_sync_export_key _mcp_sync_refresh _mcp_sync_skills 2>/dev/null || true