mcp-config: render the SAME MCP servers into Hermes' config

Hermes had no MCP servers at all -- no mcp_servers: key -- so "use the gitea
MCP" had nothing to call. The model said so ("there's no Gitea-specific skill"),
which was true, and then looped reaching for the browser as the only way in.

The obvious fix -- paste a gitea block into ~/.hermes/config.yaml -- is the
wrong one: it makes a SECOND definition of a server this repo already defines
once, free to drift (one pinned to gitea-mcp@0.0.10, the other to whatever got
typed). README already calls this out: "all MCP servers are defined here", and
listed other clients as "not done yet". So render, don't duplicate: sync.sh
already translates LiteLLM's /model/info into opencode's schema; this is the
same move into Hermes' schema, from opencode.gateway.json's mcp block. Add a
server once, both clients get it within the same TTL.

Not a shared server PROCESS, which is what "use the same one" would imply: 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. Sharing
one process would need a persistent daemon plus a stdio->HTTP bridge, for
nothing. And it stays client-side: the gateway hosts no MCP servers on purpose
(values.yaml:892; user-scoped APIs, no shared tokens), and gitea-mcp hard-exits
without a token so LiteLLM's headerless listing never registers it anyway.

Translation notes: Hermes' `command` must be a STRING (a list stringifies to
"['npx', '-y']" and fails at exec), so opencode's argv list splits into
command + args. `{env:VAR}` becomes `${VAR}` -- both by-name, so no secret is
written to either file. Hermes gives an MCP child only a filtered baseline env,
so every secret must be listed in env:, which the rendered block does. Nothing
is added to platform_toolsets: MCP servers auto-enable by name, and listing some
would turn it into an allowlist.

Rendered names are recorded in ~/.config/mcp-sync/hermes-managed.json so a
server dropped upstream is dropped from Hermes, while servers the user added by
hand -- never in the manifest -- are left alone. Only writes when the result
changes, skips silently when Hermes isn't installed, warns when PyYAML is
missing (an absent MCP block is invisible rather than obviously broken).

Verified via Hermes' own discovery: 72 tools register -- 30 gitea (incl.
issue__list) and 42 proxmox -- with ${GITEA_KEY} / ${PROXMOX_MCP_*} resolving
and no secret in config.yaml.

Also fixes a stale README claim that MCP tools come from the gateway's /mcp
endpoint; they never do, and values.yaml:892 says why.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-16 11:04:57 -04:00
co-authored by Claude Opus 4.8
parent e42807c054
commit 11f88ad3a9
3 changed files with 199 additions and 7 deletions
+3 -2
View File
@@ -54,8 +54,9 @@ Already have a clone? Just run it directly:
is already present. `zsh/zshrc` puts `~/.local/bin` on `PATH`. is already present. `zsh/zshrc` puts `~/.local/bin` on `PATH`.
opencode is configured by `mcp-config/`: it points at the covert-gpt opencode is configured by `mcp-config/`: it points at the covert-gpt
LiteLLM gateway (`https://llm.covert-gpt.com/v1` for models, LiteLLM gateway (`https://llm.covert-gpt.com/v1`) for models. MCP tools do
`https://llm.covert-gpt.com/mcp` for MCP tools). Run NOT come from the gateway — it hosts no MCP servers, deliberately; they run
client-side under your own credentials (see `mcp-config/README.md`). Run
`mcp-config/install.sh` once to store your LiteLLM key and enable `mcp-config/install.sh` once to store your LiteLLM key and enable
config sync — without it, opencode installs but can't reach the config sync — without it, opencode installs but can't reach the
gateway. gateway.
+39 -4
View File
@@ -1,10 +1,15 @@
# mcp-config — shared opencode config for laptops # mcp-config — shared client config for laptops
Single source of truth for what opencode sees: the **covert-gpt model provider** Single source of truth for what the agents see: the **covert-gpt model provider**
(LiteLLM at `llm.covert-gpt.com`) and the **MCP servers**, which run (LiteLLM at `llm.covert-gpt.com`) and the **MCP servers**, which run
**client-side**. Users install once; their config then refreshes automatically **client-side**. Users install once; their config then refreshes automatically
on each new shell. on each new shell.
Two clients consume it: **opencode** (deployed to
`~/.config/opencode/opencode.gateway.json`) and **Hermes** (its `mcp_servers:`
block in `~/.hermes/config.yaml` is rendered from the same definitions — see
"One definition, two clients" below).
## MCP runs on the CLIENT, never on the gateway ## MCP runs on the CLIENT, never on the gateway
The LiteLLM gateway hosts **no MCP servers** — deliberately. Every API worth The LiteLLM gateway hosts **no MCP servers** — deliberately. Every API worth
@@ -22,6 +27,35 @@ server-side, some client-side) is worse than either. So **all** MCP servers are
defined here, each reading the user's **own** credentials from their own defined here, each reading the user's **own** credentials from their own
environment. The config is identical for everyone; only the secrets differ. environment. The config is identical for everyone; only the secrets differ.
## One definition, two clients
The servers are defined **once**, in `opencode.gateway.json`'s `mcp` block.
Hermes speaks a different schema, so `sync.sh` *renders* that one definition into
`mcp_servers:` in `~/.hermes/config.yaml` rather than keeping a second copy — two
copies would drift, one pinned to `gitea-mcp@0.0.10` and the other to whatever
got typed. Add a server here once and **both** clients get it, within the same
TTL. The translation is mechanical:
| opencode | Hermes |
|---|---|
| `command: ["npx","-y","pkg@1.2.3"]` (argv list) | `command: npx` + `args: ["-y","pkg@1.2.3"]` — Hermes' `command` must be a **string**; a list stringifies and fails at exec |
| `environment: {K: "{env:VAR}"}` | `env: {K: "${VAR}"}` |
| `type: remote`, `url`, `headers` | `url`, `headers` |
| `enabled: false` | entry omitted |
Both placeholder forms are by-NAME references resolved at runtime, so no secret
is written to either file. Hermes hands an MCP child only a filtered baseline
environment, so every secret a server needs **must** be listed in `env:` — which
is exactly what the rendered block does.
Servers you add to Hermes by hand are left alone: `sync.sh` records the names it
rendered in `~/.config/mcp-sync/hermes-managed.json`, so a server dropped from
this repo is dropped from Hermes too, while anything not in that manifest is
never touched. Rendering is skipped silently when Hermes isn't installed, and
needs PyYAML (it warns if that's missing). Nothing needs adding to Hermes'
`platform_toolsets` — MCP servers are auto-enabled by name; listing *some*
would turn it into an allowlist, and `no_mcp` disables them all.
Credentials are referenced as `{env:VAR}` and are **never** written into the Credentials are referenced as `{env:VAR}` and are **never** written into the
config. Each user exports them in `~/.zshrc.local`: config. Each user exports them in `~/.zshrc.local`:
@@ -152,8 +186,9 @@ but missing in opencode is a sync problem; a model missing here is a LiteLLM
conflicts. If a user names a local server the same as a gateway one, theirs conflicts. If a user names a local server the same as a gateway one, theirs
wins. Use distinct names. wins. Use distinct names.
- **Key rotation**: re-run `install.sh` (or edit `~/.config/mcp-sync/key`). - **Key rotation**: re-run `install.sh` (or edit `~/.config/mcp-sync/key`).
- **Other clients**: this ships an opencode config. Claude Code / open-webui - **Other clients**: Hermes is wired (see "One definition, two clients").
would need their own equivalent file + a parallel sync entry; not done yet. Claude Code / open-webui would each still need a parallel render step in
`sync.sh`; not done yet.
- **Throttle**: `mcp-sync` forces one refresh; `MCP_SYNC_TTL=0` in the - **Throttle**: `mcp-sync` forces one refresh; `MCP_SYNC_TTL=0` in the
environment makes every new shell pull. environment makes every new shell pull.
- **python3 is required** for model discovery. Without it `sync.sh` deploys the - **python3 is required** for model discovery. Without it `sync.sh` deploys the
+157 -1
View File
@@ -51,6 +51,12 @@ MCP_SYNC_GATEWAY="${MCP_SYNC_GATEWAY:-https://llm.covert-gpt.com}"
MCP_SYNC_SKILLS_SRC="${MCP_SYNC_SKILLS_SRC:-https://git.nic-oconnor.com/public/dotfiles/archive/main.tar.gz}" MCP_SYNC_SKILLS_SRC="${MCP_SYNC_SKILLS_SRC:-https://git.nic-oconnor.com/public/dotfiles/archive/main.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
# MCP_SYNC_HERMES_CONFIG to empty to opt out entirely.
MCP_SYNC_HERMES_CONFIG="${MCP_SYNC_HERMES_CONFIG-$HOME/.hermes/config.yaml}"
# Names we rendered last time, so a server REMOVED upstream can be removed here
# without touching MCP servers the user added to Hermes by hand.
MCP_SYNC_HERMES_MANIFEST="${MCP_SYNC_HERMES_MANIFEST:-$MCP_SYNC_HOME/hermes-managed.json}"
# Throttle: only re-sync if the deployed copy is older than this many seconds # 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. # (default 1h). Keeps every new shell from hammering Gitea/the gateway.
MCP_SYNC_TTL="${MCP_SYNC_TTL:-3600}" MCP_SYNC_TTL="${MCP_SYNC_TTL:-3600}"
@@ -218,6 +224,154 @@ PYEOF
[ -n "$_base" ] && rm -f "$_base" [ -n "$_base" ] && rm -f "$_base"
} }
# --- render the SAME MCP servers into Hermes' config (non-fatal) -----------
#
# WHY THIS EXISTS: the MCP servers are defined ONCE, in opencode.gateway.json's
# `mcp` block (see README: "all MCP servers are defined here"). Hermes speaks a
# different schema, so without this it would need a second copy of every server
# — two definitions to drift apart, one pinned to gitea-mcp@0.0.10 and the other
# to whatever got typed. Instead we translate the one definition into Hermes'
# shape, the same way this file already translates LiteLLM's /model/info into
# opencode's. Add a server to Gitea once and BOTH clients get it.
#
# It is NOT a shared server process: MCP stdio servers are subprocesses, spawned
# per client over stdin/stdout and dying with it. Each client running its own
# `npx -y <pkg>` is the design — same package, same pinned version, same
# per-user credential, same identity. There is no daemon to share.
#
# Reads the DEPLOYED opencode config (post-refresh), so it always reflects what
# Gitea just served. No-ops silently when Hermes isn't installed.
_mcp_sync_hermes() {
[ -n "${MCP_SYNC_HERMES_CONFIG:-}" ] || return 0
# Hermes not installed (or never run) — nothing to configure, stay silent.
[ -f "$MCP_SYNC_HERMES_CONFIG" ] || return 0
[ -f "$MCP_SYNC_DEST" ] || return 0
command -v python3 >/dev/null 2>&1 || return 0
# PyYAML is needed to touch config.yaml at all. Unlike models, a missing MCP
# block is invisible rather than obviously broken, so say something.
if ! python3 -c 'import yaml' >/dev/null 2>&1; then
printf 'mcp-sync: python3 has no PyYAML — Hermes MCP servers not synced.\n' >&2
return 0
fi
MCP_SYNC_PY_SRC="$MCP_SYNC_DEST" \
MCP_SYNC_PY_HERMES="$MCP_SYNC_HERMES_CONFIG" \
MCP_SYNC_PY_MANIFEST="$MCP_SYNC_HERMES_MANIFEST" \
MCP_SYNC_PY_PROVIDER="$MCP_SYNC_PROVIDER" \
python3 - <<'PYEOF' 2>/dev/null || true
import json, os, re, tempfile
import yaml
src = os.environ["MCP_SYNC_PY_SRC"]
dest = os.environ["MCP_SYNC_PY_HERMES"]
manifest_path = os.environ["MCP_SYNC_PY_MANIFEST"]
try:
with open(src) as f:
oc = json.load(f)
except Exception:
raise SystemExit(0) # bad/incomplete opencode config: leave Hermes alone
# opencode's "{env:VAR}" -> Hermes' "${VAR}". Both are by-NAME references
# resolved at runtime, so no secret is ever written to either file.
_ENV_REF = re.compile(r"\{env:([A-Za-z_][A-Za-z0-9_]*)\}")
def _refs(v):
if isinstance(v, str):
return _ENV_REF.sub(r"${\1}", v)
if isinstance(v, dict):
return {k: _refs(x) for k, x in v.items()}
if isinstance(v, list):
return [_refs(x) for x in v]
return v
def spec_for(name, s):
if not isinstance(s, dict):
return None
# opencode defaults enabled to true; so does Hermes. Honour an explicit off.
if s.get("enabled") is False:
return None
kind = s.get("type", "local")
if kind == "remote":
url = s.get("url")
if not url:
return None
out = {"url": url}
headers = s.get("headers")
if isinstance(headers, dict) and headers:
out["headers"] = _refs(headers)
return out
# local/stdio. opencode: command is a LIST (argv). Hermes: command is a
# STRING and args is a separate list -- a list here stringifies to
# "['npx', '-y']" and fails at exec, so the split is mandatory.
cmd = s.get("command")
if not isinstance(cmd, list) or not cmd:
return None
out = {"command": str(cmd[0])}
if len(cmd) > 1:
out["args"] = [str(a) for a in cmd[1:]]
env = s.get("environment")
if isinstance(env, dict) and env:
# Hermes hands the child only a filtered baseline env, so every secret
# the server needs MUST be passed explicitly here.
out["env"] = _refs(env)
return out
rendered = {}
for name, s in (oc.get("mcp") or {}).items():
spec = spec_for(name, s)
if spec is not None:
rendered[name] = spec
if not rendered:
raise SystemExit(0)
try:
with open(dest) as f:
cfg = yaml.safe_load(f) or {}
except Exception:
raise SystemExit(0) # never overwrite a config we cannot parse
if not isinstance(cfg, dict):
raise SystemExit(0)
current = cfg.get("mcp_servers")
if not isinstance(current, dict):
current = {}
merged = dict(current)
# Drop servers we rendered before that upstream has since removed. Anything the
# user added to Hermes themselves was never in the manifest, so it survives.
try:
with open(manifest_path) as f:
prev = json.load(f)
prev = prev if isinstance(prev, list) else []
except Exception:
prev = []
for gone in set(prev) - set(rendered):
merged.pop(gone, None)
merged.update(rendered)
if merged != current:
cfg["mcp_servers"] = merged
d = os.path.dirname(dest) or "."
fd, tmp = tempfile.mkstemp(dir=d)
with os.fdopen(fd, "w") as f:
yaml.safe_dump(cfg, f, sort_keys=False, default_flow_style=False,
allow_unicode=True)
os.chmod(tmp, 0o600)
os.replace(tmp, dest)
os.makedirs(os.path.dirname(manifest_path) or ".", exist_ok=True)
fd, tmp = tempfile.mkstemp(dir=os.path.dirname(manifest_path) or ".")
with os.fdopen(fd, "w") as f:
json.dump(sorted(rendered), f)
os.replace(tmp, manifest_path)
PYEOF
}
# --- deploy the shared skills tree (throttled, non-fatal) ------------------ # --- deploy the shared skills tree (throttled, non-fatal) ------------------
_mcp_sync_skills() { _mcp_sync_skills() {
# Same TTL as the config, tracked on the deployed directory's own mtime. # Same TTL as the config, tracked on the deployed directory's own mtime.
@@ -271,6 +425,8 @@ _mcp_sync_skills() {
_mcp_sync_export_key _mcp_sync_export_key
_mcp_sync_refresh _mcp_sync_refresh
# After refresh: renders the just-deployed MCP servers into Hermes' schema.
_mcp_sync_hermes
_mcp_sync_skills _mcp_sync_skills
# opencode only merges config from known locations; point OPENCODE_CONFIG at # opencode only merges config from known locations; point OPENCODE_CONFIG at
@@ -304,4 +460,4 @@ print(f"mcp-sync: {len(models)} models -> {', '.join(models) or '(none)'}")
PYEOF PYEOF
} }
unset -f _mcp_sync_export_key _mcp_sync_refresh _mcp_sync_skills 2>/dev/null || true unset -f _mcp_sync_export_key _mcp_sync_refresh _mcp_sync_hermes _mcp_sync_skills 2>/dev/null || true