Back to the catalog

io.github.akomyagin/aimcpgate

MCP gateway/proxy: multiplexes tool calls across upstream MCP servers into one catalog.

Open source Open in the app JSON README (API)

About

MCP gateway/proxy: multiplexes tool calls across upstream MCP servers into one catalog.

Details

Kind
MCP servers
Topic
AI, RAG & memory
Publisher
akomyagin
Origin
official
Category
ferramentas
Transport
local
Version
0.5.0
Last push
2026-08-22T18:28:34Z
Repository state
ativo
Language
Go
License
MIT
Added
2026-08-29 03:02:24
Updated
2026-08-29 03:02:24
Origin id
io.github.akomyagin/aimcpgate

README

# aiMCPGate

*Русская версия — [README_RU.md](README_RU.md).*

A gateway / proxy for **MCP servers** (Model Context Protocol) written in Go.
It presents itself to an MCP client (Claude Code, Cursor, etc.) as **one**
MCP server, while under the hood it **multiplexes** calls across several
upstream MCP servers, **aggregates** their tools, prompts and resources into
one catalog, and **logs** every call.

> Status: **MVP complete (Stages 0–6) + post-MVP Stages 7–18 shipped, latest
> release v0.5.0.** Phase 1 — multiplexing stdio upstreams behind a
> stdio endpoint with a call log; Phase 2 — HTTP/SSE client-facing transport,
> HTTP upstreams, a CLI log viewer (`mcp-gate logs`); release pipeline
> (`goreleaser`, cross-compiled for linux/darwin/windows × amd64/arm64, no
> CGO). Post-MVP added upstream auto-restart, hot config reload, tool
> filtering/renaming, `doctor`, and — in v0.3.0 — full `prompts`/`resources`/
> `resources/templates`/`completion` aggregation, `ping`, progress forwarding
> and real cancellation, `logging/setLevel` fan-out, per-upstream call limits
> (rate limit / concurrency / result truncation / timeout), a lazy catalog and
> `tools/list` pagination, and SSE server→client streams on both the client and
> the upstream side. **v0.4.0** completed the server→client direction: all three
> server-initiated methods — `elicitation/create`, `sampling/createMessage` and
> `roots/list` — are proxied in **all four transport combinations** (stdio or
> HTTP on the client side × stdio or HTTP on the upstream side); the gateway now
> declares to an upstream exactly the capabilities its own client declared
> instead of a blanket `{}`; the HTTP transport gained server-side
> `Mcp-Session-Id` sessions with `DELETE /mcp` termination. **v0.5.0** adds
> operator observability (Stage 18): eight event kinds — upstream start
> failures and supervisor give-ups, dropped notifications and server→client
> requests, an HTTP upstream with no GET SSE, catalog collisions and bad URI
> templates, and a result that silently bypassed `max_result_bytes` — now land
> in the call journal (`mcp-gate logs`) instead of a `stderr` an MCP client
> usually owns; config parsing became strict (unknown/misspelled keys are
> fatal). It also closes the client-facing half of the guard/truncation story:
> a `tools/call` refused by the rate-limit or concurrency guard now returns
> its own JSON-RPC error code `-32029` with machine-readable
> `data: {"retryable":true,"reason":...}` instead of an indistinguishable
> `-32603`, and a non-text result that bypassed `max_result_bytes` carries a
> `result._meta` marker (`content` stays byte-for-byte untouched). Finally,
> `auth_token` referencing an unset environment variable now refuses to start
> the gateway instead of silently disabling HTTP authentication.
>
> **Upgrading to v0.5.0 — three behaviour changes, none touch the config file
> format itself:**
> - **Config parsing is now strict.** A config with an unknown or misspelled
>   top-level or per-upstream key, which used to be silently ignored, now
>   fails to load. Fix the key name (the error names it) or remove it.
> - **`auth_token: ${VAR}` with an unset `VAR` now refuses to start**, naming
>   the variable. Before, it silently became an empty token — which, on an
>   HTTP gateway, disabled the bearer check entirely with no warning. Set the
>   variable (or pass `--env-file`), or remove `auth_token` to run without
>   authentication on purpose.
> - **The call journal (`log_file` / `calls.jsonl`) gained a second record
>   kind**, `"kind":"event"`, alongside the existing call records. A binary at
>   v0.4.0 or older reading a v0.5.0 journal renders an event line as a sparse
>   `ERR` entry rather than failing — read a journal with the same or a newer
>   binary than the one that wrote it.
>
> **Upgrading to v0.4.0:** no config-file change, but two observable HTTP-mode
> behaviour changes — a session id is now mandatory on `POST /mcp` after
> `initialize` (the header is returned by the `initialize` response), and the
> upstream registry starts lazily on the first real MCP request instead of at
> process start.
>
> **Not implemented:** a per-client access policy.

## Releases

Cross-platform binaries are built via [`goreleaser`](https://goreleaser.com)
(`.goreleaser.yaml`): `linux`/`darwin`/`windows` × `amd64`/`arm64`, no CGO,
the version is baked in via `-ldflags -X main.version=...`, checksums land in
`SHA256SUMS`. Local dry run: `goreleaser release --snapshot --clean`.

## Install from MCP registry

Besides the raw release binaries, the gateway ships as an OCI image on GitHub
Container Registry and as an npm wrapper package — the two formats MCP
registries install from.

Docker:

```bash
docker run --rm -i -v $(pwd)/config.yaml:/config.yaml ghcr.io/akomyagin/aimcpgate serve
```

`-i` is mandatory: the gateway talks MCP over stdio, so the client must keep
stdin open (without it the container sees EOF and exits immediately). The
image has no config of its own, so mount yours — the example above mounts it
onto the default path `/config.yaml`; any other path works with `serve -c`.

To reproduce a registry sandbox check (Glama.ai etc.) without any real
upstream, use the demo config baked into the image — this exact command is
what a sandbox should run:

```bash
docker run --rm -i ghcr.io/akomyagin/aimcpgate serve -c /demo.config.yaml
```

npx (downloads the prebuilt binary for your platform on first install and
verifies its SHA256 checksum):

```bash
npx aimcpgate serve -c ./config.yaml
```

**Image policy:** the OCI image contains only the `mcp-gate` binary — no
runtimes for stdio upstreams (no node/npx, python, shells). If your config
launches stdio upstream servers, extend the image yourself and install what
they need; HTTP upstreams work out of the box (CA certificates are included).

**Demo config:** [`demo.config.yaml`](demo.config.yaml) and the hidden
`__demo-echo` subcommand exist only so registry sandboxes (Glama.ai) can
introspect the gateway without any real upstream — never use them in a real
deployment.

### Running CLI commands inside a container

`doctor`, `catalog`, `call` and `logs` are how an operator inspects a
deployment. Three facts decide how they must be invoked inside a container:

1. **The binary is `/mcp-gate` and it is NOT in `$PATH`.** The
   [`Dockerfile`](Dockerfile) does `COPY mcp-gate /mcp-gate` and
   `ENTRYPOINT ["/mcp-gate"]` — nothing puts it on a search path (check the
   `Dockerfile` if this ever looks wrong). So the obvious form fails:

   ```console
   $ docker exec mcp-gate mcp-gate catalog -c /config.yaml
   OCI runtime exec failed: exec failed: unable to start container process: exec: "mcp-gate": executable file not found in $PATH
   ```

   Use the absolute path instead — that is the only difference.
2. **The image is distroless, so there is no shell at all.** The base is
   `gcr.io/distroless/static-debian12:nonroot`, which ships the binary and CA
   certificates and nothing else. `docker exec mcp-gate sh -c '…'` fails the
   same way `sh` is simply not there, and there is no `ls`/`cat` to look around
   with. Keep pipes, globbing and redirection on the HOST side of the command.
3. **`docker exec` starts a NEW process; it does not query the running
   `serve`.** `doctor`, `catalog` and `call` build their own registry, open
   their own connections to the upstreams, report and exit. Their output is
   therefore upstream reachability *right now*, not the state of the live
   gateway: if the running process lost an upstream and dropped it from its
   catalog, these commands will not show that. They also keep the call journal
   clean — they run with journaling disabled, so a `call` you make this way does
   not appear in `logs`.

```bash
docker exec mcp-gate /mcp-gate version
docker exec mcp-gate /mcp-gate doctor  -c /config.yaml
docker exec mcp-gate /mcp-gate catalog -c /config.yaml
docker exec mcp-gate /mcp-gate call demo__echo '{"text":"hi"}' -c /config.yaml
docker exec mcp-gate /mcp-gate logs    -c /config.yaml --tail 50
```

The commands assume a container started **detached and named**, e.g.
`docker run -d --name mcp-gate …` — unlike the foreground `docker run --rm -i
…` example above, which exits as soon as its stdio client disconnects and
leaves nothing for `docker exec` to reach. The config is assumed mounted on
the default path `/config.yaml`, as in that same example; `demo__echo` stands
in for a tool from your own catalog. A few caveats:

- **`logs` is the exception to fact 3**: it reads the journal FILE the running
  gateway writes, so it does reflect the live process. That requires `log_file`
  in the mounted config to point at a path visible inside the container, and a
  volume mounted there — otherwise the journal goes to the container's stderr
  (i.e. to `docker logs`) and `mcp-gate logs` has nothing to read. `-c` is what
  tells it where the journal is; `--file` overrides it.
- **This is really about HTTP mode.** In stdio mode the MCP client spawns and
  owns the container, so there is usually no long-lived container to `exec`
  into. A gateway you can inspect is one started separately
  (`docker run -d --name mcp-gate …`) with `transport: http`.
- **HTTP mode needs a non-default `listen_addr`.** The default is
  `127.0.0.1:28080` — loopback INSIDE the container, unreachable from the host
  even with `-p`. Set `listen_addr: 0.0.0.0:<port>` in the config; the gateway
  then refuses to start without an `auth_token`, on purpose ("the HTTP endpoint
  would be reachable from the network without authentication").

## Why

An active MCP user typically has several servers configured (filesystem,
GitHub, search, custom ones), each one duplicated in every client's own
config. `aiMCPGate` gives you:

- **One entry point** — a single MCP endpoint instead of N entries in the
  client config.
- **One catalog** — every upstream server's tools and prompts merged together
  (namespaced as `<upstream>__<tool>` so names never collide), plus their
  resources and resource templates (addressed by URI, so never renamed).
- **A call log** — which upstream, which tool, when, success/failure. This is
  the value added on top of "just a proxy".

Solo pet project: the priority is learning Go (concurrency, `os/exec`,
JSON-RPC 2.0, the stdio and HTTP/SSE transports). Cost — **$0/month** by
default (a local process), no telemetry.

## How it works (short version)

```
MCP client ──stdio/HTTP──▶ aiMCPGate ──JSON-RPC──▶ upstream A (stdio)
                              │        ├─────────▶ upstream B (stdio)
                          call log     └─────────▶ upstream C (http, Phase 2)
```

## MVP (two phases)

- **Phase 1** — multiplexing 2+ **stdio** upstreams behind one **stdio**
  endpoint (the same transport Claude Code speaks) plus basic logging.
- **Phase 2** — **HTTP/SSE** transport, HTTP upstream servers, a log viewer
  (the CLI one was built; the web view was deliberately dropped), optionally an
  access policy — **that one was considered and declined**.

## Build

```bash
export PATH="$HOME/sdk/go/bin:$PATH"   # if go isn't already on PATH
go build ./...
go vet ./...
go test -race ./...

go run ./cmd version
```

## Usage

```bash
# stdio mode (the client launches the gateway as a subprocess):
mcp-gate serve --config ./config.yaml

# http mode (transport: http in the config) — endpoint at http://<listen_addr>/mcp;
# every request after initialize carries the issued Mcp-Session-Id (see below):
mcp-gate serve --config ./config-http.yaml

# check every enabled upstream once (launch → handshake → tools/list) and print
# a per-upstream OK/FAIL table; exit code is non-zero if any upstream failed
# (scriptable for CI/cron), no auto-restart, no call logging — one pass then exit.
# It also WARNs (POSIX only) if config.yaml or --env-file is readable by
# group/others — either may hold a secret literal, and nothing else checks:
mcp-gate doctor --config ./config.yaml

# call one aggregated tool once from the shell (single bring-up, no supervisor —
# the fastest way to debug a config, a filter or a rename without a live client):
mcp-gate call github__search_repositories '{"query":"mcp"}' --config ./config.yaml

# report the aggregated catalog size per upstream (tools / bytes / ~tokens) plus
# the heaviest individual tools — the data behind allow-list / strip decisions:
mcp-gate catalog --config ./config.yaml --top 20

# view the journal — tool calls AND operator events (last 50 lines; filter by
# upstream/tool/status):
mcp-gate logs --file ./logs/calls.jsonl --tail 50
mcp-gate logs --config ./config.yaml --upstream github --status err
# show ONLY the operator events (see "Operator events" below):
mcp-gate logs --config ./config.yaml --events
# keep watching the log as it grows, or aggregate it instead of listing records
# (--follow and --stats are mutually exclusive):
mcp-gate logs --config ./config.yaml --follow
mcp-gate logs --config ./config.yaml --stats

# generate a random auth token (for the HTTP transport) and see how to wire it in:
mcp-gate token --generate
# print the auth token currently set in the config:
mcp-gate token --config ./config-http.yaml

# print ready-to-paste MCP client config snippets (Claude Code / Cursor / Claude
# Desktop) for whichever transport the config uses: a launch command for stdio, or
# the endpoint URL plus the Bearer header (when auth_token is set) for http:
mcp-gate client-config --config ./config.yaml

# print a SKILL.md teaching an agent how to use the aggregated catalog
# (built-in text by default; overridable via skill_file in the config):
mcp-gate skill > .claude/skills/mcp-gate/SKILL.md

# shell completions (cobra's built-in command; the release archives also ship
# pre-generated ones):
mcp-gate completion bash > /etc/bash_completion.d/mcp-gate
```

All commands except `token --generate`, `completion` and `skill` (which falls
back to a built-in guide) load the config: pass `--config`, or drop a
`config.yaml` next to the binary (see Configuration below).

`serve`, `doctor`, `call` and `catalog` also accept `--env-file ./.env` — a
minimal `KEY=VALUE` parser applied **before** the config is loaded, so `${VAR}`
references inside the config resolve from that file. The real process
environment always wins over the file.

### HTTP sessions (`Mcp-Session-Id`)

In http mode the gateway runs Streamable HTTP sessions: the reply to
`initialize` carries an **`Mcp-Session-Id`** header, and every request after it
— POST, the GET SSE stream, DELETE — must send that header back. Without it the
answer is **400**; with an unknown or expired id, **404**, which tells the
client to `initialize` again. A session is released by **`DELETE /mcp`** (204),
or after 30 minutes with no requests — an open SSE stream counts as activity and
keeps it alive.

MCP clients do all of this for you. For hand-made `curl` calls, take the header
from the initialize response and echo it back:

```bash
SID=$(curl -sD - -o /dev/null -X POST http://127.0.0.1:28080/mcp \
  -H 'Content-Type: application/json' \
  -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"curl","version":"1"}}}' \
  | tr -d '\r' | awk -F': ' '/^[Mm]cp-[Ss]ession-[Ii]d/{print $2}')

curl -s -X POST http://127.0.0.1:28080/mcp \
  -H 'Content-Type: application/json' -H "Mcp-Session-Id: $SID" \
  -d '{"jsonrpc":"2.0","id":2,"method":"tools/list"}'

curl -s -X DELETE http://127.0.0.1:28080/mcp -H "Mcp-Session-Id: $SID"
```

The session also makes the call log honest: every call is audited under the
`clientInfo` of the session that made it, so several HTTP clients are told apart
in `calls.jsonl` instead of sharing one blank `client` field.

### Server→client requests over HTTP (`elicitation`, `sampling`, `roots`)

When an upstream asks something mid-call — `elicitation/create`,
`sampling/createMessage`, `roots/list` — the question is delivered as an SSE
event on the **GET `/mcp` stream of one session**, and the client answers with
an ordinary POST carrying a JSON-RPC response with the same id and the same
`Mcp-Session-Id`. Only the session the question was put to may answer it; an
answer from any other session is ignored. If nobody has declared the capability
with a stream open, the upstream is refused right away in the shape the spec
prescribes (`{"action":"decline"}` for elicitation, `-32601` for the other two)
rather than being left to time out — and the same happens if the session is
terminated while a question is outstanding.

Three consequences worth knowing:

- **The upstreams are told about the capabilities of the FIRST client that
  initializes**, and that set is fixed for the life of the process. MCP
  2025-06-18 has no re-negotiation, so a second client declaring more cannot
  change handshakes that already happened — an upstream is never promised a
  capability on behalf of a client it was not told about.
- **The upstreams start on the first request that needs them**, not when the
  gateway binds its port. That is what makes the declaration above possible at
  all: the handshake has to happen after a client has said what it supports. If
  the upstreams cannot start, the client gets a JSON-RPC `-32603` and the
  gateway exits with the error, as it did when it started them eagerly.
- **The question goes to a client that declared the capability — not
  necessarily to the one whose call provoked it.** Routing is by declared
  capability, and among the matching sessions the most recently active one
  wins; an upstream request carries nothing that says which caller it belongs
  to. With a single client (the normal case) this is invisible, but run two and
  a form raised by one client's `tools/call` can surface in the other's UI.

The **upstream** side of the same exchange works over HTTP too: a remote MCP
server reached with `url:` may ask its question as an SSE frame — either on its
long-lived `GET` stream or interleaved into the stream answering one of the
gateway's own POSTs, which is where SDK servers put an `elicitation/create`
raised inside a `tools/call`. The gateway proxies it through the same pipeline
and sends the client's answer back as one ordinary POST carrying a JSON-RPC
response **under the server's own request id**. Such an upstream is told the
gateway's client capabilities by the same honest policy as a stdio one — a
capability is offered only when the gateway's own client declared it, and
`doctor`/`call`/`catalog`, which have no client at all, keep declaring exactly
`{}`. The answer POST is not retried: an upstream that does not get it falls
back on its own timeout.

## Operator events in the journal

The journal at `log_file` holds **two kinds of line**: one per tool **call**, and
one per operator **event** — a gateway state you would otherwise never learn
about. In stdio mode the MCP client owns the terminal, so the gateway's stderr is
invisible to you, and several of these conditions were only ever logged at debug
level. They are now written to the same file `mcp-gate logs` reads:

| Event | What it means |
|---|---|
| `upstream_start_failed` | An upstream never came up; its tools are absent from the catalog. |
| `upstream_gave_up` | The supervisor stopped restarting an upstream (attempts exhausted, restart disabled by a reload, or no liveness channel) and dropped it from the catalog. |
| `notification_dropped` | A subscriber's buffer was full, so a forwarded notification was dropped — forwarding is non-blocking by design. |
| `server_request_dropped` | An upstream asked something only the client could answer (`elicitation`/`sampling`/`roots`) and no transport took the question, so the tool call was refused on its behalf. |
| `sse_stream_unavailable` | An HTTP upstream offers no `GET` SSE stream, so `tools/list_changed` from it will never arrive until the gateway restarts. |
| `catalog_collision` | Two entries claimed the same client-facing tool/prompt name or resource URI; keep-first won and the loser is hidden from the client. |
| `catalog_bad_template` | A resource URI template does not compile: it is listed to the client but can never match a read. |
| `result_truncation_skipped` | A result exceeded `max_result_bytes` but had no truncatable text (e.g. images only), so it passed through whole. |

Events show up inline with calls, marked `EVT`; `mcp-gate logs --events` shows
only them, and `--stats` gains a per-event table. `--tool` and `--status` are
call-only filters, so events are excluded while either is set (`--upstream`
applies to both). One consequence worth knowing: `notification_dropped` names no
upstream — a drop is a property of the *subscriber* whose buffer was full, not of
whoever sent the notification — so **`--upstream X` never shows it**. Look for it
without that filter. Repeated drops are coalesced — the first one is written at
once, further ones within a minute are counted into the `count=` of the next
line for that key, and the remainder is flushed at shutdown. A line that carries
such a backlog says so in its `detail=`, naming the time of the **oldest**
occurrence it folds in — the line's own timestamp is the newest one, so the two
together bound when the burst actually happened.

Two practical notes:

- **Set `log_file`.** With it empty the journal goes to stderr, which in stdio
  mode belongs to the MCP client — the events would be written where you cannot
  see them.
- **Read a journal with the same (or a newer) binary that wrote it.** Events
  carry a `"kind"` field older versions do not know, so `mcp-gate logs` from
  ≤ v0.4.0 renders them as sparse, mostly empty records.

Nothing about this is visible to the MCP client: no error codes, result bodies
or capabilities changed — the events go to the journal only.

**A call the gateway could not route is not an event — it is an ordinary
failed CALL line.** A client asking for a tool name no upstream provides gets
a `CallRecord` like any other, with its `upstream` column set to the sentinel
`(unrouted)`; `mcp-gate logs --upstream '(unrouted)'` selects exactly those
lines and nothing else. A second, distinct case looks almost the same but
names a real upstream instead: the route exists (the tool is in the catalog)
but the upstream's connection is gone (restarting or dropped) — that line
carries the real upstream name, so filter for it with `--upstream <name>` as
usual rather than the sentinel.

## Reloading config (SIGHUP)

The gateway reloads its configuration live on **SIGHUP** — no restart, no
dropped client connection. Edit `config.yaml` and send the signal:

```bash
kill -HUP $(pgrep -f 'mcp-gate serve')
```

On reload the gateway diffs the new config against the running upstreams and
applies the minimum change: newly added upstreams are launched, removed (or
`enabled: false`) ones are shut down, upstreams whose launch fields
(`command`/`args`/`url`/`env`/`headers`) changed are relaunched, and upstreams
where only the tool filter changed (`allow`/`deny`/`rename`, or the catalog
projection rules `strip_annotations`/`strip_output_schema`/`max_description`/
`describe`) are re-projected
without any restart. Call limits (`rate_limit`, `max_concurrent`,
`max_result_bytes`, `call_timeout` — global or per-upstream) are also applied
live: they never require a relaunch, the next call simply uses the new values.
Unchanged upstreams keep running untouched. A bad edit
(invalid YAML, failed validation) is logged and ignored — the currently running
config stays live, so a typo never takes the gateway down.

**Behavioural note:** since the gateway installs a SIGHUP handler, SIGHUP no
longer terminates the process the way the OS default would. To stop the gateway
use Ctrl-C, SIGINT, or SIGTERM.

SIGHUP is Unix-only. On Windows — or anywhere you would rather not send signals
— use the opt-in polling alternative instead:

```bash
mcp-gate serve --config ./config.yaml --watch-config        # bare flag = poll every 2s
mcp-gate serve --config ./config.yaml --watch-config=10s    # note the "=", not a space
```

It fingerprints the config file on that interval and applies the same reload
path SIGHUP takes. Running it alongside the SIGHUP handler is safe.

The watcher compares the file's **mtime and size**, and waits for that
fingerprint to repeat on the next tick before it reads the file. That is what
makes a two-step save (truncate, then fill) safe in practice: a writer has to
hold the file in a half-written state for longer than a full polling interval to
fool the check. The price is latency — a reload lands within up to two polling
intervals (up to 4s on the default 2s).

On stdio the upstreams come up on the client's first request, so an edit made
before any client has connected cannot be applied yet. The watcher **keeps that
edit and re-tries it every poll** until the gateway is up, then applies it — you
never have to save the file a second time to make it take. An edit refused for
good (unparseable YAML, or the no-`upstreams` guard below) is reported once and
not retried.

As a backstop on **both** triggers, a reload whose new config declares no
`upstreams` at all is refused and logged: that is the signature of a
half-written file, and applying it would tear down every running upstream. To
remove all upstreams deliberately, restart the gateway. An explicit
`enabled: false` is unaffected — disabling the last upstream still applies.

## Configuration

Without `--config`, the gateway looks for `config.yaml` **next to its own
binary** (e.g. if `mcp-gate` is installed at `/etc/gate/`, it looks for
`/etc/gate/config.yaml` — regardless of the working directory it was launched
from). If that file doesn't exist and `--config` wasn't passed either, it
errors explicitly instead of starting an empty gateway. Relative paths inside
the config (`log_file`, `skill_file`, `debug_payload_log`) resolve against the
**config file's own directory**, not the current working directory.

**Unknown keys are a startup error.** The config is parsed strictly: a
misspelled or unrecognized key stops the gateway with the key name and its line
number, instead of being silently ignored as it once was. The concrete win: a
typo in `enabled` can no longer leave an upstream quietly running. Custom `x-`
scratch keys are rejected too — to share a block, put a YAML anchor on the first
real upstream and merge it (`<<: *anchor`) into the others; anchors and merge
keys work as usual.

An upstream is **enabled by default**: omit `enabled:` entirely and it is
launched like any other. To keep one out of the gateway without deleting its
config, disable it explicitly with **`enabled: false`** — it then appears
neither in `tools/list` nor in `mcp-gate doctor`'s table. Careful: a valueless
`enabled:` (or `enabled: null`) reads as *omitted*, so commenting the value out
leaves the upstream running — only the literal `false` disables it.

> Note: the "next to the binary" lookup uses the path of the running executable.
> Under `go run ./cmd ...` that executable is a throwaway build in a temp
> directory, so the default lookup will not find your `config.yaml` — pass
> `--config` explicitly when using `go run`, or run a built binary.

Full example with every field — [`config.example.yaml`](config.example.yaml).
The set of upstream servers is declared in YAML; **secrets (tokens) go through
env/`.env`** (`${VAR}` expansion at load time), never committed in the config.
Each upstream sets **exactly one** of `command` (stdio subprocess) or `url`
(HTTP server, Streamable HTTP) — the connection kind is inferred automatically.

Unset `${VAR}` references behave differently per field:

- **`auth_token`** referencing an unset variable **fails startup**, naming the
  variable — an empty `auth_token` would silently disable the HTTP bearer check,
  so this is never allowed to happen quietly. To run without authentication,
  remove the `auth_token` key entirely.
- An unset variable in an upstream's **`env`/`headers`** is **not** an error:
  the value becomes empty and the missing secret surfaces later as a 401 from
  that upstream. The gateway reports it ahead of time — an
  `unresolved_secret_var` event in the journal (`mcp-gate logs`) and a `WARN`
  line in `mcp-gate doctor`.
- In stdio mode `mcp-gate client-config` warns (on stderr) that the operator's
  environment variables are **not inherited** by the MCP client, which launches
  the gateway in its own environment — set them where the client runs it.

By default every stdio upstream inherits the gateway's **full** process
environment — including the secrets and `auth_token` that `--env-file` loaded
for *other* upstreams. Set **`env_isolation: strict`** (global) so each stdio
upstream instead receives only a minimal base (`PATH`, `HOME`, `TMPDIR`, and the
Windows equivalents) plus its **own** `env:` block, which always reaches the
child. Recommended when you run upstreams from open registries (`npx`-launched
packages you do not fully trust). A gateway variable you *do* want a strict
upstream to see, list explicitly in its `env:` (e.g. `PYTHONPATH: ${PYTHONPATH}`).
The value is read at launch time: a live upstream keeps the environment it
started with until it is (re)started.

Separately, and always on: once the gateway has expanded a `${VAR}` secret it
knows the exact value, and it **redacts that value to `***`** in operator-facing
free text — the journal (`mcp-gate logs`) and crash logs. With no secrets
configured this costs nothing.

A secret travelling over **plain, unencrypted HTTP to a non-loopback host**
gets a `WARN` at startup and a line in `mcp-gate doctor` (never a hard
failure — cleartext on trusted infrastructure, e.g. a VPN or an isolated LAN,
can be a deliberate choice the gateway cannot second-guess from the config
alone): a non-empty `auth_token` bound past loopback, or an upstream's
`headers` sent to a non-`https://` non-loopback `url`. TLS itself is not
built into the gateway — put a TLS-terminating reverse proxy in front if you
need it, the same pattern already recommended for mutual TLS.

```yaml
transport: stdio            # stdio (Phase 1) | http (Phase 2)
listen_addr: "127.0.0.1:28080"  # only used for transport: http; loopback by default
# auth_token: ${AIMCPGATE_TOKEN}  # required if you widen listen_addr past loopback;
#                                 # the variable must be set or startup fails
log_file: ./logs/calls.jsonl
# debug_payload_log: ./logs/payloads.jsonl  # OPT-IN, off by default: logs raw
#                                   # arguments AND results — can contain secrets
# Optional global call limits (each can be overridden per upstream):
# rate_limit: { rps: 5, burst: 2 }  # token bucket per upstream for tools/call
#                                   # (refusal → client error -32029, retryable)
# max_result_bytes: 65536           # truncate oversized textual results (0 = off;
#                                   # non-text over-limit results get a _meta marker)
# call_timeout: 30s                 # bounds one upstream request
# How the catalog is presented to the client (both hot-reloadable):
# catalog_mode: lazy                # normal (default) | lazy: the client sees only
#                                   # gate_search_tools / gate_describe / gate_call
# page_size: 50                     # paginate tools/list (0/omitted = whole catalog;
#                                   # ignored in lazy mode)
# Auto-restart policy for crashed stdio upstreams (defaults: on, 1s→30s, 5 tries):
# restart: { enabled: true, initial_backoff: 1s, max_backoff: 30s, max_attempts: 5 }
# env_isolation: strict             # opt-in ("" default = inherit the gateway's full
#                                   # env): a stdio upstream then gets only a minimal
#                                   # base (PATH/HOME/...) plus its own env: block —
#                                   # NOT other upstreams' secrets or the auth token
upstreams:
  - name: filesystem        # stdio upstream
    command: npx
    args: ["-y", "@modelcontextprotocol/server-filesystem", "/home/user"]
    enabled: true
  - name: github
    command: github-mcp-server
    env:
      GITHUB_TOKEN: ${GITHUB_TOKEN}   # from the environment, not hardcoded
    enabled: true
    # Optional per-upstream tool filter / catalog projection (keys are ORIGINAL
    # tool names; all editable live via SIGHUP with no upstream restart):
    # tools:
    #   allow: ["search_repositories"]  # if non-empty, only these survive
    #   deny: ["delete_repository"]     # always subtracted, even from allow
    #   rename: { search_repositories: "gh_search" }
    #   strip_annotations: true         # drop heavyweight catalog fields
    #   strip_output_schema: true
    #   max_description: 200            # truncate descriptions to N runes
    #   describe: { get_issue: "Fetch one issue." }   # replace wholesale
    # Optional per-upstream call limits (override the globals for this upstream):
    # rate_limit: { rps: 1, burst: 1 }  # rps: 0 disables the global limit here
    #                                   # (refusal → client error -32029, retryable)
    # max_concurrent: 4                 # cap on simultaneous in-flight calls
    #                                   # (refusal → client error -32029, retryable)
    # max_result_bytes: 32768           # 0 disables the global cap here
    # call_timeout: 120s                # this upstream is slow — give it longer
  - name: remote            # http upstream (Phase 2)
    url: https://mcp.example.com/mcp
    headers:
      Authorization: "Bearer ${REMOTE_MCP_TOKEN}"   # secret, never logged
    enabled: true
```

### What the client sees when a call limit bites

Two of the call limits above surface to the MCP client (agent), not just to the
operator journal:

- **Guard refusals (`rate_limit` / `max_concurrent`).** When the gateway turns a
  `tools/call` away because the per-upstream rate limiter or concurrency cap
  could not admit it, the client gets a JSON-RPC error with the gateway's own
  code **`-32029`** and machine-readable
  `data: {"retryable": true, "reason": "rate_limit" | "concurrency_limit"}`. The
  call never reached the upstream, so an agent may wait and retry without risking
  double execution. Ordinary transport/routing failures keep the historical
  `-32603`, and an error an *upstream* itself returns is forwarded verbatim,
  code and data untouched — a `-32029` from an upstream is not a gateway signal.
- **Oversized results that cannot be truncated (`max_result_bytes`).** Text
  results are shrunk with an in-content `[truncated by mcp-gate: …]` marker. A
  non-text / non-standard result that exceeds the limit but has no truncatable
  text (e.g. images only) is passed through **whole and byte-for-byte** — its
  `content[]` is never altered — but the result's `_meta` gains the gateway key
  **`io.github.akomyagin.aimcpgate/result-over-limit`** with
  `{"limitBytes": N, "resultBytes": M}` so an agent can tell the limit was
  bypassed. A client that does not know the key simply ignores it. The operator
  `result_truncation_skipped` journal event still fires as before.

## License

MIT — see [`LICENSE`](LICENSE).

More