Files
dotfiles/mcp-config/sync.sh
T
noconnorandClaude Opus 5 9519cbe609 mcp-config: request /v1/model/info, so a versioned-route grant actually works
A virtual key's allowed_routes is matched on the exact path: granting
"/v1/model/info" does not also grant "/model/info". MCP_SYNC_GATEWAY has no
/v1 suffix, so this asked for the UNVERSIONED path — meaning a key scoped to
the versioned route still got a 403, still fell back to names-only, and still
produced a config with no context limits, with nothing in the output to say
so. Two correct-looking pieces of config that did not meet.

Tries the versioned path first and falls back to the unversioned one, so it
works whichever way a key happens to be scoped.

With the route granted this now discovers limits live instead of leaning on
the base-config floor. The floor stays: it is what keeps opencode correct if
the key ever loses the route again, and live values override it field by
field. Verified that the two agree — discovery returns exactly the 195584 /
261120 the floor declares — and the Claude models, which the floor does not
cover, picked up 998976 and 198976 for the first time.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FoDK4wwB1Twty5gwiriYLf
2026-09-12 00:18:30 -04:00

611 lines
28 KiB
Bash

#!/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. Read the shared opencode.gateway.json (the single source of truth: the
# covert-gpt provider + the client-side MCP servers). PREFERRED SOURCE IS
# THE LOCAL CHECKOUT — bootstrap.sh already cloned this repo over
# authenticated git, so re-downloading a file that is sitting on disk is
# both redundant and, historically, the ONLY reason the repo had to be
# world-readable. A box with no checkout falls back to the Gitea API with
# $GITEA_KEY. 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) -------------------------
# The local checkout is the preferred source for BOTH the config and the
# skills; these URLs are the no-checkout fallback only. They are Gitea *API*
# endpoints on purpose: the web /raw/ route answers an unauthorized request
# with a 200 and an HTML login page, which `curl -f` treats as success and
# happily writes over the file it was fetching. The API answers with an honest
# 401/403, so -f can be trusted.
MCP_SYNC_API_BASE="${MCP_SYNC_API_BASE:-https://git.nic-oconnor.com/api/v1/repos/public/dotfiles}"
MCP_SYNC_REF="${MCP_SYNC_REF:-main}"
MCP_SYNC_RAW_URL="${MCP_SYNC_RAW_URL:-$MCP_SYNC_API_BASE/raw/mcp-config/opencode.gateway.json?ref=$MCP_SYNC_REF}"
MCP_SYNC_HOME="${MCP_SYNC_HOME:-$HOME/.config/mcp-sync}"
# Where the checkout is. install.sh records the tree it ran from; failing that
# we look where bootstrap.sh clones to.
MCP_SYNC_REPO="${MCP_SYNC_REPO:-}"
MCP_SYNC_REPO_FILE="${MCP_SYNC_REPO_FILE:-$MCP_SYNC_HOME/source}"
MCP_SYNC_DOTFILES_DIR="${MCP_SYNC_DOTFILES_DIR:-$HOME/.dotfiles}"
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. Taken from the checkout like the config; with no checkout we fall
# back to the repo tarball and lift out its skills/ subtree (a skill is a folder
# of files, so unlike the config there is no single raw URL to pull). Set
# MCP_SYNC_SKILLS_SRC to a local directory (or file://) to pin it to one tree;
# see zsh/zshrc.local.example.
MCP_SYNC_SKILLS_SRC="${MCP_SYNC_SKILLS_SRC:-$MCP_SYNC_API_BASE/archive/$MCP_SYNC_REF.tar.gz}"
MCP_SYNC_SKILLS_SUBDIR="${MCP_SYNC_SKILLS_SUBDIR:-skills}"
MCP_SYNC_SKILLS_DEST="${MCP_SYNC_SKILLS_DEST:-$MCP_SYNC_HOME/skills}"
# Hermes: the SAME MCP servers, rendered into Hermes' own schema. Set
# MCP_SYNC_HERMES_CONFIG to empty to opt out entirely.
MCP_SYNC_HERMES_CONFIG="${MCP_SYNC_HERMES_CONFIG-$HOME/.hermes/config.yaml}"
# Names we rendered last time, so a server REMOVED upstream can be removed here
# without touching MCP servers the user added to Hermes by hand.
MCP_SYNC_HERMES_MANIFEST="${MCP_SYNC_HERMES_MANIFEST:-$MCP_SYNC_HOME/hermes-managed.json}"
# 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
}
# --- locate the local checkout (the preferred source) ----------------------
# Prints the repo root on stdout, or fails. Order: explicit override, the path
# install.sh recorded, then bootstrap.sh's clone location. A directory only
# counts if it actually holds the config, so a half-deleted tree falls through
# to the network rather than deploying nothing.
_mcp_sync_find_repo() {
for _cand in "$MCP_SYNC_REPO" \
"$([ -f "$MCP_SYNC_REPO_FILE" ] && cat "$MCP_SYNC_REPO_FILE" 2>/dev/null)" \
"$MCP_SYNC_DOTFILES_DIR"; do
[ -n "$_cand" ] || continue
if [ -f "$_cand/mcp-config/opencode.gateway.json" ]; then
printf '%s\n' "$_cand"
unset _cand
return 0
fi
done
unset _cand
return 1
}
# git, authenticated with the Gitea PAT when we have one, so a PRIVATE repo
# works. The token goes through the environment rather than `git -c ...`
# because argv is world-readable in `ps`.
_mcp_sync_git() {
if [ -n "${GITEA_KEY:-}" ]; then
GIT_CONFIG_COUNT=1 \
GIT_CONFIG_KEY_0=http.extraHeader \
GIT_CONFIG_VALUE_0="Authorization: token $GITEA_KEY" \
git "$@"
else
git "$@"
fi
}
# Refresh the checkout so a pushed config change actually lands. ONLY for the
# bootstrap-managed clone: a recorded DEV checkout (someone's ~/source/dotfiles)
# must never be pulled out from under them by a shell hook. Skipped unless the
# tree is clean and sitting on its default branch; quiet and non-fatal always.
_mcp_sync_pull() {
[ "$1" = "$MCP_SYNC_DOTFILES_DIR" ] || return 0
[ -d "$1/.git" ] || return 0
command -v git >/dev/null 2>&1 || return 0
[ -z "$(git -C "$1" status --porcelain 2>/dev/null)" ] || return 0
_branch="$(git -C "$1" symbolic-ref --short -q HEAD 2>/dev/null)" || return 0
_default="$(git -C "$1" symbolic-ref --short -q refs/remotes/origin/HEAD 2>/dev/null)"
_default="${_default#origin/}"
[ -n "$_default" ] || _default=main
[ "$_branch" = "$_default" ] || { unset _branch _default; return 0; }
unset _branch _default
_mcp_sync_git -C "$1" pull --ff-only --quiet >/dev/null 2>&1 || true
}
# curl, with the Gitea PAT when we have one. Used only on the no-checkout path.
_mcp_sync_curl() {
if [ -n "${GITEA_KEY:-}" ]; then
curl -fsS -m "${3:-8}" -H "Authorization: token $GITEA_KEY" "$1" -o "$2" 2>/dev/null
else
curl -fsS -m "${3:-8}" "$1" -o "$2" 2>/dev/null
fi
}
# --- 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
# 1. Get the shared base config. THE CHECKOUT WINS: it is already on disk,
# was fetched over authenticated git, and needs no network at all. Only a
# box with no checkout goes to Gitea. On total failure we fall back to the
# currently deployed file so model discovery can still refresh it.
_base=""
_repo="$(_mcp_sync_find_repo)" || _repo=""
if [ -n "$_repo" ]; then
_mcp_sync_pull "$_repo"
if [ -s "$_repo/mcp-config/opencode.gateway.json" ]; then
_base="$(mktemp 2>/dev/null)" || return 0
cp "$_repo/mcp-config/opencode.gateway.json" "$_base" 2>/dev/null ||
{ rm -f "$_base"; _base=""; }
fi
fi
if [ -z "$_base" ]; then
command -v curl >/dev/null 2>&1 || return 0
_base="$(mktemp 2>/dev/null)" || return 0
if ! _mcp_sync_curl "$MCP_SYNC_RAW_URL" "$_base" || [ ! -s "$_base" ]; then
rm -f "$_base"; _base=""
[ -f "$MCP_SYNC_DEST" ] || return 0
elif [ "$(head -c 1 "$_base")" != "{" ]; then
# A 200 that is not the config. The API endpoints we default to return an
# honest 401/403, so this only fires for an override still pointing at
# Gitea's web /raw/ route, which answers with a 200 HTML login page that
# -f cannot detect. The python step below would reject it and keep the
# last-good file — correct, but SILENTLY, leaving the config frozen with
# nothing to show for it. Say so instead.
rm -f "$_base"; _base=""
printf 'mcp-sync: %s did not return JSON (Gitea login page?).\n' "$MCP_SYNC_RAW_URL" >&2
printf 'mcp-sync: set GITEA_KEY, or point MCP_SYNC_REPO at a checkout.\n' >&2
# Bump the deployed file's mtime so the TTL throttles this warning to once
# an hour instead of printing it at the top of every single shell.
[ -f "$MCP_SYNC_DEST" ] && touch "$MCP_SYNC_DEST" 2>/dev/null
[ -f "$MCP_SYNC_DEST" ] || return 0
fi
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
# 2b. Enrich with per-model metadata. /v1/models returns ONLY ids — no context
# window, no cost, no capability flags — and opencode needs those or it
# runs every model on defaults (it cannot manage truncation without a
# context limit, and won't know a model can call tools). /model/info has
# them. Best-effort: a key still scoped to llm_api_routes 403s here, and
# we fall back to names-only, exactly as before.
# VERSIONED PATH FIRST. LiteLLM serves this metadata at both /model/info and
# /v1/model/info, but a virtual key's allowed_routes is matched on the exact
# path — granting "/v1/model/info" does NOT also grant "/model/info". Since
# MCP_SYNC_GATEWAY has no /v1 suffix, the unversioned path is what this used
# to request, so a key scoped to the versioned route still got a 403 and
# still silently fell back to names-only. Try both, versioned first, so it
# works whichever way the key is scoped.
_info=""
if [ -n "${LITELLM_KEY:-}" ]; then
for _mi_path in /v1/model/info /model/info; do
_info="$(curl -fsS -m 8 -H "Authorization: Bearer $LITELLM_KEY" \
"$MCP_SYNC_GATEWAY$_mi_path" 2>/dev/null)" || _info=""
[ -n "$_info" ] && break
done
unset _mi_path
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_MODELINFO="$_info" \
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
# LiteLLM /model/info -> opencode's per-model spec. Only fields the gateway
# actually reports are emitted; a model LiteLLM knows nothing about (a
# self-hosted vLLM entry with no model_info in the gateway's model_list) simply
# stays {}, which is what every model got before this existed.
# opencode's limit.output is a RESERVE it subtracts from limit.context to decide
# when to compact — NOT a server-side cap. LiteLLM reports max_output_tokens as
# the whole window for self-hosted vLLM (vLLM caps TOTAL context, not output
# separately — see charts/inference/values.yaml model_info), so copying it
# verbatim made opencode reserve the entire window and target an input of
# context - output. Cap the reserve at a generous agent-output budget instead.
OUTPUT_RESERVE_CAP = 32768
# opencode targets input == context - output EXACTLY, and the server counts one
# more token than opencode does, landing at context + 1 and getting a hard 400
# (ContextWindowExceededError). Observed on qwen3-coder-next: 196609 in + 65536
# out = 262145 vs a 262144 window, one token over, every time. Hold a margin
# back so opencode's target can never sit on the boundary.
CONTEXT_SAFETY_MARGIN = 1024
def spec_for(mi):
spec = {}
limit = {}
if mi.get("max_input_tokens"):
limit["context"] = max(1, mi["max_input_tokens"] - CONTEXT_SAFETY_MARGIN)
if mi.get("max_output_tokens"):
limit["output"] = min(mi["max_output_tokens"], OUTPUT_RESERVE_CAP)
if limit:
spec["limit"] = limit
# LiteLLM prices PER TOKEN; opencode (like models.dev) uses $ per 1M tokens.
cost = {}
for src, dst in (("input_cost_per_token", "input"),
("output_cost_per_token", "output")):
if mi.get(src) is not None:
cost[dst] = mi[src] * 1_000_000
if cost:
spec["cost"] = cost
for src, dst in (("supports_function_calling", "tool_call"),
("supports_reasoning", "reasoning"),
("supports_vision", "attachment")):
if mi.get(src) is not None:
spec[dst] = bool(mi[src])
return spec
info = {}
try:
for entry in (json.loads(os.environ.get("MCP_SYNC_MODELINFO") or "{}")
.get("data", [])):
name = entry.get("model_name")
if name:
info[name] = spec_for(entry.get("model_info") or {})
except Exception:
pass
# Per-model specs declared in the BASE config are a FLOOR; live /model/info
# overrides them field by field. This matters because the /model/info fetch
# above is best-effort and 403s for any key not scoped to management routes —
# which is the normal case for a day-to-day virtual key. Before this merge that
# fallback silently produced `{}` for every model, i.e. no context limit at
# all, and opencode then ran the whole gateway on its built-in defaults and
# walked straight into ContextWindowExceededError 400s on the one model whose
# window is smaller than those defaults. Declaring limits in the base file did
# not help either, because `prov["models"] = models` replaced the map wholesale.
# Live data still wins wherever it is present, so this reintroduces no drift.
base_models = {}
try:
base_models = (cfg.get("provider", {}).get(provider) or {}).get("models") or {}
except Exception:
pass
models = {}
try:
data = json.loads(os.environ.get("MCP_SYNC_MODELS") or "{}")
models = {m["id"]: {**(base_models.get(m["id"]) or {}), **dict(info.get(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
# No python3: the base config is deployed verbatim, so its "models" map
# stays EMPTY and opencode shows the provider with zero models. That looks
# like a broken gateway rather than a missing dependency, so say so — this
# is the one case worth breaking the silence for.
mv "$_base" "$MCP_SYNC_DEST" 2>/dev/null || true
_base=""
printf 'mcp-sync: python3 not found — models cannot be discovered.\n' >&2
printf 'mcp-sync: install python3 (bootstrap.sh does), then open a new shell.\n' >&2
fi
[ -n "$_base" ] && rm -f "$_base"
}
# --- render the SAME MCP servers into Hermes' config (non-fatal) -----------
#
# WHY THIS EXISTS: the MCP servers are defined ONCE, in opencode.gateway.json's
# `mcp` block (see README: "all MCP servers are defined here"). Hermes speaks a
# different schema, so without this it would need a second copy of every server
# — two definitions to drift apart, one pinned to gitea-mcp@0.0.10 and the other
# to whatever got typed. Instead we translate the one definition into Hermes'
# shape, the same way this file already translates LiteLLM's /model/info into
# opencode's. Add a server to Gitea once and BOTH clients get it.
#
# It is NOT a shared server process: MCP stdio servers are subprocesses, spawned
# per client over stdin/stdout and dying with it. Each client running its own
# `npx -y <pkg>` is the design — same package, same pinned version, same
# per-user credential, same identity. There is no daemon to share.
#
# Reads the DEPLOYED opencode config (post-refresh), so it always reflects what
# Gitea just served. No-ops silently when Hermes isn't installed.
_mcp_sync_hermes() {
[ -n "${MCP_SYNC_HERMES_CONFIG:-}" ] || return 0
# Hermes not installed (or never run) — nothing to configure, stay silent.
[ -f "$MCP_SYNC_HERMES_CONFIG" ] || return 0
[ -f "$MCP_SYNC_DEST" ] || return 0
command -v python3 >/dev/null 2>&1 || return 0
# PyYAML is needed to touch config.yaml at all. Unlike models, a missing MCP
# block is invisible rather than obviously broken, so say something.
if ! python3 -c 'import yaml' >/dev/null 2>&1; then
printf 'mcp-sync: python3 has no PyYAML — Hermes MCP servers not synced.\n' >&2
return 0
fi
MCP_SYNC_PY_SRC="$MCP_SYNC_DEST" \
MCP_SYNC_PY_HERMES="$MCP_SYNC_HERMES_CONFIG" \
MCP_SYNC_PY_MANIFEST="$MCP_SYNC_HERMES_MANIFEST" \
MCP_SYNC_PY_PROVIDER="$MCP_SYNC_PROVIDER" \
python3 - <<'PYEOF' 2>/dev/null || true
import json, os, re, tempfile
import yaml
src = os.environ["MCP_SYNC_PY_SRC"]
dest = os.environ["MCP_SYNC_PY_HERMES"]
manifest_path = os.environ["MCP_SYNC_PY_MANIFEST"]
try:
with open(src) as f:
oc = json.load(f)
except Exception:
raise SystemExit(0) # bad/incomplete opencode config: leave Hermes alone
# opencode's "{env:VAR}" -> Hermes' "${VAR}". Both are by-NAME references
# resolved at runtime, so no secret is ever written to either file.
_ENV_REF = re.compile(r"\{env:([A-Za-z_][A-Za-z0-9_]*)\}")
def _refs(v):
if isinstance(v, str):
return _ENV_REF.sub(r"${\1}", v)
if isinstance(v, dict):
return {k: _refs(x) for k, x in v.items()}
if isinstance(v, list):
return [_refs(x) for x in v]
return v
def spec_for(name, s):
if not isinstance(s, dict):
return None
# opencode defaults enabled to true; so does Hermes. Honour an explicit off.
if s.get("enabled") is False:
return None
kind = s.get("type", "local")
if kind == "remote":
url = s.get("url")
if not url:
return None
out = {"url": url}
headers = s.get("headers")
if isinstance(headers, dict) and headers:
out["headers"] = _refs(headers)
return out
# local/stdio. opencode: command is a LIST (argv). Hermes: command is a
# STRING and args is a separate list -- a list here stringifies to
# "['npx', '-y']" and fails at exec, so the split is mandatory.
cmd = s.get("command")
if not isinstance(cmd, list) or not cmd:
return None
out = {"command": str(cmd[0])}
if len(cmd) > 1:
out["args"] = [str(a) for a in cmd[1:]]
env = s.get("environment")
if isinstance(env, dict) and env:
# Hermes hands the child only a filtered baseline env, so every secret
# the server needs MUST be passed explicitly here.
out["env"] = _refs(env)
return out
rendered = {}
for name, s in (oc.get("mcp") or {}).items():
spec = spec_for(name, s)
if spec is not None:
rendered[name] = spec
if not rendered:
raise SystemExit(0)
try:
with open(dest) as f:
cfg = yaml.safe_load(f) or {}
except Exception:
raise SystemExit(0) # never overwrite a config we cannot parse
if not isinstance(cfg, dict):
raise SystemExit(0)
current = cfg.get("mcp_servers")
if not isinstance(current, dict):
current = {}
merged = dict(current)
# Drop servers we rendered before that upstream has since removed. Anything the
# user added to Hermes themselves was never in the manifest, so it survives.
try:
with open(manifest_path) as f:
prev = json.load(f)
prev = prev if isinstance(prev, list) else []
except Exception:
prev = []
for gone in set(prev) - set(rendered):
merged.pop(gone, None)
merged.update(rendered)
if merged != current:
cfg["mcp_servers"] = merged
d = os.path.dirname(dest) or "."
fd, tmp = tempfile.mkstemp(dir=d)
with os.fdopen(fd, "w") as f:
yaml.safe_dump(cfg, f, sort_keys=False, default_flow_style=False,
allow_unicode=True)
os.chmod(tmp, 0o600)
os.replace(tmp, dest)
os.makedirs(os.path.dirname(manifest_path) or ".", exist_ok=True)
fd, tmp = tempfile.mkstemp(dir=os.path.dirname(manifest_path) or ".")
with os.fdopen(fd, "w") as f:
json.dump(sorted(rendered), f)
os.replace(tmp, manifest_path)
PYEOF
}
# --- 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://}"
;;
*)
# Same rule as the config: the checkout is the source of truth when there
# is one, so no tarball download and no anonymous read required.
_repo="$(_mcp_sync_find_repo)" || _repo=""
if [ -n "$_repo" ] && [ -d "$_repo/$MCP_SYNC_SKILLS_SUBDIR" ]; then
_tree="$_repo/$MCP_SYNC_SKILLS_SUBDIR"
elif command -v curl >/dev/null 2>&1 && command -v tar >/dev/null 2>&1 &&
_mcp_sync_curl "$MCP_SYNC_SKILLS_SRC" "$_work/repo.tgz" 15 &&
[ -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
# After refresh: renders the just-deployed MCP servers into Hermes' schema.
_mcp_sync_hermes
_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"
# --- mcp-sync: force a refresh now, ignoring the TTL ------------------------
# The automatic sync above runs ONLY at shell start and is throttled to
# MCP_SYNC_TTL (1h). So a model or MCP server added on the gateway can take up
# to an hour AND a new terminal before it shows up in opencode -- which reads
# as "my new model isn't there" when in fact the gateway is fine and the local
# config is just stale. Run `mcp-sync` to pull it immediately.
#
# The name is `mcp-sync`, but the DEFINITION below uses an underscore: a hyphen
# is not a legal function name in POSIX sh, and dash rejects it at PARSE time --
# i.e. it aborts the whole file, not just this line. install.sh sources sync.sh
# under /bin/sh (dash on Debian-family), so a bare `mcp-sync() {` here silently
# killed install.sh's "deploy the config now" step for every install. An alias
# is resolved lazily, so it costs nothing to parse and keeps the typed name.
MCP_SYNC_SELF="${MCP_SYNC_SELF:-$HOME/.config/mcp-sync/sync.sh}"
mcp_sync() {
[ -f "$MCP_SYNC_SELF" ] || {
printf 'mcp-sync: %s not found\n' "$MCP_SYNC_SELF" >&2; return 1
}
# Re-source with the throttle disabled; this redefines and re-runs everything.
# shellcheck disable=SC1090
MCP_SYNC_TTL=0 . "$MCP_SYNC_SELF" || return 1
command -v python3 >/dev/null 2>&1 || { printf 'mcp-sync: refreshed.\n' >&2; return 0; }
MCP_SYNC_PY_DEST="$MCP_SYNC_DEST" MCP_SYNC_PY_PROVIDER="$MCP_SYNC_PROVIDER" \
python3 - <<'PYEOF' >&2
import json, os
cfg = json.load(open(os.environ["MCP_SYNC_PY_DEST"]))
models = cfg.get("provider", {}).get(os.environ["MCP_SYNC_PY_PROVIDER"], {}).get("models", {})
print(f"mcp-sync: {len(models)} models -> {', '.join(models) or '(none)'}")
PYEOF
}
alias mcp-sync=mcp_sync 2>/dev/null || true
unset -f _mcp_sync_export_key _mcp_sync_refresh _mcp_sync_hermes _mcp_sync_skills \
_mcp_sync_find_repo _mcp_sync_pull _mcp_sync_curl _mcp_sync_git 2>/dev/null || true
# This file is SOURCED into the user's interactive shell, so scratch variables
# would otherwise linger in their environment for the rest of the session.
unset _base _repo _models _info _work _tree _cand _branch _default 2>/dev/null || true