Files
dotfiles/mcp-config/sync.sh
T
noconnorandClaude Opus 5 d42d0b29ff mcp-config: render gateway context limits into Hermes and pi, not just opencode
OpenAI's /v1/models schema carries no context window, so a client that only
lists models cannot know how large a request it may send, and oversizing one
is a hard ContextWindowExceededError 400 — vLLM counts prompt + max_tokens
against its window. opencode already read the window from /v1/model/info.
Hermes and pi never consult that endpoint: they take it from static config,
which was hand-copied and drifted. Hermes named a model the gateway had
stopped serving while its replacement (the one model with a SMALLER window)
had no entry; pi's reserve was half its maxTokens. Both fail only once a
session grows, which is why it looked like the model or the GPU.

This renders one /v1/model/info response into all three clients. It is not a
plugin per harness and needs no upstream change — it extends the renderer that
already translates the MCP servers into Hermes' schema, with the same
manifest discipline.

SAME SOURCE, THREE DIFFERENT NUMBERS, deliberately — each client means
something different by "context":

  opencode  limit.context = window - 1024   reserves limit.output and targets
                                            context - output, so needs headroom
  Hermes    context_length = window        omits max_tokens entirely; vLLM
                                            bounds output by what is left
  pi        maxTokens <= reserveTokens-2048 compacts at window - reserveTokens
                                            (compaction.js shouldCompact) then
                                            requests maxTokens

pi's maxTokens is derived FROM its reserve rather than the reserve being raised
to fit: reserveTokens is one global setting shared by every provider pi knows,
so raising it for this gateway would shrink everyone's usable context.

Safety properties, each tested in a sandbox against copies of the real
configs before touching live ones (11 cases):

- Ownership is per FIELD. A value is retracted only if this set it and the
  gateway stopped declaring it. Hand-added models and fields are never
  touched; a model removed upstream loses only what was rendered.
- Vision is retracted, not just added. The chart states that supports_vision
  is what makes clients send image parts, so if a backend falls back to
  language-only, the `image` input this added is taken back — but a user's own
  `image` on an entry is left alone.
- Raw /v1/model/info is cached on each successful fetch, because refresh is
  throttled and a throttled shell never fetches. A bad fetch (403 body, HTML
  error page, empty list, rows without model_name) never replaces a good cache.
- Never invents a window: claude-* declares none and is skipped. A gateway
  that suddenly declares no windows is treated as broken, not empty.
- One output cap (MCP_SYNC_OUTPUT_RESERVE_CAP) is read by opencode and pi, so
  the clients cannot drift apart on it. Parses under dash as well as bash,
  since install.sh sources this under /bin/sh.

First live run found a bug nobody knew about: pi's Claude entries carried
maxTokens 64000 against a 34816 reserve, so claude-haiku-4-5 sent
(200000 - 34816) + 64000 = 229184 tokens at a 200000 window — still broken
after the earlier manual reserve fix, which only covered 32768. Now 32768, and
every served model satisfies the invariant. It also gave Hermes limits for the
Claude routes it had none for.

hermes/gateway.yaml: the static map is marked as a first-boot seed that the
next sync overwrites, so nobody hand-edits it believing it is authoritative.
The source of truth is model_info.max_input_tokens in ai-k8s-iac's
charts/inference/values.yaml. README documents all of the above.

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

955 lines
41 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}"
# Model limits for the non-opencode clients. The gateway's /v1/model/info is
# cached here on every successful fetch, because the refresh above is throttled
# and a throttled shell never fetches — the renderers must still have something
# to render from.
MCP_SYNC_MODELINFO_CACHE="${MCP_SYNC_MODELINFO_CACHE:-$MCP_SYNC_HOME/model-info.json}"
# Per-model FIELDS we set in Hermes last time, so a field can be retracted
# without touching fields the user set by hand. Separate from the MCP manifest.
MCP_SYNC_HERMES_MODELS_MANIFEST="${MCP_SYNC_HERMES_MODELS_MANIFEST:-$MCP_SYNC_HOME/hermes-models-managed.json}"
# pi. Set MCP_SYNC_PI_MODELS to empty to opt out entirely.
MCP_SYNC_PI_MODELS="${MCP_SYNC_PI_MODELS-$HOME/.pi/agent/models.json}"
MCP_SYNC_PI_SETTINGS="${MCP_SYNC_PI_SETTINGS:-$HOME/.pi/agent/settings.json}"
MCP_SYNC_PI_MANIFEST="${MCP_SYNC_PI_MANIFEST:-$MCP_SYNC_HOME/pi-managed.json}"
# The most output any client is told it may reserve/request. ONE value, read by
# the opencode, Hermes and pi renderers alike, so they cannot drift apart on it.
MCP_SYNC_OUTPUT_RESERVE_CAP="${MCP_SYNC_OUTPUT_RESERVE_CAP:-32768}"
# 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
# Cache it for the Hermes and pi renderers — but only a response that really
# is model info. curl -f already rejects a 403, yet a 200 carrying an error
# page or an empty list is possible, and caching that would strip every
# client's limits until the next good fetch. A bad fetch leaves the last good
# cache in place.
if [ -n "$_info" ] && command -v python3 >/dev/null 2>&1; then
MCP_SYNC_PY_INFO="$_info" MCP_SYNC_PY_CACHE="$MCP_SYNC_MODELINFO_CACHE" \
python3 - <<'PYEOF' 2>/dev/null || true
import json, os, tempfile
try:
data = json.loads(os.environ["MCP_SYNC_PY_INFO"])
rows = data.get("data")
except Exception:
raise SystemExit(0)
if not isinstance(rows, list) or not any(
isinstance(r, dict) and r.get("model_name") for r in rows):
raise SystemExit(0)
cache = os.environ["MCP_SYNC_PY_CACHE"]
d = os.path.dirname(cache) or "."
os.makedirs(d, exist_ok=True)
fd, tmp = tempfile.mkstemp(dir=d)
with os.fdopen(fd, "w") as f:
json.dump({"data": rows}, f)
os.replace(tmp, cache)
PYEOF
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" \
MCP_SYNC_PY_OUTPUT_CAP="$MCP_SYNC_OUTPUT_RESERVE_CAP" \
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 = int(os.environ.get("MCP_SYNC_PY_OUTPUT_CAP") or 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
}
# --- render the gateway's MODEL LIMITS into Hermes' config (non-fatal) ------
#
# WHY THIS EXISTS: OpenAI's /v1/models schema carries no context window, so a
# client that only lists models cannot know how big a request it may send.
# opencode gets the window from /v1/model/info above. Hermes does not consult
# that endpoint at all — it takes context_length from a static `models:` map
# under its custom provider — so the number used to be hand-copied into
# hermes/gateway.yaml and kept "in sync" by nobody. It drifted: the map named a
# model the gateway had stopped serving, while the model that replaced it (and
# the only one with a SMALLER window than its neighbours) had no entry at all.
# Short turns were fine; long tasks hit ContextWindowExceededError 400s.
#
# NO MARGIN, deliberately, and this is the one number that differs from the
# opencode renderer. opencode reserves limit.output and targets an input of
# context - output, so it needs headroom. Hermes omits max_tokens entirely, so
# vLLM bounds the output by whatever context is left: the raw window is right.
#
# OWNERSHIP IS PER FIELD, not per model. The manifest records which fields this
# set on which model. A field is retracted only if WE set it and the gateway no
# longer declares it (a model removed upstream, or supports_vision flipped off
# because a backend fell back to language-only — see the drift warning on
# supports_vision in ai-k8s-iac's values.yaml). Anything the user typed into the
# map by hand was never in the manifest and is never touched.
#
# Only fills in the provider install.sh created; never creates one.
_mcp_sync_hermes_models() {
[ -n "${MCP_SYNC_HERMES_CONFIG:-}" ] || return 0
[ -f "$MCP_SYNC_HERMES_CONFIG" ] || return 0
[ -f "$MCP_SYNC_MODELINFO_CACHE" ] || return 0
command -v python3 >/dev/null 2>&1 || return 0
python3 -c 'import yaml' >/dev/null 2>&1 || return 0 # warned about by _mcp_sync_hermes
MCP_SYNC_PY_CACHE="$MCP_SYNC_MODELINFO_CACHE" \
MCP_SYNC_PY_HERMES="$MCP_SYNC_HERMES_CONFIG" \
MCP_SYNC_PY_MANIFEST="$MCP_SYNC_HERMES_MODELS_MANIFEST" \
MCP_SYNC_PY_PROVIDER="$MCP_SYNC_PROVIDER" \
python3 - <<'PYEOF' 2>/dev/null || true
import json, os, tempfile
import yaml
provider = os.environ["MCP_SYNC_PY_PROVIDER"]
dest = os.environ["MCP_SYNC_PY_HERMES"]
manifest_path = os.environ["MCP_SYNC_PY_MANIFEST"]
try:
with open(os.environ["MCP_SYNC_PY_CACHE"]) as f:
rows = json.load(f).get("data") or []
except Exception:
raise SystemExit(0)
rendered = {}
for r in rows:
if not isinstance(r, dict):
continue
name = r.get("model_name")
mi = r.get("model_info") or {}
window = mi.get("max_input_tokens")
# A wildcard route (claude-*) declares no window. Never invent one: a guessed
# context_length is worse than letting Hermes use its own default.
if not name or not isinstance(window, int) or window <= 0:
continue
fields = {"context_length": window}
if mi.get("supports_vision") is True:
fields["supports_vision"] = True
rendered[name] = fields
# A gateway that suddenly declares no windows at all is broken, not empty. Do
# not treat that as "retract everything".
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) or not isinstance(cfg.get("custom_providers"), list):
raise SystemExit(0)
target = next((p for p in cfg["custom_providers"]
if isinstance(p, dict) and p.get("name") == provider), None)
if target is None:
raise SystemExit(0)
models = target.get("models")
if not isinstance(models, dict):
models = {}
before = json.dumps(models, sort_keys=True, default=str)
try:
with open(manifest_path) as f:
prev = json.load(f)
prev = prev if isinstance(prev, dict) else {}
except Exception:
prev = {}
for name, owned in prev.items():
entry = models.get(name)
if not isinstance(owned, list) or not isinstance(entry, dict):
continue
still = rendered.get(name, {})
for field in owned:
if field not in still:
entry.pop(field, None)
if not entry:
models.pop(name, None)
for name, fields in rendered.items():
entry = models.get(name)
entry = entry if isinstance(entry, dict) else {}
entry.update(fields)
models[name] = entry
if json.dumps(models, sort_keys=True, default=str) != before:
target["models"] = models
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)
d = os.path.dirname(manifest_path) or "."
os.makedirs(d, exist_ok=True)
fd, tmp = tempfile.mkstemp(dir=d)
with os.fdopen(fd, "w") as f:
json.dump({n: sorted(fl) for n, fl in rendered.items()}, f, sort_keys=True)
os.replace(tmp, manifest_path)
PYEOF
}
# --- render the gateway's MODEL LIMITS into pi's models.json (non-fatal) ----
#
# Same reason as Hermes: pi reads a static models.json and never asks the
# gateway, so contextWindow/maxTokens were hand-copied and drifted.
#
# THE INVARIANT, because getting this wrong is a guaranteed 400 rather than a
# degraded answer. pi compacts when contextTokens > contextWindow - reserveTokens
# (compaction.js shouldCompact) and then sends max_tokens = maxTokens. vLLM
# counts prompt + max_tokens against its window. So the largest request pi can
# make is (window - reserveTokens) + maxTokens, and it fits only if
#
# maxTokens <= reserveTokens - margin
#
# This used to be violated outright: pi's default reserve is 16384 while
# maxTokens was set to 32768, so a full context sent 196608 - 16384 + 32768 =
# 212992 tokens at a 196608 window.
#
# maxTokens is DERIVED FROM the reserve, rather than the reserve being raised to
# fit maxTokens. reserveTokens is one global setting shared by every provider pi
# knows about; raising it to suit this gateway would silently shrink usable
# context for all of them. Capping maxTokens per model keeps the fix local, and
# means raising reserveTokens in settings.json is how you buy back output.
#
# OWNERSHIP: the manifest records models this ADDED and models it added "image"
# to. contextWindow and maxTokens are always managed on gateway models, because
# a stale value there is the bug. Models the user created are never removed, and
# a user's own "image" is never taken away — only one this added.
#
# Only fills in the provider the user configured; never creates one, because a
# provider needs a baseUrl and key this does not own.
_mcp_sync_pi() {
[ -n "${MCP_SYNC_PI_MODELS:-}" ] || return 0
[ -f "$MCP_SYNC_PI_MODELS" ] || return 0
[ -f "$MCP_SYNC_MODELINFO_CACHE" ] || return 0
command -v python3 >/dev/null 2>&1 || return 0
MCP_SYNC_PY_CACHE="$MCP_SYNC_MODELINFO_CACHE" \
MCP_SYNC_PY_PI="$MCP_SYNC_PI_MODELS" \
MCP_SYNC_PY_PI_SETTINGS="$MCP_SYNC_PI_SETTINGS" \
MCP_SYNC_PY_MANIFEST="$MCP_SYNC_PI_MANIFEST" \
MCP_SYNC_PY_PROVIDER="$MCP_SYNC_PROVIDER" \
MCP_SYNC_PY_OUTPUT_CAP="$MCP_SYNC_OUTPUT_RESERVE_CAP" \
python3 - <<'PYEOF' 2>/dev/null || true
import json, os, tempfile
provider = os.environ["MCP_SYNC_PY_PROVIDER"]
dest = os.environ["MCP_SYNC_PY_PI"]
manifest_path = os.environ["MCP_SYNC_PY_MANIFEST"]
cap = int(os.environ.get("MCP_SYNC_PY_OUTPUT_CAP") or 32768)
# Headroom between pi's compaction point and the server's hard limit. pi counts
# from the server's own usage.totalTokens, so this only has to absorb the new
# message appended since the last measured turn.
MARGIN = 2048
# pi's built-in default when compaction.reserveTokens is unset.
PI_DEFAULT_RESERVE = 16384
try:
with open(os.environ["MCP_SYNC_PY_CACHE"]) as f:
rows = json.load(f).get("data") or []
except Exception:
raise SystemExit(0)
reserve = PI_DEFAULT_RESERVE
try:
with open(os.environ["MCP_SYNC_PY_PI_SETTINGS"]) as f:
r = (json.load(f).get("compaction") or {}).get("reserveTokens")
if isinstance(r, int) and r > 0:
reserve = r
except Exception:
pass
ceiling = reserve - MARGIN
if ceiling <= 0:
raise SystemExit(0) # no output is provably safe under this reserve; do not guess
rendered = {}
for r in rows:
if not isinstance(r, dict):
continue
name = r.get("model_name")
mi = r.get("model_info") or {}
window = mi.get("max_input_tokens")
if not name or not isinstance(window, int) or window <= 0:
continue
out = mi.get("max_output_tokens")
# LiteLLM's max_output_tokens is the WHOLE window for a vLLM backend (it caps
# total context, not output), so it is an upper bound, never a value to copy.
out = out if isinstance(out, int) and out > 0 else cap
rendered[name] = {
"contextWindow": window,
"maxTokens": min(out, cap, ceiling),
"vision": mi.get("supports_vision") is True,
}
if not rendered:
raise SystemExit(0)
try:
with open(dest) as f:
cfg = json.load(f)
except Exception:
raise SystemExit(0)
prov = (cfg.get("providers") or {}).get(provider) if isinstance(cfg, dict) else None
if not isinstance(prov, dict) or not isinstance(prov.get("models"), list):
raise SystemExit(0)
before = json.dumps(cfg, sort_keys=True)
try:
with open(manifest_path) as f:
prev = json.load(f)
prev = prev if isinstance(prev, dict) else {}
except Exception:
prev = {}
added_prev = set(prev.get("added") or [])
image_prev = set(prev.get("image") or [])
# Drop models WE added that the gateway no longer serves. A user-created entry
# is never removed, even if the gateway stops listing it.
models = [m for m in prov["models"]
if not (isinstance(m, dict) and m.get("id") in added_prev
and m.get("id") not in rendered)]
by_id = {m.get("id"): m for m in models if isinstance(m, dict)}
added_now, image_now = set(), set()
for name, f in rendered.items():
m = by_id.get(name)
if m is None:
m = {"id": name, "name": name, "reasoning": False, "input": ["text"],
"cost": {"input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0}}
models.append(m)
by_id[name] = m
added_now.add(name)
elif name in added_prev:
added_now.add(name)
m["contextWindow"] = f["contextWindow"]
m["maxTokens"] = f["maxTokens"]
inp = m.get("input") if isinstance(m.get("input"), list) else ["text"]
if f["vision"]:
if "image" not in inp:
inp = inp + ["image"]
image_now.add(name)
elif name in image_prev:
image_now.add(name)
elif name in image_prev and "image" in inp:
# We added it and the gateway no longer claims vision: take it back, or
# pi keeps sending image parts the backend will reject.
inp = [x for x in inp if x != "image"]
m["input"] = inp
prov["models"] = models
if json.dumps(cfg, sort_keys=True) != before:
d = os.path.dirname(dest) or "."
fd, tmp = tempfile.mkstemp(dir=d)
with os.fdopen(fd, "w") as f:
json.dump(cfg, f, indent=2)
f.write("\n")
os.chmod(tmp, 0o600) # the provider block can carry an apiKey
os.replace(tmp, dest)
d = os.path.dirname(manifest_path) or "."
os.makedirs(d, exist_ok=True)
fd, tmp = tempfile.mkstemp(dir=d)
with os.fdopen(fd, "w") as f:
json.dump({"added": sorted(added_now), "image": sorted(image_now)}, 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
# Then the gateway's model limits into the two clients that do not discover
# them on their own. Both run from the cache, so a throttled shell still
# converges on the last known good values.
_mcp_sync_hermes_models
_mcp_sync_pi
_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