Files
dotfiles/mcp-config/sync.sh
T
noconnorandClaude Opus 4.8 16e01cb3d0 mcp-config: add covert-gpt provider, auto-discover models
Two changes to the synced opencode config:

- Model provider. opencode can't list models from a custom OpenAI-compatible
  provider itself, so sync.sh queries the gateway's /v1/models with the
  user's key and materializes the map client-side. New gateway models land
  on every box at the next sync with no config edit. If discovery fails the
  previous map is carried over, so a gateway hiccup never empties the list.
- Aggregate MCP endpoint. One entry at /mcp/ (trailing slash required — the
  bare path 307-redirects and MCP clients don't follow it) exposes every
  server the key can access, replacing the per-server gitea entry.

sync.sh also exports OPENCODE_CONFIG: opencode does not auto-load extra
files from its config dir, so without it the synced file was ignored.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 04:40:25 -04:00

141 lines
6.1 KiB
Bash

#!/usr/bin/env sh
# mcp-sync — refresh the shared LiteLLM-gateway opencode config on the laptop,
# auto-discover the gateway's models, 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 aggregate MCP endpoint). The file
# is identical for all users — the per-user key is injected via
# {env:LITELLM_KEY}, 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:
# the single /mcp/ endpoint aggregates every server the key can access.
# 3. 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.
# 4. 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}"
# 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"
}
_mcp_sync_export_key
_mcp_sync_refresh
# 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 2>/dev/null || true