diff --git a/mcp-config/README.md b/mcp-config/README.md index 22cc9e9..7ec9c88 100644 --- a/mcp-config/README.md +++ b/mcp-config/README.md @@ -10,18 +10,33 @@ sync — the laptops pull from Gitea's `main`.) ## How it works -- `opencode.gateway.json` — the shared server list. **Identical for every - user**: the per-user key is injected via `{env:LITELLM_KEY}`, never written - into the file. Add a gateway server here and everyone picks it up next shell. -- `sync.sh` — sourced from each user's shell rc. On a new shell it pulls - `opencode.gateway.json` (throttled ~1h), drops it where opencode merges it, - and exports `LITELLM_KEY` from `~/.config/mcp-sync/key`. Silent + non-fatal: - a Gitea hiccup never breaks the shell, last-good config stays in place. +- `opencode.gateway.json` — the shared base config: the `covert-gpt` model + provider (LiteLLM's OpenAI-compatible endpoint at + `https://llm.covert-gpt.com/v1`, models map left empty — it is filled by + discovery at sync time) and a single MCP entry for the gateway's + **aggregate** endpoint `https://llm.covert-gpt.com/mcp/` (trailing slash + required; the bare path 307-redirects, which MCP clients don't follow). + That one endpoint exposes every MCP server the user's key can access, so + new gateway servers need no client config change at all. **Identical for + every user**: the per-user key is injected via `{env:LITELLM_KEY}`, never + written into the file. +- `sync.sh` — sourced from each user's shell rc. On a new shell (throttled + ~1h) it pulls the base config from Gitea, queries the gateway's + `/v1/models` with the user's key to **auto-discover models** (opencode + can't list models from a custom provider itself, so the map is + materialized client-side; if discovery fails the previous map is carried + over), deploys the result to `~/.config/opencode/opencode.gateway.json`, + and exports `LITELLM_KEY` (from `~/.config/mcp-sync/key`) plus + `OPENCODE_CONFIG` (pointing at the deployed file — opencode does NOT + auto-load extra files in its config dir, so without this export the file + is ignored). Silent + non-fatal: a Gitea hiccup never breaks the shell, + last-good config stays in place. - `install.sh` — one-time bootstrap (curl-pipe). Fetches `sync.sh`, stores the user's key (0600), and adds the rc hook. -opencode **merges** config files, so the synced `opencode.gateway.json` -coexists with each user's own `~/.config/opencode/opencode.json` and any +opencode merges the `OPENCODE_CONFIG` file on top of the user's global +`~/.config/opencode/opencode.json` (and under any project `opencode.json`), +so the synced config coexists with each user's own settings and any **local-only** MCP servers (e.g. a client-side `kubernetes` pointed at their own kubeconfig). Sync never touches those. @@ -37,13 +52,15 @@ curl -fsSL https://git.nic-oconnor.com/public/dotfiles/raw/branch/main/mcp-confi Open a new terminal, run `opencode`, and the gateway tools are there. -## Adding a gateway server (admin) +## Adding a gateway server or model (admin) -1. Register it on the gateway (`charts/inference/values.yaml` → - `proxy_config.mcp_servers`), deploy, smoke-test. -2. Add a matching entry to `opencode.gateway.json` here and push to `main`. -3. Users get it automatically within the sync TTL (or immediately on a new - shell). No per-user action. +Register it on the gateway (`charts/inference/values.yaml` → +`proxy_config.mcp_servers` for MCP servers, `model_list` for models), +deploy, smoke-test. That's it — clients auto-discover both: MCP servers +through the aggregate `/mcp/` endpoint, models via the `/v1/models` query +in `sync.sh`. Users pick them up within the sync TTL (or immediately on a +new shell). `opencode.gateway.json` here only needs editing for structural +changes (a new provider, endpoint URLs). For **per-user / per-target** tools (cluster access, etc.) do NOT add them here — those run client-side with the user's own credentials. See diff --git a/mcp-config/opencode.gateway.json b/mcp-config/opencode.gateway.json index 18d61ca..441ccea 100644 --- a/mcp-config/opencode.gateway.json +++ b/mcp-config/opencode.gateway.json @@ -1,9 +1,20 @@ { "$schema": "https://opencode.ai/config.json", + "provider": { + "covert-gpt": { + "npm": "@ai-sdk/openai-compatible", + "name": "covert-gpt (LiteLLM)", + "options": { + "baseURL": "https://llm.covert-gpt.com/v1", + "apiKey": "{env:LITELLM_KEY}" + }, + "models": {} + } + }, "mcp": { - "gitea": { + "covert-gpt": { "type": "remote", - "url": "https://llm.covert-gpt.com/mcp/gitea", + "url": "https://llm.covert-gpt.com/mcp/", "enabled": true, "headers": { "Authorization": "Bearer {env:LITELLM_KEY}" diff --git a/mcp-config/sync.sh b/mcp-config/sync.sh index fc73224..9e60dee 100644 --- a/mcp-config/sync.sh +++ b/mcp-config/sync.sh @@ -1,6 +1,6 @@ #!/usr/bin/env sh -# mcp-sync — refresh the shared LiteLLM-gateway MCP config on the laptop and -# export the user's LiteLLM key into the environment. +# 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: @@ -10,14 +10,21 @@ # # What it does: # 1. Fetch the shared opencode.gateway.json from Gitea (the single source of -# truth for which gateway MCP servers exist). The file is identical for -# all users — the per-user key is injected via {env:LITELLM_KEY}, never -# baked into the file. -# 2. Drop it at ~/.config/opencode/opencode.gateway.json. opencode MERGES -# config files, so this coexists with the user's own opencode.json and -# any local-only MCP servers (e.g. a client-side kubernetes pointed at -# their kubeconfig) — those are never touched. -# 3. Export LITELLM_KEY from ~/.config/mcp-sync/key so {env:LITELLM_KEY} +# 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) ------------------------- @@ -25,40 +32,14 @@ MCP_SYNC_RAW_URL="${MCP_SYNC_RAW_URL:-https://git.nic-oconnor.com/public/dotfile 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}" -# Throttle: only re-pull if the cached copy is older than this many seconds -# (default 1h). Keeps every new shell from hammering Gitea. +# 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}" -# --- 1+2. refresh the gateway config (throttled, non-fatal) --------------- -_mcp_sync_fetch() { - mkdir -p "$(dirname "$MCP_SYNC_DEST")" || return 0 - - # Skip if we pulled recently (mtime within TTL). `find -mmin` is portable - # enough; fall back to always-fetch 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 - tmp="$(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 "$tmp" 2>/dev/null; then - # Only replace if it's non-empty and valid JSON (don't clobber good config - # with an error page). - if [ -s "$tmp" ] && { ! command -v python3 >/dev/null 2>&1 || python3 -c "import json,sys; json.load(open(sys.argv[1]))" "$tmp" 2>/dev/null; }; then - mv "$tmp" "$MCP_SYNC_DEST" 2>/dev/null || rm -f "$tmp" - else - rm -f "$tmp" - fi - else - rm -f "$tmp" - fi -} - -# --- 3. export the per-user key ------------------------------------------- +# --- 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. @@ -66,6 +47,94 @@ _mcp_sync_export_key() { [ -n "$LITELLM_KEY" ] && export LITELLM_KEY } -_mcp_sync_fetch +# --- 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 -unset -f _mcp_sync_fetch _mcp_sync_export_key 2>/dev/null || true +_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