diff --git a/README.md b/README.md index b8d3627..abb2e04 100644 --- a/README.md +++ b/README.md @@ -54,8 +54,9 @@ Already have a clone? Just run it directly: is already present. `zsh/zshrc` puts `~/.local/bin` on `PATH`. opencode is configured by `mcp-config/`: it points at the covert-gpt - LiteLLM gateway (`https://llm.covert-gpt.com/v1` for models, - `https://llm.covert-gpt.com/mcp` for MCP tools). Run + LiteLLM gateway (`https://llm.covert-gpt.com/v1`) for models. MCP tools do + 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 config sync — without it, opencode installs but can't reach the gateway. diff --git a/mcp-config/README.md b/mcp-config/README.md index 9a20fb7..7a838a4 100644 --- a/mcp-config/README.md +++ b/mcp-config/README.md @@ -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 **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 @@ -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 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`: @@ -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 wins. Use distinct names. - **Key rotation**: re-run `install.sh` (or edit `~/.config/mcp-sync/key`). -- **Other clients**: this ships an opencode config. Claude Code / open-webui - would need their own equivalent file + a parallel sync entry; not done yet. +- **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-sync` forces one refresh; `MCP_SYNC_TTL=0` in the environment makes every new shell pull. - **python3 is required** for model discovery. Without it `sync.sh` deploys the diff --git a/mcp-config/sync.sh b/mcp-config/sync.sh index 6fddfe3..978fbaf 100644 --- a/mcp-config/sync.sh +++ b/mcp-config/sync.sh @@ -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_SUBDIR="${MCP_SYNC_SKILLS_SUBDIR:-skills}" MCP_SYNC_SKILLS_DEST="${MCP_SYNC_SKILLS_DEST:-$MCP_SYNC_HOME/skills}" +# Hermes: the SAME MCP servers, rendered into Hermes' own schema. Set +# MCP_SYNC_HERMES_CONFIG to empty to opt out entirely. +MCP_SYNC_HERMES_CONFIG="${MCP_SYNC_HERMES_CONFIG-$HOME/.hermes/config.yaml}" +# Names we rendered last time, so a server REMOVED upstream can be removed here +# without touching MCP servers the user added to Hermes by hand. +MCP_SYNC_HERMES_MANIFEST="${MCP_SYNC_HERMES_MANIFEST:-$MCP_SYNC_HOME/hermes-managed.json}" # Throttle: only re-sync if the deployed copy is older than this many seconds # (default 1h). Keeps every new shell from hammering Gitea/the gateway. MCP_SYNC_TTL="${MCP_SYNC_TTL:-3600}" @@ -218,6 +224,154 @@ PYEOF [ -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 ` is the design — same package, same pinned version, same +# per-user credential, same identity. There is no daemon to share. +# +# Reads the DEPLOYED opencode config (post-refresh), so it always reflects what +# Gitea just served. No-ops silently when Hermes isn't installed. +_mcp_sync_hermes() { + [ -n "${MCP_SYNC_HERMES_CONFIG:-}" ] || return 0 + # Hermes not installed (or never run) — nothing to configure, stay silent. + [ -f "$MCP_SYNC_HERMES_CONFIG" ] || return 0 + [ -f "$MCP_SYNC_DEST" ] || return 0 + command -v python3 >/dev/null 2>&1 || return 0 + # PyYAML is needed to touch config.yaml at all. Unlike models, a missing MCP + # block is invisible rather than obviously broken, so say something. + if ! python3 -c 'import yaml' >/dev/null 2>&1; then + printf 'mcp-sync: python3 has no PyYAML — Hermes MCP servers not synced.\n' >&2 + return 0 + fi + + MCP_SYNC_PY_SRC="$MCP_SYNC_DEST" \ + MCP_SYNC_PY_HERMES="$MCP_SYNC_HERMES_CONFIG" \ + MCP_SYNC_PY_MANIFEST="$MCP_SYNC_HERMES_MANIFEST" \ + MCP_SYNC_PY_PROVIDER="$MCP_SYNC_PROVIDER" \ + python3 - <<'PYEOF' 2>/dev/null || true +import json, os, re, tempfile +import yaml + +src = os.environ["MCP_SYNC_PY_SRC"] +dest = os.environ["MCP_SYNC_PY_HERMES"] +manifest_path = os.environ["MCP_SYNC_PY_MANIFEST"] + +try: + with open(src) as f: + oc = json.load(f) +except Exception: + raise SystemExit(0) # bad/incomplete opencode config: leave Hermes alone + +# opencode's "{env:VAR}" -> Hermes' "${VAR}". Both are by-NAME references +# resolved at runtime, so no secret is ever written to either file. +_ENV_REF = re.compile(r"\{env:([A-Za-z_][A-Za-z0-9_]*)\}") + + +def _refs(v): + if isinstance(v, str): + return _ENV_REF.sub(r"${\1}", v) + if isinstance(v, dict): + return {k: _refs(x) for k, x in v.items()} + if isinstance(v, list): + return [_refs(x) for x in v] + return v + + +def spec_for(name, s): + if not isinstance(s, dict): + return None + # opencode defaults enabled to true; so does Hermes. Honour an explicit off. + if s.get("enabled") is False: + return None + kind = s.get("type", "local") + if kind == "remote": + url = s.get("url") + if not url: + return None + out = {"url": url} + headers = s.get("headers") + if isinstance(headers, dict) and headers: + out["headers"] = _refs(headers) + return out + # local/stdio. opencode: command is a LIST (argv). Hermes: command is a + # STRING and args is a separate list -- a list here stringifies to + # "['npx', '-y']" and fails at exec, so the split is mandatory. + cmd = s.get("command") + if not isinstance(cmd, list) or not cmd: + return None + out = {"command": str(cmd[0])} + if len(cmd) > 1: + out["args"] = [str(a) for a in cmd[1:]] + env = s.get("environment") + if isinstance(env, dict) and env: + # Hermes hands the child only a filtered baseline env, so every secret + # the server needs MUST be passed explicitly here. + out["env"] = _refs(env) + return out + + +rendered = {} +for name, s in (oc.get("mcp") or {}).items(): + spec = spec_for(name, s) + if spec is not None: + rendered[name] = spec +if not rendered: + raise SystemExit(0) + +try: + with open(dest) as f: + cfg = yaml.safe_load(f) or {} +except Exception: + raise SystemExit(0) # never overwrite a config we cannot parse +if not isinstance(cfg, dict): + raise SystemExit(0) + +current = cfg.get("mcp_servers") +if not isinstance(current, dict): + current = {} +merged = dict(current) + +# Drop servers we rendered before that upstream has since removed. Anything the +# user added to Hermes themselves was never in the manifest, so it survives. +try: + with open(manifest_path) as f: + prev = json.load(f) + prev = prev if isinstance(prev, list) else [] +except Exception: + prev = [] +for gone in set(prev) - set(rendered): + merged.pop(gone, None) + +merged.update(rendered) + +if merged != current: + cfg["mcp_servers"] = merged + d = os.path.dirname(dest) or "." + fd, tmp = tempfile.mkstemp(dir=d) + with os.fdopen(fd, "w") as f: + yaml.safe_dump(cfg, f, sort_keys=False, default_flow_style=False, + allow_unicode=True) + os.chmod(tmp, 0o600) + os.replace(tmp, dest) + +os.makedirs(os.path.dirname(manifest_path) or ".", exist_ok=True) +fd, tmp = tempfile.mkstemp(dir=os.path.dirname(manifest_path) or ".") +with os.fdopen(fd, "w") as f: + json.dump(sorted(rendered), f) +os.replace(tmp, manifest_path) +PYEOF +} + # --- deploy the shared skills tree (throttled, non-fatal) ------------------ _mcp_sync_skills() { # Same TTL as the config, tracked on the deployed directory's own mtime. @@ -271,6 +425,8 @@ _mcp_sync_skills() { _mcp_sync_export_key _mcp_sync_refresh +# After refresh: renders the just-deployed MCP servers into Hermes' schema. +_mcp_sync_hermes _mcp_sync_skills # opencode only merges config from known locations; point OPENCODE_CONFIG at @@ -304,4 +460,4 @@ print(f"mcp-sync: {len(models)} models -> {', '.join(models) or '(none)'}") 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