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>
mcp-config — shared client config for laptops
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
client-side. Users install once; their config then refreshes automatically
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
The LiteLLM gateway hosts no MCP servers — deliberately. Every API worth exposing (Gitea, Proxmox, UniFi, NetBird, k8s) is user-scoped, so hosting it centrally means one shared token per server: every user acts as the same identity and attribution is gone.
LiteLLM can do per-request BYOK (env values written ${X-Header} are filled
from the caller's headers — verified working for proxmox on v1.87.0), but only
for servers that tolerate a credential-less spawn. LiteLLM builds its
tool→server mapping from a headerless background listing, and gitea-mcp
hard-exits without a token, so it never registers and its tool calls 404. The
gateway could therefore host only some servers — and a split brain (some MCPs
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
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
config. Each user exports them in ~/.zshrc.local:
| MCP server | env vars |
|---|---|
gitea |
GITEA_KEY |
proxmox |
PROXMOX_MCP_TOKEN_ID, PROXMOX_MCP_TOKEN_SECRET |
Requires npx — the MCP servers are npm packages that opencode spawns as
npx -y <pkg>@<version>. bootstrap.sh installs Node for this (node/).
Without it every server dies with "command not found", which opencode surfaces
only as a dead server — easy to misread as a broken config.
This repo lives in Gitea at public/dotfiles. (It is mirrored from the
ai-k8s-iac repo under mcp-config/; edit it there or here and keep them in
sync — the laptops pull from Gitea's main.)
How it works
-
opencode.gateway.json— the shared base config:- the
covert-gptmodel provider (LiteLLM's OpenAI-compatible endpoint athttps://llm.covert-gpt.com/v1, models map left empty — it is filled by discovery at sync time), and - the MCP servers, each
type: local, spawned by opencode asnpx -y <pkg>@<pinned-version>on the user's own machine.
Identical for every user: every credential (the LiteLLM key, the Gitea PAT, the Proxmox token) is injected via
{env:VAR}and never written into the file. Versions are pinned —npxruns whatever the registry serves, and that process gets your tokens. - the
-
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/v1/modelswith the user's key to auto-discover models (opencode can't list models from a custom provider itself, so the map is materialized client-side; if discovery fails the previous map is carried over), deploys the repo's skills (see below), deploys the config to~/.config/opencode/opencode.gateway.json, and exportsLITELLM_KEY(from~/.config/mcp-sync/key) plusOPENCODE_CONFIG(pointing at the deployed file — opencode does NOT auto-load extra files in its config dir, so without this export the file is ignored). Silent + non-fatal: a Gitea hiccup never breaks the shell, last-good config stays in place. -
../skills/— the shared skill folders, deployed to~/.config/mcp-sync/skillsand wired in throughskills.pathsin the 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 truth; the tree is fetched whole from the repo tarball. They deploy to mcp-sync's own directory rather than~/.config/opencode/skillsso the atomic replace can never delete a skill the user wrote by hand. Seeskills/README.md. -
install.sh— one-time bootstrap (curl-pipe). Fetchessync.sh, stores the user's key (0600), and adds the rc hook.
opencode merges the OPENCODE_CONFIG file on top of the user's global
~/.config/opencode/opencode.json (and under any project opencode.json),
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
own kubeconfig). Sync never touches those.
User setup (one time)
Each user needs a LiteLLM virtual key (for models) plus their own
credentials for each MCP server. Run bootstrap.sh (it installs Node, which
provides the npx the MCP servers need), then:
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)
Then export your own MCP credentials in ~/.zshrc.local:
export GITEA_KEY=... # your Gitea PAT (repo + issue scopes)
export PROXMOX_MCP_TOKEN_ID=... # e.g. alice@pve!mcp
export PROXMOX_MCP_TOKEN_SECRET=... # the token UUID
Open a new terminal, run opencode, and the tools are there — acting as you.
Adding an MCP server (admin)
Add it to mcp.<name> in opencode.gateway.json as type: local, with a
pinned package version and {env:VAR} refs for every secret, then push to
main. Users pick it up within the sync TTL (or immediately on a new shell).
Two hard requirements:
- It must be an npm package (opencode spawns it with
npx). - Its credentials must be per-user, via
{env:VAR}. Never commit a token, and never introduce a shared one — that is the whole reason MCP is not on the gateway.
Models are different: they live on the gateway
(charts/inference/values.yaml → model_list) and are auto-discovered by the
/v1/models query in sync.sh. No client change needed.
Adding a skill (admin)
Add a folder under skills/ and push to main — see skills/README.md.
Verify with opencode debug skill.
"I added a model on the gateway and opencode doesn't see it"
Almost always a stale local config, not a gateway or key problem. The sync runs
only at shell start and is throttled to MCP_SYNC_TTL (1h), so a new model
can take up to an hour and a new terminal to appear.
Pull it immediately:
mcp-sync # force a refresh now, ignoring the TTL; prints the model list
If the model still isn't listed, it isn't reaching your key — check the gateway, not the client:
curl -fsS -H "Authorization: Bearer $LITELLM_KEY" \
https://llm.covert-gpt.com/v1/models | jq -r '.data[].id'
Whatever that prints is exactly what sync.sh will deploy. A model present here
but missing in opencode is a sync problem; a model missing here is a LiteLLM
model_list or key-scoping problem.
Notes / caveats
- Local-server precedence: opencode merges with later-config-wins on key conflicts. If a user names a local server the same as a gateway one, theirs wins. Use distinct names.
- Key rotation: re-run
install.sh(or edit~/.config/mcp-sync/key). - Other clients: Hermes is wired (see "One definition, two clients").
Claude Code / open-webui would each still need a parallel render step in
sync.sh; not done yet. - Throttle:
mcp-syncforces one refresh;MCP_SYNC_TTL=0in the environment makes every new shell pull. - python3 is required for model discovery. Without it
sync.shdeploys the base config verbatim, so the provider comes up with an EMPTY model list. It warns when this happens;bootstrap.shinstalls python3.