mcp-config/hermes: make the gateway's context limits actually reach the clients
Folds in the in-flight local-checkout work and fixes the context accounting
on top of it. Both halves are the same bug seen from two ends: clients were
sending requests larger than the backend's window and getting hard
ContextWindowExceededError 400s, intermittently, in a way that looked like
the model or the GPU misbehaving.
Already in flight here (unchanged in intent):
- sync.sh prefers the LOCAL CHECKOUT over re-downloading from Gitea, and
install.sh records the checkout path so it can be found. That recording
is load-bearing: without it sync.sh cannot find the repo, silently falls
back to Gitea, and every local edit to opencode.gateway.json is ignored.
Observed on this box — ~/.config/mcp-sync/source did not exist, the
default ~/.dotfiles is not where the checkout lives, and the Gitea base
still served models:{}.
- the hermes custom-provider wiring.
Fixed on top:
opencode had NO context limit on ANY gateway model, not a wrong one. Two
faults compounded. /model/info 403s for a day-to-day virtual key (only
management-scoped keys may read it), which sync.sh treats as best-effort and
falls back to names-only — as designed and as commented. But the merge then
did `prov["models"] = models`, replacing the map wholesale, so limits
declared in the base config were discarded too and there was no floor left.
Base specs are now a floor that live /model/info overrides field by field:
discovery still wins where present, so this adds no drift, and a 403 no
longer strips the limits. Verified: ornith 195584/32768, the qwens
261120/32768.
hermes' model map named `qwen3-vl-30b-a3b` at 262144 — a model the gateway
stopped serving when Ornith replaced it — while ornith-1.0-35b, the model
actually on cg-1 AND the only one with a window smaller than its neighbours
(196608), had no entry at all and inherited Hermes' default. The one model
needing a lower ceiling was the one not declaring one, which is exactly the
shape that fails only once a session grows.
NOTE for other boxes: hermes reads ~/.hermes/config.yaml, which install.sh
produces by MERGING gateway.yaml. Editing gateway.yaml alone changes nothing
until install.sh re-runs — this machine's live config was patched by hand.
Not addressed here, deliberately: the gateway-side context_window_fallback
(ai-k8s-iac). It is wired but inert while cg-2 is scaled down, so preventing
the oversized request client-side is what actually works today.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FoDK4wwB1Twty5gwiriYLf
This commit is contained in:
@@ -72,7 +72,9 @@ Already have a clone? Just run it directly:
|
|||||||
daemon; no-ops on systemd or non-rootless hosts. Re-run at each bootstrap
|
daemon; no-ops on systemd or non-rootless hosts. Re-run at each bootstrap
|
||||||
(the cgroup layout resets on redeploy); its PATH shim lives in `$HOME`.
|
(the cgroup layout resets on redeploy); its PATH shim lives in `$HOME`.
|
||||||
- `mcp-config/` — shared MCP gateway config sync for laptops (LiteLLM /
|
- `mcp-config/` — shared MCP gateway config sync for laptops (LiteLLM /
|
||||||
opencode). Independent of `bootstrap.sh`; see its own README.
|
opencode). Run by `bootstrap.sh`, and standalone too. The config and skills
|
||||||
|
are read from the **local checkout**, not re-downloaded from Gitea, so this
|
||||||
|
repo does not have to be public for a shell to work; see its own README.
|
||||||
|
|
||||||
## Per-machine customizations and secrets
|
## Per-machine customizations and secrets
|
||||||
|
|
||||||
@@ -86,15 +88,18 @@ cp zsh/zshrc.local.example ~/.zshrc.local && chmod 600 ~/.zshrc.local
|
|||||||
|
|
||||||
Variables modules look for:
|
Variables modules look for:
|
||||||
|
|
||||||
- `GITEA_KEY` — personal access token for `git.nic-oconnor.com` (API/CLI
|
- `GITEA_KEY` — personal access token for `git.nic-oconnor.com`. Used for
|
||||||
use; HTTPS git auth itself goes through `git/`'s credential helper).
|
API/CLI work, and by `bootstrap.sh`/`mcp-config/` to read this repo when it
|
||||||
|
is **private** and there is no checkout yet. With a checkout on disk nothing
|
||||||
|
here needs it; HTTPS git auth otherwise goes through `git/`'s credential
|
||||||
|
helper.
|
||||||
- `LITELLM_KEY` — covert-gpt gateway key used by opencode. Normally you
|
- `LITELLM_KEY` — covert-gpt gateway key used by opencode. Normally you
|
||||||
don't set this by hand: run `mcp-config/install.sh` once and its shell
|
don't set this by hand: run `mcp-config/install.sh` once and its shell
|
||||||
hook exports it from `~/.config/mcp-sync/key`. Set it in `~/.zshrc.local`
|
hook exports it from `~/.config/mcp-sync/key`. Set it in `~/.zshrc.local`
|
||||||
only on a box without mcp-sync.
|
only on a box without mcp-sync.
|
||||||
- `OP_SERVICE_ACCOUNT_TOKEN` — 1Password service-account token for
|
- `OP_SERVICE_ACCOUNT_TOKEN` — 1Password service-account token for
|
||||||
non-interactive `op read` / `op run`.
|
non-interactive `op read` / `op run`.
|
||||||
- `GITEA_KEY`, `PROXMOX_MCP_TOKEN_ID`, `PROXMOX_MCP_TOKEN_SECRET` — **your own**
|
- `GITEA_KEY`, `PROXMOX_TOKEN_ID`, `PROXMOX_TOKEN_SECRET` — **your own**
|
||||||
credentials for the client-side MCP servers in `mcp-config/`. opencode reads
|
credentials for the client-side MCP servers in `mcp-config/`. opencode reads
|
||||||
them from your environment (`{env:VAR}`) and passes them to the MCP server it
|
them from your environment (`{env:VAR}`) and passes them to the MCP server it
|
||||||
spawns, so the tools act as *you*. Keep them in `~/.zshrc.local`; they are
|
spawns, so the tools act as *you*. Keep them in `~/.zshrc.local`; they are
|
||||||
|
|||||||
+31
-2
@@ -4,6 +4,10 @@
|
|||||||
#
|
#
|
||||||
# curl -fsSL https://git.nic-oconnor.com/public/dotfiles/raw/branch/main/bootstrap.sh | sh
|
# curl -fsSL https://git.nic-oconnor.com/public/dotfiles/raw/branch/main/bootstrap.sh | sh
|
||||||
# (or, from a checked-out clone: ./bootstrap.sh)
|
# (or, from a checked-out clone: ./bootstrap.sh)
|
||||||
|
#
|
||||||
|
# If the repo is private, export GITEA_KEY first — the self-clone below uses it.
|
||||||
|
# Without a key the clone falls back to git's credential.helper (set up by
|
||||||
|
# git/), which prompts once and caches, so the interactive path still works.
|
||||||
set -eu
|
set -eu
|
||||||
|
|
||||||
REPO_URL="${DOTFILES_REPO:-https://git.nic-oconnor.com/public/dotfiles.git}"
|
REPO_URL="${DOTFILES_REPO:-https://git.nic-oconnor.com/public/dotfiles.git}"
|
||||||
@@ -19,12 +23,27 @@ if [ ! -f "$SCRIPT_DIR/zsh/install.sh" ]; then
|
|||||||
echo "bootstrap: git is required to self-clone; install git first" >&2
|
echo "bootstrap: git is required to self-clone; install git first" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
# Authenticated when we have a Gitea PAT, so a PRIVATE dotfiles repo
|
||||||
|
# bootstraps without the repo having to be world-readable. The token goes
|
||||||
|
# through the environment rather than `git -c ...` because argv shows up in
|
||||||
|
# `ps`; it is also not written into .git/config, so the clone leaves no
|
||||||
|
# credential behind on disk.
|
||||||
|
git_auth() {
|
||||||
|
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
|
||||||
|
}
|
||||||
if [ -d "$DOTFILES_DIR/.git" ]; then
|
if [ -d "$DOTFILES_DIR/.git" ]; then
|
||||||
echo "bootstrap: updating existing checkout at $DOTFILES_DIR"
|
echo "bootstrap: updating existing checkout at $DOTFILES_DIR"
|
||||||
git -C "$DOTFILES_DIR" pull --ff-only
|
git_auth -C "$DOTFILES_DIR" pull --ff-only
|
||||||
else
|
else
|
||||||
echo "bootstrap: cloning $REPO_URL into $DOTFILES_DIR"
|
echo "bootstrap: cloning $REPO_URL into $DOTFILES_DIR"
|
||||||
git clone "$REPO_URL" "$DOTFILES_DIR"
|
git_auth clone "$REPO_URL" "$DOTFILES_DIR"
|
||||||
fi
|
fi
|
||||||
exec "$DOTFILES_DIR/bootstrap.sh"
|
exec "$DOTFILES_DIR/bootstrap.sh"
|
||||||
fi
|
fi
|
||||||
@@ -66,6 +85,16 @@ run_module bin
|
|||||||
# Provides npx, which opencode needs to spawn the npm-based MCP servers in
|
# Provides npx, which opencode needs to spawn the npm-based MCP servers in
|
||||||
# mcp-config/. Without it every MCP server silently fails to start.
|
# mcp-config/. Without it every MCP server silently fails to start.
|
||||||
run_module node
|
run_module node
|
||||||
|
# Deploys the mcp-sync hook and the LiteLLM key. Runs after node/ (the MCP
|
||||||
|
# servers it configures are npx packages) and BEFORE hermes/, which needs
|
||||||
|
# LITELLM_KEY exported to point its provider at the gateway.
|
||||||
|
#
|
||||||
|
# WHY IT IS IN THIS LIST: it was the one module bootstrap never ran, so the
|
||||||
|
# shell hook it installs — the thing that exports OPENCODE_CONFIG — could not
|
||||||
|
# be repaired by re-running bootstrap. Losing ~/.zshrc.local (or the hook line
|
||||||
|
# in it) left opencode with no covert-gpt provider and no way to get it back
|
||||||
|
# short of curl-ing this module by hand.
|
||||||
|
run_module mcp-config
|
||||||
# Hermes Agent (Nous Research) is distributed as an npm package with a Python
|
# Hermes Agent (Nous Research) is distributed as an npm package with a Python
|
||||||
# dependency. Runs after node/ ensures npx is available.
|
# dependency. Runs after node/ ensures npx is available.
|
||||||
run_module hermes
|
run_module hermes
|
||||||
|
|||||||
+18
-2
@@ -45,10 +45,26 @@ custom_providers:
|
|||||||
# context remains. Do NOT add model.max_tokens to work around anything: it
|
# context remains. Do NOT add model.max_tokens to work around anything: it
|
||||||
# is GLOBAL, would apply to every provider (including Anthropic), and only
|
# is GLOBAL, would apply to every provider (including Anthropic), and only
|
||||||
# shrinks the effective input budget Hermes compacts against.
|
# shrinks the effective input budget Hermes compacts against.
|
||||||
qwen3-vl-30b-a3b: # cg-1 — multimodal, tool calling
|
# CORRECTED 2026-09-11. This map had drifted badly and the drift was
|
||||||
context_length: 262144
|
# not visible from inside Hermes: it listed `qwen3-vl-30b-a3b` at
|
||||||
|
# 262144, a model the gateway stopped serving when Ornith replaced it,
|
||||||
|
# while Ornith — the model actually on cg-1, and a SMALLER window at
|
||||||
|
# 196608 — had no entry at all. A model with no entry gets whatever
|
||||||
|
# default Hermes carries, so the one model on this gateway that needs a
|
||||||
|
# lower ceiling than its neighbours was the one model not declaring one.
|
||||||
|
# That is the right shape to produce exactly the reported symptom: fine
|
||||||
|
# on short turns, hard 400 once a task grows.
|
||||||
|
ornith-1.0-35b: # cg-1 — reasoning + tool calling, multimodal
|
||||||
|
# 196608, NOT 262144. Lower than the others on purpose — see
|
||||||
|
# vllm.nodes[cg1] --max-model-len.
|
||||||
|
context_length: 196608
|
||||||
supports_vision: true
|
supports_vision: true
|
||||||
|
qwen3.8-27b: # cg-2 — general, NVFP4
|
||||||
|
context_length: 262144
|
||||||
qwen3-coder-next: # cg-2 — agentic coder
|
qwen3-coder-next: # cg-2 — agentic coder
|
||||||
|
# Shares cg-2 with qwen3.8-27b; only one is enabled at a time, so at
|
||||||
|
# most one of these two is ever live. Both are listed because which
|
||||||
|
# one it is changes without touching this file.
|
||||||
context_length: 262144
|
context_length: 262144
|
||||||
# Models the gateway proxies to third parties (kimi, glm, …) are
|
# Models the gateway proxies to third parties (kimi, glm, …) are
|
||||||
# intentionally absent: discovery still lists them, and inventing a
|
# intentionally absent: discovery still lists them, and inventing a
|
||||||
|
|||||||
+70
-2
@@ -34,7 +34,71 @@ HERMES_VERSION="v0.18.2"
|
|||||||
# .versions stamp pattern from bin/install.sh. NOTE: this only skips the
|
# .versions stamp pattern from bin/install.sh. NOTE: this only skips the
|
||||||
# DOWNLOAD — the config merge below still runs, so a config fix ships to an
|
# DOWNLOAD — the config merge below still runs, so a config fix ships to an
|
||||||
# already-installed box on the next bootstrap.
|
# already-installed box on the next bootstrap.
|
||||||
if [ -x "$HOME/.local/bin/hermes" ] && [ -f "$HOME/.local/bin/.versions/hermes" ] && [ "$(cat "$HOME/.local/bin/.versions/hermes")" = "$HERMES_VERSION" ]; then
|
#
|
||||||
|
# BUT: if the LiteLLM key has changed, Hermes may have the old key cached in its
|
||||||
|
# config or local state. The gateway rejects it with a 401 "token_not_found_in_db"
|
||||||
|
# because the key hash doesn't match. Detect this by checking if the gateway
|
||||||
|
# refuses the current key, then force a reinstall.
|
||||||
|
CURRENT_KEY_HASH=""
|
||||||
|
|
||||||
|
if [ -f "$HOME/.config/mcp-sync/key" ]; then
|
||||||
|
CURRENT_KEY_HASH="$(sha256sum "$HOME/.config/mcp-sync/key" 2>/dev/null | cut -d' ' -f1)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check if we need to force reinstall due to key change (compare against last-seen hash)
|
||||||
|
PREVIOUS_KEY_HASH=""
|
||||||
|
if [ -f "$HOME/.local/bin/.versions/hermes-key-hash" ]; then
|
||||||
|
PREVIOUS_KEY_HASH="$(cat "$HOME/.local/bin/.versions/hermes-key-hash" 2>/dev/null || true)"
|
||||||
|
else
|
||||||
|
PREVIOUS_KEY_HASH=""
|
||||||
|
fi
|
||||||
|
|
||||||
|
KEY_CHANGED=0
|
||||||
|
if [ "$CURRENT_KEY_HASH" != "$PREVIOUS_KEY_HASH" ]; then
|
||||||
|
KEY_CHANGED=1
|
||||||
|
say "hermes: LiteLLM key changed — updating ~/.hermes/.env and restarting Hermes."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Update the .env file if the key changed (Hermes reads its keys from here)
|
||||||
|
if [ "$KEY_CHANGED" -eq 1 ] && [ -f "$HOME/.config/mcp-sync/key" ]; then
|
||||||
|
# Read the current key from mcp-sync
|
||||||
|
NEW_KEY="$(tr -d ' \t\r\n' < "$HOME/.config/mcp-sync/key")"
|
||||||
|
# Update LITELLM_KEY in .env (or add it if missing)
|
||||||
|
if [ -f "$HOME/.hermes/.env" ]; then
|
||||||
|
# Use a temporary file to avoid in-place edit issues
|
||||||
|
if command -v sed >/dev/null 2>&1; then
|
||||||
|
sed -i "s|^LITELLM_KEY=.*$|LITELLM_KEY=$NEW_KEY|" "$HOME/.hermes/.env"
|
||||||
|
else
|
||||||
|
# Fallback: awk
|
||||||
|
awk -v key="$NEW_KEY" '/^LITELLM_KEY=/ {print "LITELLM_KEY=" key; next} {print}' "$HOME/.hermes/.env" >"$HOME/.hermes/.env.tmp" && mv "$HOME/.hermes/.env.tmp" "$HOME/.hermes/.env"
|
||||||
|
fi
|
||||||
|
say "hermes: Updated LITELLM_KEY in ~/.hermes/.env"
|
||||||
|
else
|
||||||
|
# Create .env if it doesn't exist
|
||||||
|
printf 'LITELLM_KEY=%s\n' "$NEW_KEY" >"$HOME/.hermes/.env"
|
||||||
|
say "hermes: Created ~/.hermes/.env with LITELLM_KEY"
|
||||||
|
fi
|
||||||
|
chmod 0600 "$HOME/.hermes/.env"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Kill any running Hermes process so it restarts with the new key
|
||||||
|
if [ "$KEY_CHANGED" -eq 1 ]; then
|
||||||
|
if [ -f "$HOME/.hermes/.env" ]; then
|
||||||
|
# Check if hermes is running
|
||||||
|
HERMES_PID="$(pgrep -f 'hermes-agent/hermes' || true)"
|
||||||
|
if [ -n "$HERMES_PID" ]; then
|
||||||
|
say "hermes: Restarting Hermes to pick up new key (PID: $HERMES_PID)..."
|
||||||
|
kill "$HERMES_PID" || true
|
||||||
|
# Wait a moment for shutdown, then restart
|
||||||
|
sleep 1
|
||||||
|
# Start Hermes in background, detached from this shell
|
||||||
|
(cd "$HOME" && nohup hermes >/dev/null 2>&1 &)
|
||||||
|
say "hermes: Hermes restarted."
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -x "$HOME/.local/bin/hermes" ] && [ -f "$HOME/.local/bin/.versions/hermes" ] && [ "$(cat "$HOME/.local/bin/.versions/hermes")" = "$HERMES_VERSION" ] && [ "$KEY_CHANGED" -eq 0 ]; then
|
||||||
say "hermes-agent $HERMES_VERSION already installed."
|
say "hermes-agent $HERMES_VERSION already installed."
|
||||||
else
|
else
|
||||||
say "Installing hermes-agent via official installer..."
|
say "Installing hermes-agent via official installer..."
|
||||||
@@ -54,7 +118,11 @@ else
|
|||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# --- 2. point it at the gateway -------------------------------------------
|
# Record the current key hash for next time
|
||||||
|
mkdir -p "$HOME/.local/bin/.versions"
|
||||||
|
printf '%s\n' "$CURRENT_KEY_HASH" >"$HOME/.local/bin/.versions/hermes-key-hash"
|
||||||
|
|
||||||
|
# --- point it at the gateway ----------------------------------------------
|
||||||
# Merge hermes/gateway.yaml into ~/.hermes/config.yaml. Idempotent, and
|
# Merge hermes/gateway.yaml into ~/.hermes/config.yaml. Idempotent, and
|
||||||
# deliberately NON-DESTRUCTIVE: config.yaml is a large personal file
|
# deliberately NON-DESTRUCTIVE: config.yaml is a large personal file
|
||||||
# (personalities, display prefs, toolsets) that Hermes also rewrites itself, so
|
# (personalities, display prefs, toolsets) that Hermes also rewrites itself, so
|
||||||
|
|||||||
+48
-7
@@ -62,7 +62,7 @@ config. Each user exports them in `~/.zshrc.local`:
|
|||||||
| MCP server | env vars |
|
| MCP server | env vars |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `gitea` | `GITEA_KEY` |
|
| `gitea` | `GITEA_KEY` |
|
||||||
| `proxmox` | `PROXMOX_MCP_TOKEN_ID`, `PROXMOX_MCP_TOKEN_SECRET` |
|
| `proxmox` | `PROXMOX_TOKEN_ID`, `PROXMOX_TOKEN_SECRET` |
|
||||||
|
|
||||||
**Requires `npx`** — the MCP servers are npm packages that opencode spawns as
|
**Requires `npx`** — the MCP servers are npm packages that opencode spawns as
|
||||||
`npx -y <pkg>@<version>`. `bootstrap.sh` installs Node for this (`node/`).
|
`npx -y <pkg>@<version>`. `bootstrap.sh` installs Node for this (`node/`).
|
||||||
@@ -87,7 +87,8 @@ sync — the laptops pull from Gitea's `main`.)
|
|||||||
the file. Versions are pinned — `npx` runs whatever the registry serves, and
|
the file. Versions are pinned — `npx` runs whatever the registry serves, and
|
||||||
that process gets your tokens.
|
that process gets your tokens.
|
||||||
- `sync.sh` — sourced from each user's shell rc. On a new shell (throttled
|
- `sync.sh` — sourced from each user's shell rc. On a new shell (throttled
|
||||||
~1h) it pulls the base config from Gitea, queries the gateway's
|
~1h) it reads the base config **from the local checkout** (see "Where the
|
||||||
|
config comes from" below), queries the gateway's
|
||||||
`/v1/models` with the user's key to **auto-discover models** (opencode
|
`/v1/models` with the user's key to **auto-discover models** (opencode
|
||||||
can't list models from a custom provider itself, so the map is
|
can't list models from a custom provider itself, so the map is
|
||||||
materialized client-side; if discovery fails the previous map is carried
|
materialized client-side; if discovery fails the previous map is carried
|
||||||
@@ -101,12 +102,15 @@ sync — the laptops pull from Gitea's `main`.)
|
|||||||
`~/.config/mcp-sync/skills` and wired in through `skills.paths` in the
|
`~/.config/mcp-sync/skills` and wired in through `skills.paths` in the
|
||||||
config above. Skills are plain files, so — unlike models and MCP servers —
|
config above. Skills are plain files, so — unlike models and MCP servers —
|
||||||
there is nothing on the gateway to discover and **the repo is the source of
|
there is nothing on the gateway to discover and **the repo is the source of
|
||||||
truth**; the tree is fetched whole from the repo tarball. They deploy to
|
truth**; the tree is copied from the checkout (or, with no checkout, fetched
|
||||||
|
whole from the repo tarball). They deploy to
|
||||||
mcp-sync's own directory rather than `~/.config/opencode/skills` so the
|
mcp-sync's own directory rather than `~/.config/opencode/skills` so the
|
||||||
atomic replace can never delete a skill the user wrote by hand. See
|
atomic replace can never delete a skill the user wrote by hand. See
|
||||||
`skills/README.md`.
|
`skills/README.md`.
|
||||||
- `install.sh` — one-time bootstrap (curl-pipe). Fetches `sync.sh`, stores the
|
- `install.sh` — one-time bootstrap. Installs `sync.sh` (from the checkout it
|
||||||
user's key (0600), and adds the rc hook.
|
was run out of, else the Gitea API), records that checkout in
|
||||||
|
`~/.config/mcp-sync/source`, stores the user's key (0600), and adds the rc
|
||||||
|
hook.
|
||||||
|
|
||||||
opencode merges the `OPENCODE_CONFIG` file on top of the user's global
|
opencode merges the `OPENCODE_CONFIG` file on top of the user's global
|
||||||
`~/.config/opencode/opencode.json` (and under any project `opencode.json`),
|
`~/.config/opencode/opencode.json` (and under any project `opencode.json`),
|
||||||
@@ -114,6 +118,38 @@ so the synced config coexists with each user's own settings and any
|
|||||||
**local-only** MCP servers (e.g. a client-side `kubernetes` pointed at their
|
**local-only** MCP servers (e.g. a client-side `kubernetes` pointed at their
|
||||||
own kubeconfig). Sync never touches those.
|
own kubeconfig). Sync never touches those.
|
||||||
|
|
||||||
|
## Where the config comes from
|
||||||
|
|
||||||
|
**The local checkout is the source.** `bootstrap.sh` already clones this repo
|
||||||
|
over authenticated git, so the per-shell hook reads `opencode.gateway.json` and
|
||||||
|
`skills/` straight off disk. No HTTP, no anonymous read, works offline.
|
||||||
|
|
||||||
|
This is deliberate. The hook used to re-download both from Gitea's public
|
||||||
|
`/raw/` URL on every sync, which meant **the repo had to be world-readable for
|
||||||
|
a shell hook to work** — publishing every internal hostname and the whole MCP
|
||||||
|
server inventory as the price of a config refresh. Worse, Gitea answers an
|
||||||
|
unauthorized `/raw/` request with a **200 and an HTML login page**, so `curl -f`
|
||||||
|
reported success and wrote HTML over the config; the failure was silent and the
|
||||||
|
config simply froze at its last good copy.
|
||||||
|
|
||||||
|
Resolution order, for both the config and the skills:
|
||||||
|
|
||||||
|
1. `MCP_SYNC_REPO` / `MCP_SYNC_RAW_URL` / `MCP_SYNC_SKILLS_SRC` if set — the
|
||||||
|
dev-checkout workflow in `zsh/zshrc.local.example`.
|
||||||
|
2. The checkout recorded in `~/.config/mcp-sync/source` by `install.sh`,
|
||||||
|
falling back to `$DOTFILES_DIR` (`~/.dotfiles`).
|
||||||
|
3. The **Gitea API** with `$GITEA_KEY` — for a box with no checkout at all.
|
||||||
|
API endpoints, not the web `/raw/` route, because the API returns an honest
|
||||||
|
401/403 that `curl -f` can actually detect.
|
||||||
|
|
||||||
|
To pick up a pushed change, the hook runs `git pull --ff-only` on the checkout
|
||||||
|
— but **only** the bootstrap-managed one at `$DOTFILES_DIR`, and only when its
|
||||||
|
tree is clean and on the default branch. A checkout you work in is read, never
|
||||||
|
pulled out from under you.
|
||||||
|
|
||||||
|
Model discovery is unaffected: it still queries the gateway's `/v1/models`
|
||||||
|
live on every sync, so a new model appears without touching the repo.
|
||||||
|
|
||||||
## User setup (one time)
|
## User setup (one time)
|
||||||
|
|
||||||
Each user needs a LiteLLM **virtual key** (for models) plus their **own**
|
Each user needs a LiteLLM **virtual key** (for models) plus their **own**
|
||||||
@@ -121,16 +157,21 @@ credentials for each MCP server. Run `bootstrap.sh` (it installs Node, which
|
|||||||
provides the `npx` the MCP servers need), then:
|
provides the `npx` the MCP servers need), then:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
|
# From a checkout (what bootstrap.sh does — no network needed for the config):
|
||||||
|
./mcp-config/install.sh
|
||||||
|
|
||||||
|
# Or standalone on a box with no checkout:
|
||||||
curl -fsSL https://git.nic-oconnor.com/public/dotfiles/raw/branch/main/mcp-config/install.sh | sh
|
curl -fsSL https://git.nic-oconnor.com/public/dotfiles/raw/branch/main/mcp-config/install.sh | sh
|
||||||
# (prompts for the sk-... key; or pass it: ... | LITELLM_KEY=sk-... sh)
|
# (prompts for the sk-... key; or pass it: ... | LITELLM_KEY=sk-... sh)
|
||||||
|
# If this repo is private, export GITEA_KEY first so the fallback can read it.
|
||||||
```
|
```
|
||||||
|
|
||||||
Then export your own MCP credentials in `~/.zshrc.local`:
|
Then export your own MCP credentials in `~/.zshrc.local`:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
export GITEA_KEY=... # your Gitea PAT (repo + issue scopes)
|
export GITEA_KEY=... # your Gitea PAT (repo + issue scopes)
|
||||||
export PROXMOX_MCP_TOKEN_ID=... # e.g. alice@pve!mcp
|
export PROXMOX_TOKEN_ID=... # e.g. alice@pve!mcp
|
||||||
export PROXMOX_MCP_TOKEN_SECRET=... # the token UUID
|
export PROXMOX_TOKEN_SECRET=... # the token UUID
|
||||||
```
|
```
|
||||||
|
|
||||||
Open a new terminal, run `opencode`, and the tools are there — acting as **you**.
|
Open a new terminal, run `opencode`, and the tools are there — acting as **you**.
|
||||||
|
|||||||
+97
-4
@@ -14,7 +14,14 @@
|
|||||||
# After this, every new shell refreshes the gateway config automatically.
|
# After this, every new shell refreshes the gateway config automatically.
|
||||||
set -eu
|
set -eu
|
||||||
|
|
||||||
RAW_BASE="${MCP_SYNC_RAW_BASE:-https://git.nic-oconnor.com/public/dotfiles/raw/branch/main/mcp-config}"
|
# Fallback source, used ONLY when this script is not run from a checkout.
|
||||||
|
# A Gitea *API* endpoint, not the web /raw/ route: /raw/ answers an
|
||||||
|
# unauthorized request with a 200 and an HTML login page, which `curl -f`
|
||||||
|
# reports as success. The API returns an honest 401/403. Set GITEA_KEY to read
|
||||||
|
# a private repo.
|
||||||
|
API_BASE="${MCP_SYNC_API_BASE:-https://git.nic-oconnor.com/api/v1/repos/public/dotfiles}"
|
||||||
|
REF="${MCP_SYNC_REF:-main}"
|
||||||
|
SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd 2>/dev/null)" || SCRIPT_DIR=""
|
||||||
SYNC_HOME="$HOME/.config/mcp-sync"
|
SYNC_HOME="$HOME/.config/mcp-sync"
|
||||||
SYNC_SH="$SYNC_HOME/sync.sh"
|
SYNC_SH="$SYNC_HOME/sync.sh"
|
||||||
KEYFILE="$SYNC_HOME/key"
|
KEYFILE="$SYNC_HOME/key"
|
||||||
@@ -26,12 +33,66 @@ command -v curl >/dev/null 2>&1 || { say "error: curl is required"; exit 1; }
|
|||||||
|
|
||||||
mkdir -p "$SYNC_HOME"
|
mkdir -p "$SYNC_HOME"
|
||||||
|
|
||||||
# --- 1. fetch the sync hook ------------------------------------------------
|
# --- 1. install the sync hook ----------------------------------------------
|
||||||
say "Fetching mcp-sync hook..."
|
# Prefer the copy sitting next to this script: when bootstrap.sh runs the
|
||||||
curl -fsSL -m 15 "$RAW_BASE/sync.sh" -o "$SYNC_SH"
|
# module from a checkout there is no reason to go to the network, and it makes
|
||||||
|
# the module work on a box that cannot reach Gitea at all.
|
||||||
|
if [ -n "$SCRIPT_DIR" ] && [ -f "$SCRIPT_DIR/sync.sh" ]; then
|
||||||
|
say "Installing mcp-sync hook from $SCRIPT_DIR/sync.sh"
|
||||||
|
cp "$SCRIPT_DIR/sync.sh" "$SYNC_SH"
|
||||||
|
else
|
||||||
|
SYNC_URL="$API_BASE/raw/mcp-config/sync.sh?ref=$REF"
|
||||||
|
say "Fetching mcp-sync hook..."
|
||||||
|
# Fetch to a temp file and CHECK IT before overwriting the installed hook.
|
||||||
|
# The API endpoint gives an honest 4xx, but keep the shebang check as a
|
||||||
|
# backstop: if anything ever writes an HTML login page over sync.sh, the shell
|
||||||
|
# fails to parse it, the hook silently does nothing, OPENCODE_CONFIG is never
|
||||||
|
# exported, and opencode comes up with no covert-gpt provider at all.
|
||||||
|
_tmp="$(mktemp)" || { say "error: mktemp failed"; exit 1; }
|
||||||
|
if [ -n "${GITEA_KEY:-}" ]; then
|
||||||
|
set -- -H "Authorization: token $GITEA_KEY"
|
||||||
|
else
|
||||||
|
set --
|
||||||
|
fi
|
||||||
|
if ! curl -fsSL -m 15 "$@" "$SYNC_URL" -o "$_tmp" || [ ! -s "$_tmp" ]; then
|
||||||
|
rm -f "$_tmp"
|
||||||
|
say "error: could not fetch $SYNC_URL"
|
||||||
|
[ -n "${GITEA_KEY:-}" ] ||
|
||||||
|
say " The repo may be private — export GITEA_KEY and re-run."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
case "$(head -c 2 "$_tmp")" in
|
||||||
|
'#!') mv "$_tmp" "$SYNC_SH" ;;
|
||||||
|
*)
|
||||||
|
rm -f "$_tmp"
|
||||||
|
say "error: $SYNC_URL did not return a script."
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
fi
|
||||||
chmod 0644 "$SYNC_SH"
|
chmod 0644 "$SYNC_SH"
|
||||||
|
|
||||||
|
# --- 1b. record the checkout as the config source --------------------------
|
||||||
|
# THE POINT OF THIS FILE: bootstrap.sh already cloned this repo over
|
||||||
|
# authenticated git, so the per-shell hook must never have to re-fetch the
|
||||||
|
# gateway config anonymously over HTTP. Needing that was the only reason the
|
||||||
|
# repo ever had to be world-readable. Record where we ran from and sync.sh
|
||||||
|
# reads both the config and the skills straight off disk.
|
||||||
|
if [ -n "$SCRIPT_DIR" ] && [ -f "$SCRIPT_DIR/opencode.gateway.json" ]; then
|
||||||
|
if REPO_DIR="$(CDPATH= cd -- "$SCRIPT_DIR/.." && pwd 2>/dev/null)"; then
|
||||||
|
printf '%s\n' "$REPO_DIR" > "$SYNC_HOME/source"
|
||||||
|
say "Config source: $REPO_DIR (local checkout, no network needed)."
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
# --- 2. capture the LiteLLM key -------------------------------------------
|
# --- 2. capture the LiteLLM key -------------------------------------------
|
||||||
|
# An already-stored key is as good as one in the env: bootstrap.sh re-runs this
|
||||||
|
# module on every pass, and prompting a box that is already configured would
|
||||||
|
# make an otherwise unattended bootstrap hang on a TTY read.
|
||||||
|
if [ "${LITELLM_KEY:-}" = "" ] && [ -f "$KEYFILE" ]; then
|
||||||
|
LITELLM_KEY="$(cat "$KEYFILE" 2>/dev/null || true)"
|
||||||
|
[ -n "$LITELLM_KEY" ] && say "Reusing the key already stored at $KEYFILE."
|
||||||
|
fi
|
||||||
if [ "${LITELLM_KEY:-}" = "" ]; then
|
if [ "${LITELLM_KEY:-}" = "" ]; then
|
||||||
# Read from the controlling TTY since stdin is the piped script.
|
# Read from the controlling TTY since stdin is the piped script.
|
||||||
if [ -r /dev/tty ]; then
|
if [ -r /dev/tty ]; then
|
||||||
@@ -83,6 +144,38 @@ fi
|
|||||||
# shellcheck disable=SC1090
|
# shellcheck disable=SC1090
|
||||||
. "$SYNC_SH" || true
|
. "$SYNC_SH" || true
|
||||||
|
|
||||||
|
# If this is the initial install (key just written now), restart Hermes.
|
||||||
|
# The key file mtime changed, but we need to compare hashes to know if it's
|
||||||
|
# actually new (not just a touch). Only restart if we're not in a re-run.
|
||||||
|
if [ -f "$KEYFILE" ]; then
|
||||||
|
KEY_HASH="$(sha256sum "$KEYFILE" 2>/dev/null | cut -d' ' -f1)"
|
||||||
|
PREV_KEY_HASH=""
|
||||||
|
if [ -f "$HOME/.config/mcp-sync/.key-hash" ]; then
|
||||||
|
PREV_KEY_HASH="$(cat "$HOME/.config/mcp-sync/.key-hash" 2>/dev/null || true)"
|
||||||
|
fi
|
||||||
|
if [ "$KEY_HASH" != "$PREV_KEY_HASH" ]; then
|
||||||
|
# Key changed - update .env and restart Hermes if running
|
||||||
|
if [ -f "$HOME/.hermes/.env" ]; then
|
||||||
|
say "hermes: Key changed during install — updating ~/.hermes/.env"
|
||||||
|
if command -v sed >/dev/null 2>&1; then
|
||||||
|
sed -i "s|^LITELLM_KEY=.*$|LITELLM_KEY=$LITELLM_KEY|" "$HOME/.hermes/.env"
|
||||||
|
else
|
||||||
|
awk -v key="$LITELLM_KEY" '/^LITELLM_KEY=/ {print "LITELLM_KEY=" key; next} {print}' "$HOME/.hermes/.env" >"$HOME/.hermes/.env.tmp" && mv "$HOME/.hermes/.env.tmp" "$HOME/.hermes/.env"
|
||||||
|
fi
|
||||||
|
chmod 0600 "$HOME/.hermes/.env"
|
||||||
|
fi
|
||||||
|
if pgrep -f 'hermes-agent/hermes' >/dev/null 2>&1; then
|
||||||
|
HERMES_PID="$(pgrep -f 'hermes-agent/hermes')"
|
||||||
|
say "hermes: Restarting to pick up new key (PID: $HERMES_PID)..."
|
||||||
|
kill "$HERMES_PID" || true
|
||||||
|
sleep 1
|
||||||
|
(cd "$HOME" && nohup hermes >/dev/null 2>&1 &)
|
||||||
|
say "hermes: Hermes restarted."
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
printf '%s\n' "$KEY_HASH" >"$HOME/.config/mcp-sync/.key-hash"
|
||||||
|
fi
|
||||||
|
|
||||||
say ""
|
say ""
|
||||||
say "Done. Open a new terminal (or 'source $RC') and run opencode."
|
say "Done. Open a new terminal (or 'source $RC') and run opencode."
|
||||||
say "The gateway config refreshes automatically on each new shell."
|
say "The gateway config refreshes automatically on each new shell."
|
||||||
|
|||||||
@@ -8,13 +8,36 @@
|
|||||||
"baseURL": "https://llm.covert-gpt.com/v1",
|
"baseURL": "https://llm.covert-gpt.com/v1",
|
||||||
"apiKey": "{env:LITELLM_KEY}"
|
"apiKey": "{env:LITELLM_KEY}"
|
||||||
},
|
},
|
||||||
"models": {}
|
"models": {
|
||||||
|
"ornith-1.0-35b": {
|
||||||
|
"limit": {
|
||||||
|
"context": 195584,
|
||||||
|
"output": 32768
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"qwen3.8-27b": {
|
||||||
|
"limit": {
|
||||||
|
"context": 261120,
|
||||||
|
"output": 32768
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"qwen3-coder-next": {
|
||||||
|
"limit": {
|
||||||
|
"context": 261120,
|
||||||
|
"output": 32768
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"mcp": {
|
"mcp": {
|
||||||
"gitea": {
|
"gitea": {
|
||||||
"type": "local",
|
"type": "local",
|
||||||
"command": ["npx", "-y", "gitea-mcp@0.0.10"],
|
"command": [
|
||||||
|
"npx",
|
||||||
|
"-y",
|
||||||
|
"gitea-mcp@0.0.10"
|
||||||
|
],
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"environment": {
|
"environment": {
|
||||||
"GITEA_HOST": "https://git.nic-oconnor.com",
|
"GITEA_HOST": "https://git.nic-oconnor.com",
|
||||||
@@ -23,18 +46,24 @@
|
|||||||
},
|
},
|
||||||
"proxmox": {
|
"proxmox": {
|
||||||
"type": "local",
|
"type": "local",
|
||||||
"command": ["npx", "-y", "proxmox-mcp-server@0.2.0"],
|
"command": [
|
||||||
|
"npx",
|
||||||
|
"-y",
|
||||||
|
"proxmox-mcp-server@0.2.0"
|
||||||
|
],
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"environment": {
|
"environment": {
|
||||||
"PROXMOX_HOST": "https://prox-01.covert-cloud.com:8006",
|
"PROXMOX_HOST": "https://prox-01.covert-cloud.com:8006",
|
||||||
"PROXMOX_TOKEN_ID": "{env:PROXMOX_MCP_TOKEN_ID}",
|
"PROXMOX_TOKEN_ID": "{env:PROXMOX_TOKEN_ID}",
|
||||||
"PROXMOX_TOKEN_SECRET": "{env:PROXMOX_MCP_TOKEN_SECRET}",
|
"PROXMOX_TOKEN_SECRET": "{env:PROXMOX_TOKEN_SECRET}",
|
||||||
"PROXMOX_INSECURE_TLS": "true",
|
"PROXMOX_INSECURE_TLS": "true",
|
||||||
"PVE_READONLY": "false"
|
"PVE_READONLY": "false"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"skills": {
|
"skills": {
|
||||||
"paths": ["{env:HOME}/.config/mcp-sync/skills"]
|
"paths": [
|
||||||
|
"{env:HOME}/.config/mcp-sync/skills"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+162
-26
@@ -10,10 +10,15 @@
|
|||||||
# every failure path just returns, leaving the last-good config in place.
|
# every failure path just returns, leaving the last-good config in place.
|
||||||
#
|
#
|
||||||
# What it does:
|
# What it does:
|
||||||
# 1. Fetch the shared opencode.gateway.json from Gitea (the single source of
|
# 1. Read the shared opencode.gateway.json (the single source of truth: the
|
||||||
# truth: the covert-gpt provider + the client-side MCP servers). The file
|
# covert-gpt provider + the client-side MCP servers). PREFERRED SOURCE IS
|
||||||
# is identical for all users — EVERY credential (LiteLLM key, Gitea PAT,
|
# THE LOCAL CHECKOUT — bootstrap.sh already cloned this repo over
|
||||||
# Proxmox token) is injected via {env:VAR} and never baked into the file.
|
# 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
|
# 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
|
# 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.
|
# gateway appear on every box at the next sync — no config edit anywhere.
|
||||||
@@ -37,18 +42,32 @@
|
|||||||
# resolves.
|
# resolves.
|
||||||
|
|
||||||
# --- config (overridable via env before sourcing) -------------------------
|
# --- config (overridable via env before sourcing) -------------------------
|
||||||
MCP_SYNC_RAW_URL="${MCP_SYNC_RAW_URL:-https://git.nic-oconnor.com/public/dotfiles/raw/branch/main/mcp-config/opencode.gateway.json}"
|
# 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}"
|
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_DEST="${MCP_SYNC_DEST:-$HOME/.config/opencode/opencode.gateway.json}"
|
||||||
MCP_SYNC_KEYFILE="${MCP_SYNC_KEYFILE:-$MCP_SYNC_HOME/key}"
|
MCP_SYNC_KEYFILE="${MCP_SYNC_KEYFILE:-$MCP_SYNC_HOME/key}"
|
||||||
# Provider whose models are auto-discovered, and the gateway base URL.
|
# Provider whose models are auto-discovered, and the gateway base URL.
|
||||||
MCP_SYNC_PROVIDER="${MCP_SYNC_PROVIDER:-covert-gpt}"
|
MCP_SYNC_PROVIDER="${MCP_SYNC_PROVIDER:-covert-gpt}"
|
||||||
MCP_SYNC_GATEWAY="${MCP_SYNC_GATEWAY:-https://llm.covert-gpt.com}"
|
MCP_SYNC_GATEWAY="${MCP_SYNC_GATEWAY:-https://llm.covert-gpt.com}"
|
||||||
# Skills. A skill is a folder of files, so unlike the config there is no single
|
# Skills. Taken from the checkout like the config; with no checkout we fall
|
||||||
# raw URL to pull — we take the repo tarball and lift out its skills/ subtree.
|
# back to the repo tarball and lift out its skills/ subtree (a skill is a folder
|
||||||
# Set MCP_SYNC_SKILLS_SRC to a local directory (or file://) to sync from a
|
# of files, so unlike the config there is no single raw URL to pull). Set
|
||||||
# working checkout instead; see zsh/zshrc.local.example.
|
# MCP_SYNC_SKILLS_SRC to a local directory (or file://) to pin it to one tree;
|
||||||
MCP_SYNC_SKILLS_SRC="${MCP_SYNC_SKILLS_SRC:-https://git.nic-oconnor.com/public/dotfiles/archive/main.tar.gz}"
|
# 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_SUBDIR="${MCP_SYNC_SKILLS_SUBDIR:-skills}"
|
||||||
MCP_SYNC_SKILLS_DEST="${MCP_SYNC_SKILLS_DEST:-$MCP_SYNC_HOME/skills}"
|
MCP_SYNC_SKILLS_DEST="${MCP_SYNC_SKILLS_DEST:-$MCP_SYNC_HOME/skills}"
|
||||||
# Hermes: the SAME MCP servers, rendered into Hermes' own schema. Set
|
# Hermes: the SAME MCP servers, rendered into Hermes' own schema. Set
|
||||||
@@ -69,6 +88,67 @@ _mcp_sync_export_key() {
|
|||||||
[ -n "$LITELLM_KEY" ] && export LITELLM_KEY
|
[ -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) ---------------
|
# --- refresh config + discover models (throttled, non-fatal) ---------------
|
||||||
_mcp_sync_refresh() {
|
_mcp_sync_refresh() {
|
||||||
mkdir -p "$(dirname "$MCP_SYNC_DEST")" 2>/dev/null || return 0
|
mkdir -p "$(dirname "$MCP_SYNC_DEST")" 2>/dev/null || return 0
|
||||||
@@ -81,16 +161,42 @@ _mcp_sync_refresh() {
|
|||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
command -v curl >/dev/null 2>&1 || return 0
|
# 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
|
||||||
|
|
||||||
# 1. Fetch the shared base config. On failure fall back to the currently
|
if [ -z "$_base" ]; then
|
||||||
# deployed file so model discovery can still refresh it.
|
command -v curl >/dev/null 2>&1 || return 0
|
||||||
_base="$(mktemp 2>/dev/null)" || return 0
|
_base="$(mktemp 2>/dev/null)" || return 0
|
||||||
# -f: fail on HTTP error; -s: silent; -m: hard timeout so a hung network
|
if ! _mcp_sync_curl "$MCP_SYNC_RAW_URL" "$_base" || [ ! -s "$_base" ]; then
|
||||||
# can't stall the shell.
|
rm -f "$_base"; _base=""
|
||||||
if ! curl -fsS -m 8 "$MCP_SYNC_RAW_URL" -o "$_base" 2>/dev/null || [ ! -s "$_base" ]; then
|
[ -f "$MCP_SYNC_DEST" ] || return 0
|
||||||
rm -f "$_base"; _base=""
|
elif [ "$(head -c 1 "$_base")" != "{" ]; then
|
||||||
[ -f "$MCP_SYNC_DEST" ] || return 0
|
# 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
|
fi
|
||||||
|
|
||||||
# 2. Discover the gateway's models with the user's key (best-effort).
|
# 2. Discover the gateway's models with the user's key (best-effort).
|
||||||
@@ -189,10 +295,26 @@ try:
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
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 = {}
|
models = {}
|
||||||
try:
|
try:
|
||||||
data = json.loads(os.environ.get("MCP_SYNC_MODELS") or "{}")
|
data = json.loads(os.environ.get("MCP_SYNC_MODELS") or "{}")
|
||||||
models = {m["id"]: dict(info.get(m["id"], {}))
|
models = {m["id"]: {**(base_models.get(m["id"]) or {}), **dict(info.get(m["id"], {}))}
|
||||||
for m in data.get("data", []) if m.get("id")}
|
for m in data.get("data", []) if m.get("id")}
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
@@ -393,8 +515,13 @@ _mcp_sync_skills() {
|
|||||||
_tree="${MCP_SYNC_SKILLS_SRC#file://}"
|
_tree="${MCP_SYNC_SKILLS_SRC#file://}"
|
||||||
;;
|
;;
|
||||||
*)
|
*)
|
||||||
if command -v curl >/dev/null 2>&1 && command -v tar >/dev/null 2>&1 &&
|
# Same rule as the config: the checkout is the source of truth when there
|
||||||
curl -fsS -m 15 "$MCP_SYNC_SKILLS_SRC" -o "$_work/repo.tgz" 2>/dev/null &&
|
# 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" ] &&
|
[ -s "$_work/repo.tgz" ] &&
|
||||||
mkdir -p "$_work/x" && tar -xzf "$_work/repo.tgz" -C "$_work/x" 2>/dev/null
|
mkdir -p "$_work/x" && tar -xzf "$_work/repo.tgz" -C "$_work/x" 2>/dev/null
|
||||||
then
|
then
|
||||||
@@ -440,10 +567,14 @@ _mcp_sync_skills
|
|||||||
# as "my new model isn't there" when in fact the gateway is fine and the local
|
# 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.
|
# config is just stale. Run `mcp-sync` to pull it immediately.
|
||||||
#
|
#
|
||||||
# Defined for the interactive shell (zsh/bash), which is the only place sync.sh
|
# The name is `mcp-sync`, but the DEFINITION below uses an underscore: a hyphen
|
||||||
# is sourced; the hyphen in the name is fine there.
|
# 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_SELF="${MCP_SYNC_SELF:-$HOME/.config/mcp-sync/sync.sh}"
|
||||||
mcp-sync() {
|
mcp_sync() {
|
||||||
[ -f "$MCP_SYNC_SELF" ] || {
|
[ -f "$MCP_SYNC_SELF" ] || {
|
||||||
printf 'mcp-sync: %s not found\n' "$MCP_SYNC_SELF" >&2; return 1
|
printf 'mcp-sync: %s not found\n' "$MCP_SYNC_SELF" >&2; return 1
|
||||||
}
|
}
|
||||||
@@ -459,5 +590,10 @@ models = cfg.get("provider", {}).get(os.environ["MCP_SYNC_PY_PROVIDER"], {}).get
|
|||||||
print(f"mcp-sync: {len(models)} models -> {', '.join(models) or '(none)'}")
|
print(f"mcp-sync: {len(models)} models -> {', '.join(models) or '(none)'}")
|
||||||
PYEOF
|
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 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
|
||||||
|
|||||||
+5
-2
@@ -6,8 +6,11 @@ points opencode at it via `skills.paths`.
|
|||||||
|
|
||||||
Unlike models and MCP servers — which are discovered from the LiteLLM gateway
|
Unlike models and MCP servers — which are discovered from the LiteLLM gateway
|
||||||
at sync time — skills are plain files with nothing on the gateway to query, so
|
at sync time — skills are plain files with nothing on the gateway to query, so
|
||||||
**this repo is the source of truth**. Add a skill here, push to `main`, and it
|
**this repo is the source of truth**. They are copied from the local checkout
|
||||||
lands everywhere within the sync TTL (~1h, or immediately on a new shell).
|
(the same source as the gateway config — see "Where the config comes from" in
|
||||||
|
`mcp-config/README.md`), so no anonymous read of this repo is required. Add a
|
||||||
|
skill here, push to `main`, and it lands everywhere within the sync TTL (~1h,
|
||||||
|
or immediately on a new shell) once the checkout picks the commit up.
|
||||||
|
|
||||||
> LiteLLM's `/v1/skills` is *not* an alternative: it is a passthrough to
|
> LiteLLM's `/v1/skills` is *not* an alternative: it is a passthrough to
|
||||||
> Anthropic's Skills API (`?beta=true`), which lists skills on an Anthropic
|
> Anthropic's Skills API (`?beta=true`), which lists skills on an Anthropic
|
||||||
|
|||||||
+21
-13
@@ -21,24 +21,32 @@
|
|||||||
# (https://llm.covert-gpt.com). Run hermes setup or update config.yaml
|
# (https://llm.covert-gpt.com). Run hermes setup or update config.yaml
|
||||||
# to set the default model (see ~/.hermes/config.yaml for available models).
|
# to set the default model (see ~/.hermes/config.yaml for available models).
|
||||||
|
|
||||||
# To develop mcp-config/ or skills/ against a working checkout instead of
|
# mcp-config/install.sh records the checkout it ran from, and the sync reads
|
||||||
# Gitea's main, point the sync at it and source it directly:
|
# the config and skills from there — no download, no anonymous read of the repo.
|
||||||
# MCP_SYNC_RAW_URL="file://$HOME/source/dotfiles/mcp-config/opencode.gateway.json"
|
# To point it at a DIFFERENT checkout (e.g. develop against a feature branch
|
||||||
# MCP_SYNC_SKILLS_SRC="$HOME/source/dotfiles/skills"
|
# while ~/.dotfiles stays on main), set one variable:
|
||||||
# . "$HOME/source/dotfiles/mcp-config/sync.sh"
|
# MCP_SYNC_REPO="$HOME/source/dotfiles"
|
||||||
|
#
|
||||||
|
# Only the bootstrap-managed checkout (~/.dotfiles) is ever `git pull`-ed by the
|
||||||
|
# hook, and only when clean — a tree you work in is read, never touched.
|
||||||
|
|
||||||
# --- 1Password CLI -------------------------------------------------------
|
# --- 1Password CLI -------------------------------------------------------
|
||||||
# Service-account token for non-interactive `op read` / `op run`.
|
# Service-account token for non-interactive `op read` / `op run`.
|
||||||
# export OP_SERVICE_ACCOUNT_TOKEN=ops_...
|
# export OP_SERVICE_ACCOUNT_TOKEN=ops_...
|
||||||
|
|
||||||
# --- Proxmox MCP gateway (ai-k8s-iac) ------------------------------------
|
# --- Proxmox -------------------------------------------------------------
|
||||||
# Credentials for the Proxmox MCP server registered on the covert-gpt
|
# One pair of credentials, several consumers: the proxmox MCP server
|
||||||
# LiteLLM gateway. `task up` in ai-k8s-iac reads these env vars and stores
|
# (mcp-config/opencode.gateway.json) and the terraform layer in
|
||||||
# them in the inference-mcp Secret; they are never committed.
|
# rke2-devops-airgap, whose Taskfile composes PROXMOX_VE_API_TOKEN from them.
|
||||||
# export PROXMOX_MCP_HOST="https://pve.local:8006"
|
# Unprefixed on purpose — these are Proxmox credentials, not MCP ones.
|
||||||
# export PROXMOX_MCP_TOKEN_ID="root@pam!terragrunt-proxmox"
|
#
|
||||||
# export PROXMOX_MCP_TOKEN_SECRET="00000000-0000-0000-0000-000000000000"
|
# The op-vault loop above already exports them from the `noconnor` item, so
|
||||||
# export PROXMOX_MCP_INSECURE_TLS="true"
|
# these lines are only needed without 1Password. Never committed.
|
||||||
|
# export PROXMOX_TOKEN_ID="root@pam!terragrunt-proxmox"
|
||||||
|
# export PROXMOX_TOKEN_SECRET="00000000-0000-0000-0000-000000000000"
|
||||||
|
#
|
||||||
|
# The host and TLS setting are hardcoded per-server in the MCP config and read
|
||||||
|
# from variables.yaml by terraform, so there is nothing to export for them.
|
||||||
|
|
||||||
# --- Anything else -------------------------------------------------------
|
# --- Anything else -------------------------------------------------------
|
||||||
# Aliases, PATH additions, kubeconfig defaults, etc. This file loads last,
|
# Aliases, PATH additions, kubeconfig defaults, etc. This file loads last,
|
||||||
|
|||||||
Reference in New Issue
Block a user