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
611 lines
28 KiB
Bash
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
|