From 70b049625a2548fc8ca6f8348f034fa66d17a3b3 Mon Sep 17 00:00:00 2001 From: Nic O'Connor Date: Mon, 13 Jul 2026 13:10:11 -0400 Subject: [PATCH] mcp-config: run MCP servers client-side, not on the gateway Every API worth exposing (Gitea, Proxmox) is user-scoped, so hosting MCP centrally means one shared token per server: everyone acts as the same identity and attribution is lost. LiteLLM's per-request BYOK cannot cover all of them either -- it builds its tool->server map from a headerless background listing, and gitea-mcp hard-exits without a token, so it never registers. A split brain (some MCPs server-side, some client-side) is worse than either, so all of them move client-side. Servers are declared local, spawned via npx with pinned versions, and read each user's OWN credentials via {env:VAR}. No token is ever written to the config; the file stays identical for every user. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 8 +++ mcp-config/README.md | 105 ++++++++++++++++++++++--------- mcp-config/opencode.gateway.json | 23 +++++-- mcp-config/sync.sh | 10 +-- 4 files changed, 106 insertions(+), 40 deletions(-) diff --git a/README.md b/README.md index a774607..0c51d10 100644 --- a/README.md +++ b/README.md @@ -89,6 +89,14 @@ Variables modules look for: only on a box without mcp-sync. - `OP_SERVICE_ACCOUNT_TOKEN` — 1Password service-account token for non-interactive `op read` / `op run`. +- `GITEA_KEY`, `PROXMOX_MCP_TOKEN_ID`, `PROXMOX_MCP_TOKEN_SECRET` — **your own** + 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 + spawns, so the tools act as *you*. Keep them in `~/.zshrc.local`; they are + never written into any config file. + + These are NOT used by `ai-k8s-iac` / `task up`: the LiteLLM gateway hosts no + MCP servers and stores no MCP credentials. See `mcp-config/README.md`. Secrets never go in this repo or in `zsh/zshrc` — that file is shared config applied to every box. diff --git a/mcp-config/README.md b/mcp-config/README.md index d55a603..11181b3 100644 --- a/mcp-config/README.md +++ b/mcp-config/README.md @@ -1,8 +1,39 @@ -# mcp-config — shared MCP gateway config for laptops +# mcp-config — shared opencode config for laptops -Single source of truth for the **shared** MCP servers every covert-gpt user -gets through the LiteLLM gateway (`llm.covert-gpt.com`). Users install once; -their config then refreshes automatically on each new shell. +Single source of truth for what opencode sees: 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. + +## 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. + +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 @`. `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 @@ -10,16 +41,17 @@ sync — the laptops pull from Gitea's `main`.) ## How it works -- `opencode.gateway.json` — the shared base config: the `covert-gpt` model - provider (LiteLLM's OpenAI-compatible endpoint at - `https://llm.covert-gpt.com/v1`, models map left empty — it is filled by - discovery at sync time) and a single MCP entry for the gateway's - **aggregate** endpoint `https://llm.covert-gpt.com/mcp/` (trailing slash - required; the bare path 307-redirects, which MCP clients don't follow). - That one endpoint exposes every MCP server the user's key can access, so - new gateway servers need no client config change at all. **Identical for - every user**: the per-user key is injected via `{env:LITELLM_KEY}`, never - written into the file. +- `opencode.gateway.json` — the shared base config: + - the `covert-gpt` model provider (LiteLLM's OpenAI-compatible endpoint at + `https://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 as + `npx -y @` 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 — `npx` runs whatever the registry serves, and + that process gets your tokens. - `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/models` with the user's key to **auto-discover models** (opencode @@ -50,34 +82,45 @@ own kubeconfig). Sync never touches those. ## User setup (one time) -Each user needs a LiteLLM **virtual key** scoped to the gateway servers (see -`charts/inference/MCP-GATEWAY.md` in ai-k8s-iac for how keys are minted). Then: +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: ```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) ``` -Open a new terminal, run `opencode`, and the gateway tools are there. +Then export your own MCP credentials in `~/.zshrc.local`: -## Adding a gateway server or model (admin) +```sh +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 +``` -Register it on the gateway (`charts/inference/values.yaml` → -`proxy_config.mcp_servers` for MCP servers, `model_list` for models), -deploy, smoke-test. That's it — clients auto-discover both: MCP servers -through the aggregate `/mcp/` endpoint, models via the `/v1/models` query -in `sync.sh`. Users pick them up within the sync TTL (or immediately on a -new shell). `opencode.gateway.json` here only needs editing for structural -changes (a new provider, endpoint URLs). +Open a new terminal, run `opencode`, and the tools are there — acting as **you**. + +## Adding an MCP server (admin) + +Add it to `mcp.` 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) -Skills have no gateway side. Add a folder under `skills/` and push to `main` — -see `skills/README.md`. Verify with `opencode debug skill`. - -For **per-user / per-target** tools (cluster access, etc.) do NOT add them -here — those run client-side with the user's own credentials. See -`charts/inference/MCP-GATEWAY.md`. +Add a folder under `skills/` and push to `main` — see `skills/README.md`. +Verify with `opencode debug skill`. ## Notes / caveats diff --git a/mcp-config/opencode.gateway.json b/mcp-config/opencode.gateway.json index d7f10ca..a15b8bc 100644 --- a/mcp-config/opencode.gateway.json +++ b/mcp-config/opencode.gateway.json @@ -12,12 +12,25 @@ } }, "mcp": { - "covert-gpt": { - "type": "remote", - "url": "https://llm.covert-gpt.com/mcp/", + "gitea": { + "type": "local", + "command": ["npx", "-y", "gitea-mcp@0.0.10"], "enabled": true, - "headers": { - "Authorization": "Bearer {env:LITELLM_KEY}" + "environment": { + "GITEA_HOST": "https://git.nic-oconnor.com", + "GITEA_ACCESS_TOKEN": "{env:GITEA_KEY}" + } + }, + "proxmox": { + "type": "local", + "command": ["npx", "-y", "proxmox-mcp-server@0.2.0"], + "enabled": true, + "environment": { + "PROXMOX_HOST": "https://prox-01.covert-cloud.com:8006", + "PROXMOX_TOKEN_ID": "{env:PROXMOX_MCP_TOKEN_ID}", + "PROXMOX_TOKEN_SECRET": "{env:PROXMOX_MCP_TOKEN_SECRET}", + "PROXMOX_INSECURE_TLS": "true", + "PVE_READONLY": "false" } } }, diff --git a/mcp-config/sync.sh b/mcp-config/sync.sh index 91fef55..9b90741 100644 --- a/mcp-config/sync.sh +++ b/mcp-config/sync.sh @@ -11,15 +11,17 @@ # # What it does: # 1. Fetch the shared opencode.gateway.json from Gitea (the single source of -# truth: the covert-gpt provider + the aggregate MCP endpoint). The file -# is identical for all users — the per-user key is injected via -# {env:LITELLM_KEY}, never baked into the file. +# truth: the covert-gpt provider + the client-side MCP servers). The file +# is identical for all users — EVERY credential (LiteLLM key, Gitea PAT, +# Proxmox token) is injected via {env:VAR} and never baked into the file. # 2. Auto-discover models: query the gateway's /v1/models with the user's # key and fill the provider's "models" map. New models exposed on the # gateway appear on every box at the next sync — no config edit anywhere. # (opencode cannot list models from a custom provider itself; the map # must be materialized client-side.) MCP servers need no discovery step: -# the single /mcp/ endpoint aggregates every server the key can access. +# they are declared in the config and run locally under `npx`, each with +# the user's OWN credentials. The gateway hosts no MCP servers at all — +# see mcp-config/README.md for why (user-scoped APIs, no shared tokens). # 3. Deploy the repo's skills/ tree to ~/.config/mcp-sync/skills. Skills are # plain files, so there is nothing on the gateway to discover — the repo # IS the source of truth, and the tree is fetched whole (repo tarball).