Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Introduction

mcpgw is a single binary that keeps one list of your MCP servers, points every client at itself, and forwards the calls — so the list lives in one place, and so does the traffic.

Two problems, one binary

The first is bookkeeping. Claude Desktop keeps its server list in one file. Claude Code keeps its own. Cursor spells the same entry differently. VS Code has a fourth shape. One server, four files, four schemas — and every token rotation means finding all four again.

mcpgw makes ~/.config/mcpgw/config.toml the canonical list and syncs it outward. It only ever rewrites entries it wrote itself; anything you added by hand is left alone and reported back to you as unmanaged. Every client file is backed up before it's touched, and one command puts the previous version back. Nothing about that is a one-way door: mcpgw eject writes your original definitions back into every client and leaves you able to uninstall mcpgw entirely.

The second is visibility. Once the servers are wired up, you have no idea what's happening through them. Which tool did the agent pick? What arguments went out? Did that call fail, or did the server quietly return nothing?

The entries mcpgw writes point at mcpgw, so every client's calls pass through one process — mcpgw serve in a terminal, or the same gateway supervised by your machine's service manager via mcpgw daemon install. It writes every request to a daily JSONL file, and mcpgw watch follows it live:

$ mcpgw watch
watching /Users/you/.local/share/mcpgw/traffic (Ctrl-C to stop)
  now  ✓  github__create_issue               87ms
  12s  ✓  linear tools/list                   4ms
  30s  ✗  github__search_code               210ms  upstream "github" failed after 3 attempt(s)

Why not the MCP Inspector

The official MCP Inspector connects to a server as its own client, so everything it shows you is traffic Inspector generated. That's the right tool for poking at a server you're writing. It is the wrong tool for the question "what did my agent just do".

mcpgw sits on the path the agent already uses, so the log is the agent's own calls.

Where it sits

agentgateway targets Kubernetes and enterprise deployments. MetaMCP gives you aggregation and a web UI, in exchange for a Node and Docker stack you have to keep alive. mcpgw is a static binary with no runtime dependencies doing the boring local job: one config, every client, real traffic.

Start with Installation, then type mcpgw — on a terminal that opens the setup wizard, which walks the Quickstart for you one confirmed step at a time. Before you point your whole machine at it, the Trust model is what having one process in the middle does and does not protect.

Installation

mcpgw is one static binary with no runtime dependencies. Pick whichever of these you already trust.

Installer script

curl -fsSL https://github.com/kennywillbe/mcpgw/releases/latest/download/mcpgw-installer.sh | sh

Detects your platform, downloads that archive and puts the binary in ~/.local/bin. Two knobs:

MCPGW_INSTALL_DIR=/usr/local/bin sh -c "$(curl -fsSL .../mcpgw-installer.sh)"
MCPGW_VERSION=0.1.0              sh -c "$(curl -fsSL .../mcpgw-installer.sh)"

The script does not check signatures or hashes. If you want that, use the archive route below and verify against SHA256SUMS yourself.

Homebrew

brew install kennywillbe/tap/mcpgw

Cargo

cargo install mcpgw

Builds from source, so it works on any target Rust supports — including ones with no prebuilt archive.

From a release archive

Every release attaches one archive per platform plus a combined SHA256SUMS:

version=0.1.0
target=aarch64-apple-darwin
base=https://github.com/kennywillbe/mcpgw/releases/download/v${version}

curl -fsSLO "${base}/mcpgw-${version}-${target}.tar.gz"
curl -fsSLO "${base}/SHA256SUMS"
grep " mcpgw-${version}-${target}.tar.gz\$" SHA256SUMS | shasum -a 256 -c -

tar -xzf "mcpgw-${version}-${target}.tar.gz"
install "mcpgw-${version}-${target}/mcpgw" /usr/local/bin/

Prebuilt targets: aarch64-apple-darwin, x86_64-apple-darwin, x86_64-unknown-linux-gnu, x86_64-pc-windows-msvc.

Updating

mcpgw self-update

Downloads the latest release archive for your platform, checks it against the release's SHA256SUMS, and replaces the running binary with it. It refuses to touch an install that belongs to a package manager: under ~/.cargo/bin it tells you to run cargo install mcpgw, under a Homebrew prefix brew upgrade mcpgw.

mcpgw self-update --check changes nothing and only reports, exiting 0 when you already have the latest release and 10 when you don't — a pair a script can branch on.

Once a day, after a command has finished, mcpgw asks GitHub whether a newer release exists and prints one line to stderr if there is:

mcpgw 0.2.0 is available (you have 0.1.0) — run `mcpgw self-update`

It never writes to stdout, so --json output stays parseable, and it stays quiet when the network doesn't answer. MCPGW_NO_UPDATE_CHECK=1 turns it off entirely.

Check it

mcpgw --version
mcpgw doctor

On a fresh machine doctor will tell you there's no config yet and list which clients it found. That's the expected first-run state — go to the Quickstart.

Quickstart

Type mcpgw. That is the quickstart.

mcpgw

On a terminal, a bare mcpgw with no arguments is the setup wizard, and it does the whole of this page one confirmed step at a time. It asks before every change and writes nothing until you say yes.

What the wizard does

1 — looks around. Which of the thirteen supported MCP clients are installed here, and how many servers each one holds.

Looking around — 2 MCP clients found.
  Cursor          2 servers  ~/.cursor/mcp.json
  Claude Desktop  1 server   ~/Library/Application Support/Claude/claude_desktop_config.json
  11 other supported clients are not installed here

2 — adopts what they already hold. Every server it found goes into the canonical config, once, with the duplicates folded together and every rename printed.

Importing what your clients already have.
  2 servers to bring in, from Cursor.
  The rest come across as they are: github, linear.

3 — offers to keep the gateway running. A launch agent, a systemd user unit, or a Windows service, depending on the machine — and on macOS it warns about the "Background Items Added" notification before it appears, rather than leaving you to wonder what just asked. Declining is a normal answer: mcpgw serve in a terminal is the same gateway.

4 — points every client at the gateway and checks the result: is the gateway answering, does every enabled server answer through its own endpoint, and does every entry that was just written point at one that does.

Checking that it actually works…
  ✓ gateway answering at http://127.0.0.1:8137/mcp
  ✓ github  http://127.0.0.1:8137/s/github — 41 tools
  ✓ Cursor  2 entries, all pointing at endpoints that answer

It ends by telling you to restart your clients, because no harness re-reads its MCP config while running.

mcpgw init          # the same thing, spelled out
mcpgw init --yes    # never prompts: the recommended answer at every step

--yes is for scripts and agents. It still prints the whole plan, and where a step needs a decision that cannot be made for you it stops and says which command to run instead.

Off a terminal — in a pipe, a CI job, a Dockerfile — a bare mcpgw prints help and exits 2 rather than opening a wizard nobody can answer.

Once everything is set up, a bare mcpgw stops being a wizard and becomes a status card: how many servers, whether the gateway is answering, which clients are synced.

Piece by piece

The steps below are what the wizard does, and each remains the way to do that one thing on its own — after setup, they are how you keep the list current.

1. Adopt what you already have

mcpgw import

import reads Claude Desktop, Claude Code, Cursor, VS Code, Gemini CLI, Codex CLI, opencode, Windsurf, Zed, Cline, Amp and Zoo Code, and pulls every server it finds into the canonical config. Names that aren't valid mcpgw names get slugified, and every rename is printed. The same server configured in three clients is imported once.

mcpgw import --dry-run          # look before you leap
mcpgw import --from cursor      # only one client (repeatable)
mcpgw import --yes              # never prompt; keep canonical on conflict

A client entry that differs from a canonical entry you already wrote is a conflict, and import asks what to do with it. --yes answers "keep the canonical entry" without asking, so scripts and agents can run import knowing it will neither block nor overwrite anything you wrote by hand. The skipped entries are still listed in the output.

Client ids are claude-desktop, claude-code, cursor, vscode, gemini, codex, opencode, windsurf, zed, cline, cline-cli, amp, zoomcpgw sync --help prints the current list.

Cline is two ids because it is two installs: the VS Code extension and the standalone CLI read different files, and nothing keeps them in step. A machine with both gets both, and import folds a server it finds on both into one canonical entry.

2. Add the rest by hand

mcpgw add github -- npx -y @modelcontextprotocol/server-github
mcpgw add linear --url https://mcp.linear.app/mcp

Everything after -- is the stdio command, verbatim. --url makes it an HTTP server instead. Useful flags:

mcpgw add db --env PGHOST=localhost --tag work -- my-mcp-server
mcpgw add staging --url https://x/mcp --header "Authorization=Bearer $TOKEN"
mcpgw add scratch --disabled -- some-server     # in the list, not in use

3. See the list

mcpgw list
mcpgw list --json

Every command that prints something also speaks --json.

To take a server out of rotation without losing its config:

mcpgw disable scratch
mcpgw enable scratch
mcpgw remove scratch

4. Point every client at the gateway

mcpgw sync --dry-run     # the diff, no writes
mcpgw sync               # write it

Each enabled server keeps its entry and its name in the client; only the transport changes, to that server's own endpoint on the gateway (http://127.0.0.1:8137/s/<name>). So the client's list looks the same before and after, tool names are untouched, and anything the client keeps beside the entry — Cline's off switch, its auto-approved tools — survives the move. Then run the gateway, with mcpgw daemon install or mcpgw serve.

sync only rewrites entries mcpgw wrote. Anything you added to a client by hand is left exactly where it is and reported as unmanaged, with an import suggestion attached. Before each write, the client's config file is copied into your state directory.

mcpgw sync --client cursor --client vscode    # a subset
mcpgw sync --aggregate                        # one `mcpgw` entry instead of one per server
mcpgw sync --rollback                         # undo the last sync

The first time this moves entries that used to point straight at your servers, sync says so once:

  These entries used to point straight at the servers. They now point at mcpgw,
  which forwards to the same servers — same names, same tools.

  One thing changed: if the gateway isn't running, they won't answer.
  `mcpgw daemon status` tells you, `mcpgw daemon install` keeps it running.

  Undo everything this run did: mcpgw sync --rollback

5. Check it actually works

mcpgw doctor

The static pass: does the canonical config parse, are the names valid, do the stdio commands resolve on PATH, are the URLs well-formed, is any client holding an entry that can't be represented. Errors exit 1; warnings don't, so this is safe in CI.

mcpgw doctor --probe

The live pass, in two sections. Direct spawns or dials every server, runs the MCP handshake and tools/list, and reports name, version and tool count. Through the gateway does the same against the endpoints your synced client entries actually point at — the path a client takes, which a direct probe says nothing about. Probes run in parallel with a per-server timeout (--timeout SECS, default 10).

The second section appears once sync has written entries pointing at a gateway; see Gateway.

For one server in detail, without a gateway:

mcpgw inspect github

Next

Gateway is what your clients are now talking to, Running as a daemon is how it stays up, and Watching traffic is what you get for having it in the middle. If you decide against all of it, Backing out is one command.

Gateway

The gateway is how mcpgw works, not a mode you turn on. mcpgw sync points every client at it: each client talks to mcpgw, and mcpgw talks to the servers.

  Claude Code  ─┐                        ┌─ github    (stdio)
  Cursor       ─┼─→  mcpgw serve  ─────→ ├─ linear    (http)
  VS Code      ─┤    :8137/mcp           └─ postgres  (stdio)
  Claude Desktop┘    (mcpgw connect)

Two things fall out of that shape: one connection per upstream instead of one per client, and a single place where all the traffic is visible. A third is the cost — the gateway has to be running for a client to reach anything, which is what Running as a daemon is for, and one process now holds every server's credentials, which is what the Trust model is about.

Earlier versions could also write each server into each client directly, gateway or no gateway. That choice is gone: entries written straight at the servers were a second shape to test, a second thing for doctor to reason about, and the source of a silent breakage on clients that cannot hold an HTTP entry at all. mcpgw sync writes gateway entries, and mcpgw eject writes the originals back if you want out.

Serving

mcpgw serve

Every enabled server, behind http://127.0.0.1:8137/mcp. Ctrl-C shuts it down and reaps the child processes.

mcpgw serve --port 9000
mcpgw serve --server github --server linear   # a subset (repeatable)
mcpgw serve --no-capture                      # don't write the traffic log

Binding

--bind defaults to 127.0.0.1. Anything else prints a warning, because there is no authentication yet — whoever can reach the address can call your MCP servers:

mcpgw serve --bind 0.0.0.0    # warns loudly; keep it behind something

A gateway under a service manager refuses the same address outright — see Running as a daemon. What loopback does and does not buy you is spelled out in the Trust model.

Tool names

Tools are exposed as server__toolgithub__create_issue, linear__list_issues. Namespacing every tool up front means adding a server can never rename an existing one, which would otherwise silently break a prompt that referenced it.

The exception: --server with exactly one name turns the gateway into a plain pipe to that server, tool names untouched.

Per-server endpoints

Alongside the aggregate, every served server gets its own endpoint, where its tools appear under their own names. No flag — serving one implies serving all of them:

mcpgw serve
# http://127.0.0.1:8137/mcp      — everything, as server__tool
# http://127.0.0.1:8137/s/github — github only, tools unprefixed
# http://127.0.0.1:8137/s/linear — linear only, tools unprefixed

A per-server endpoint is a plain pipe, so it forwards everything an MCP server can offer — tools, resources, resource templates, prompts and argument completion — with names, URIs and errors untouched. Answers are handed back as the server wrote them: caching metadata, _meta, and pagination cursors all survive the hop, and a client pages through a long tools/list against the server's own cursors instead of being handed one list the gateway assembled.

The one thing the gateway does adjust is the protocol revision, because the two sides of it need not agree. A current client speaks MCP 2026-07-28, where every result carries resultType and lists carry ttlMs and cacheScope; the server behind the gateway may predate all three. The gateway fills in what the client's revision requires and never overwrites what the server actually said, so each client gets a reply that is valid for the revision it negotiated. A server that gave no freshness hint is reported as ttlMs: 0, cacheScope: private — "ask again, and do not share it": the gateway will not invent a caching policy on a server's behalf, and the answer was fetched with your credentials.

/mcp serves tools only, and that is deliberate. Tools can be namespaced (github__create_issue); resource URIs and prompt names cannot. Two servers can both offer file:///README.md — one name, two different documents — and rewriting the URIs would break every link inside the contents that points at them. So the aggregate merges what it can merge honestly, and /s/<name> is where the rest lives.

One caveat: an endpoint reports its server's capabilities as of the last time it reached that server. A client connecting to a freshly started gateway, before anything has talked to the server yet, is told "tools" — the conservative answer — because working it out for real would mean starting the server in the middle of a handshake. Anything that connects after the first request through the endpoint sees the full set. The name an endpoint reports at initialize follows the same rule: once the gateway has met the server it answers with that server's own name and version (Context7 4.0.4), which is what a client shows the user; before first contact, and on /mcp, it is mcpgw.

The endpoints share one process and one set of upstream connections, so a client can take the whole gateway, a single server, or both at once without starting anything twice. A stdio-only client reaches one the same way:

mcpgw connect --server github

Upstream lifecycle

Each server gets one connection, multiplexed across every client session — no process per session. If an upstream dies it's restarted with backoff. If it keeps failing, calls to it return a loud error rather than the tool quietly vanishing from tools/list, which is the failure mode that costs you an afternoon.

stdio and HTTP upstreams run through the same lifecycle; from a client's side they're indistinguishable.

Config reload

A running gateway follows the config file. mcpgw add, remove, enable and disable take effect within a couple of seconds — no restart, and nothing disconnected:

mcpgw add github -- npx -y @modelcontextprotocol/server-github
# a moment later, without touching the gateway:
curl http://127.0.0.1:8137/s/github

An added server gets its endpoint and joins /mcp; a removed or disabled one loses both and its process is stopped. A server the edit didn't mention is left completely alone — same connection, same child process — so adding one server never interrupts the others. Only a change to a server's own transport (its command, args, env or URL) restarts that server.

Nothing is torn down under a request in flight: a tools/call that was already running when the config changed still gets its answer from the process it started on.

On Unix, kill -HUP the gateway to reload immediately rather than waiting for the next check. A config file that doesn't parse changes nothing — the gateway says so and keeps serving what it already had, so a typo can't take your servers down.

Pointing clients at it

mcpgw sync

Every enabled server keeps its entry and its name; only the transport changes, to that server's own /s/<name> endpoint. So the client's list of servers looks the same before and after, tools stay unprefixed, and anything the client keeps beside the entry — Cline's off switch, its auto-approved tools — survives the move, because it is still the same entry.

"github": { "type": "http", "url": "http://127.0.0.1:8137/s/github" }

The shape is per client: httpUrl for Gemini, serverUrl for Windsurf, type: "remote" for opencode, and so on. Any running mcpgw serve answers on those endpoints.

mcpgw sync --gateway-url http://127.0.0.1:9000/mcp
mcpgw sync --dry-run
mcpgw sync --rollback              # back to whatever was there before

The other shape: one entry for the whole gateway

mcpgw sync --aggregate

The alternative to one entry per server: a single mcpgw entry pointing at /mcp, where every tool is namespaced server__tool. One entry per client instead of a dozen, at the price of prefixed tool names and no resources or prompts (see above). Worth it when a client charges you per entry — a UI that lists servers, a harness with a low limit — and not otherwise, which is why per-server is the default.

Switching between the two is a normal sync either way: the entries the other shape wrote are mcpgw's own, so they are replaced rather than left behind. Aggregate mode also does not need the canonical config to be readable, since the one entry it writes says nothing about which servers exist.

Checking the path clients take

mcpgw doctor --probe

A server that answers when mcpgw spawns it directly tells you nothing about whether a client can reach it through the gateway, so --probe reports the two separately:

probes — direct to each server
  ✓ github (canonical): github-mcp-server 0.19.1, 41 tools

probes — through the gateway at http://127.0.0.1:8137/mcp
  ✓ http://127.0.0.1:8137/s/github ← Cursor "github", Zed "github": github-mcp-server 0.19.1, 41 tools

The second section takes every entry mcpgw wrote into a client, keeps the ones aimed at the gateway, and runs a real initialize and tools/list against that endpoint — the same request the client makes. Entries you wrote by hand are left alone, and entries pointing somewhere else are not the gateway's business.

Two failures it exists to name. A gateway that isn't running is one error, not one per client:

  ✗ not reachable — start it with `mcpgw serve` (3 endpoint(s) not checked)

And an entry left over from a server that has since been renamed or disabled, which is the failure that otherwise shows up as a client silently missing its tools:

  ✗ Cursor "ghost" points at http://127.0.0.1:8137/s/ghost, which the running
    gateway does not serve — no server endpoint named "ghost" — known
    endpoints: /s/github, /s/linear

--gateway-url points the check at a gateway on another port, matching sync --gateway-url.

stdio-only clients

Claude Desktop only speaks stdio, so it can't be handed a URL. mcpgw connect is the bridge — a stdio server on one side, an HTTP client to the gateway on the other:

mcpgw connect
mcpgw connect --url http://127.0.0.1:9000/mcp
mcpgw connect --server github    # one server's endpoint, tools unprefixed
mcpgw connect --server github --url http://127.0.0.1:9000/mcp

--url alone is the gateway, verbatim. With --server it says where the gateway is and the server's endpoint is resolved on it — which is what sync writes for a stdio-only client.

sync writes this for you; you rarely type it. If the gateway isn't running, the client sees a plain message saying so and telling you to start it — not a transport error.

Running as a daemon

mcpgw serve holds a terminal. That is fine while you are trying the gateway out and wrong the moment you depend on it: the first thing an MCP client does in the morning is ask for a tool list, and nothing is there to answer.

mcpgw daemon is the answer — the gateway supervised by the machine's own service manager, started at login and restarted when it dies.

Since every client entry mcpgw writes points at the gateway, this is not a nicety: a gateway nobody started is a client with no servers. The setup wizard offers to install it for that reason, and mcpgw daemon install is the same step on its own.

Every command works on all three platforms, each through that platform's own supervisor: a launch agent on macOS, a systemd user unit on Linux, a service on Windows. The three sections below are what is different about each; the ones after them are the same everywhere.

macOS: the launch agent

mcpgw daemon install            # or --port 9000 --bind ::1
installed the mcpgw gateway service at ~/Library/LaunchAgents/io.mcpgw.gateway.plist
  macOS will show a "Background Items Added" notification and list mcpgw under
  System Settings › General › Login Items & Extensions — leave it enabled, or the
  gateway will not come back at your next login
  it serves ~/.config/mcpgw/config.toml and runs with the PATH you installed from,
  so re-run `mcpgw daemon install` if either moves
  its output goes to the daemon logs — `mcpgw daemon logs --follow` reads both streams
it will answer on http://127.0.0.1:8137/mcp

The notification is the part worth reading twice. macOS announces every new login item, and the entry it adds is switchable — turn mcpgw off there and the gateway stops coming back at login, with nothing in mcpgw to say why.

What gets installed is a plain launch agent: a plist in ~/Library/LaunchAgents, loaded into your login session with launchctl bootstrap. It is readable, and it is the whole story — cat it if you ever wonder what the daemon is running.

Two things in it are decisions rather than defaults:

  • KeepAlive is a dictionary, SuccessfulExit = false. A gateway that crashes comes straight back; a gateway you stopped stays stopped. The bare KeepAlive = true most generators emit cannot tell those apart, and restarts the one you just asked it to stop.
  • PATH is captured at install time. A launch agent otherwise starts with /usr/bin:/bin:/usr/sbin:/sbin, and almost every stdio MCP server is an npx, uvx or bunx living somewhere else — so the gateway would come up with every stdio server failing to spawn. The cost is that the PATH is frozen: change it, or move your config, and re-run install.

The rest of the commands do what they say:

mcpgw daemon stop        # unloads the job; the plist stays, so status says "stopped"
mcpgw daemon start       # loads it again, on the port it was installed with
mcpgw daemon uninstall   # unloads it and deletes the plist

stop unloads the job rather than signalling it, because a signalled gateway is a gateway that did not exit successfully — which is exactly what KeepAlive restarts on. start runs the plist as it stands, so changing the port means running install again rather than start --port.

Linux: the systemd user unit

mcpgw daemon install            # or --port 9000 --bind ::1
installed the mcpgw gateway service at ~/.config/systemd/user/mcpgw.service
  user lingering is off, so the gateway stops when your last session ends —
  `loginctl enable-linger you` changes that, and mcpgw does not run it for you
  because it outlives every user service you have, not just this one
  it serves ~/.config/mcpgw/config.toml and runs with the PATH you installed from,
  so re-run `mcpgw daemon install` if either moves
  its output goes to the daemon logs — `mcpgw daemon logs --follow` reads both streams
it will answer on http://127.0.0.1:8137/mcp

It is a user unit, not a system one: the gateway runs your MCP servers with your environment and reads config out of your home directory, so nothing about it wants root. Everything is systemctl --user, which means systemctl --user status mcpgw.service and journalctl --user -u mcpgw work on it exactly as you would expect. Install writes the unit, reloads the user manager and enable --nows it, so the gateway is up before the command returns and comes back at your next login.

The unit is short and worth reading:

[Unit]
Description=mcpgw MCP gateway
Documentation=https://github.com/kennywillbe/mcpgw

[Service]
Type=simple
Environment=MCPGW_CONFIG=/home/you/.config/mcpgw/config.toml
Environment=MCPGW_STATE_DIR=/home/you/.local/share/mcpgw
Environment=PATH=/home/you/.local/bin:/usr/local/bin:/usr/bin:/bin
ExecStart=/home/you/.local/bin/mcpgw serve --bind 127.0.0.1 --port 8137
Restart=on-failure
RestartSec=2
StandardOutput=append:/home/you/.local/share/mcpgw/logs/daemon.out.log
StandardError=append:/home/you/.local/share/mcpgw/logs/daemon.err.log

[Install]
WantedBy=default.target

Three of those lines are decisions rather than defaults:

  • Type=simple, and no readiness notification. sd-notify would mean a dependency and a socket protocol to assert something mcpgw daemon status already checks better — by asking the gateway for an HTTP response on the address it was installed for.
  • Restart=on-failure, not always. A gateway that crashes comes straight back; a gateway you stopped stays stopped.
  • PATH is captured at install time. A user unit otherwise starts with the manager's own minimal PATH, and almost every stdio MCP server is an npx, uvx or bunx living under ~/.local/bin or a version manager's shim directory — so the gateway would come up with every stdio server failing to spawn. The cost is that the PATH is frozen: change it, or move your config, and re-run install.

stop and start are systemctl --user stop / start on that unit, and uninstall disables it, deletes the file and reloads. Uninstalling something that is not installed succeeds — the end state is what was asked for.

Logging out stops it, unless you linger

A user manager normally shuts down with your last session, which takes the gateway with it. mcpgw daemon install and mcpgw daemon status both report which side of that you are on:

service   installed under systemd --user, running
          (~/.config/systemd/user/mcpgw.service) — enabled, so it comes back
          at login; user lingering is off, so the gateway stops when your last
          session ends …

mcpgw does not run enable-linger for you. It is an account-wide switch: afterwards every user service you have keeps running while you are logged out, which is a decision about the machine and not about this gateway. If that is what you want — a headless box, or a gateway that answers over an SSH tunnel with no desktop session — run it once yourself:

loginctl enable-linger "$USER"

If loginctl is not there at all, the note says the question could not be answered rather than guessing at it.

Distributions without systemd

On a machine with no systemctl on PATH, install, start, stop and uninstall say so and point at the alternative rather than failing with an errno:

Error: systemd --user: cannot run `systemctl` (No such file or directory) —
this build installs the gateway as a systemd user unit, and this machine does
not appear to be running systemd. Start it with `mcpgw serve` under whatever
supervisor you do have (an OpenRC, runit or s6 service), and `mcpgw daemon
status` will still report on it

Windows: the service

mcpgw daemon install registers a real Windows service called mcpgw ("mcpgw gateway" in the Services console), set to start automatically — at boot, before anyone logs in — and to be restarted by the service manager if it dies.

mcpgw daemon install

The administrator prompt

Registering, starting, stopping and removing a service all need administrator rights. mcpgw tells you why before Windows asks:

Windows needs administrator rights to install a service. It is about to ask
you to approve one elevated `mcpgw daemon install`, which does that and
nothing else. If you say no, nothing changes.

Then the UAC dialog appears. Approving it runs that one command elevated and nothing else; mcpgw waits for it, asks the service manager what actually happened, and reports that. Declining it is a normal answer, not an error to decipher:

Windows needs administrator rights to install a service. You said no, so
nothing was installed and nothing was changed. Two ways forward: open a
terminal as administrator and run `mcpgw daemon install` again, or skip the
service and run `mcpgw serve` in a terminal — same gateway, it just stops
when the terminal does.

mcpgw daemon status and mcpgw daemon logs never prompt: reading the service database needs no rights at all.

What the service actually runs

A Windows service is not an ordinary program — the service manager expects the process it starts to report in as a service within thirty seconds, and mcpgw serve is an ordinary program. So the registered command is an internal one that exists only to be a service: it starts mcpgw serve as its child, redirects that child's output into the two log files mcpgw daemon logs reads, and stops it when Windows asks the service to stop. If the gateway dies, the service ends with its exit code, which is what makes the restart actions fire — three restarts within an hour before Windows gives up.

There is no unit file to look at. The registration lives in HKLM\SYSTEM\CurrentControlSet\Services\mcpgw, which is what mcpgw daemon status prints.

It runs as LocalSystem

This is the one thing worth knowing before it surprises you. A Windows service runs under a machine account, not yours — running it as you would mean mcpgw asking for and storing your password, which it will not do. So:

  • The gateway is pointed at your config file and your log directory explicitly, at install time. It reads the config you edit, not one under C:\Windows\System32.
  • But the MCP servers it launches run as SYSTEM too. A server that needs something only your account has — an entry on your PATH, a credential in your user profile, a tool installed per-user — will not find it there. If a server works under mcpgw serve and not under the service, this is why.

If that trade is wrong for you, mcpgw serve in a terminal is the same gateway with none of it.

Status

mcpgw daemon status
gateway   running — http://127.0.0.1:8137/mcp answers (HTTP 405)
service   not installed under launchd
logs      ~/.local/share/mcpgw/logs/daemon.out.log (not written yet)
          ~/.local/share/mcpgw/logs/daemon.err.log (not written yet)

no service is installed, but a gateway is already answering at
http://127.0.0.1:8137/mcp — that is a foreground `mcpgw serve`, and it stops
when its terminal does

Three separate questions, deliberately: something can be listening on the port without being a gateway, and a gateway can be running without anything being installed to keep it running. --url points the probe somewhere else.

It exits 0 when a gateway is answering and 1 when it is not, so it can be used as a check in a script.

Logs

A supervised gateway has no terminal, so its output goes to two files under the state directory:

mcpgw daemon logs             # the last 50 lines of each
mcpgw daemon logs -n 200
mcpgw daemon logs --follow    # keep printing as it writes

Both streams are shown. A gateway that failed to start says why on stderr and nothing at all on stdout, and picking the wrong file first wastes the minute you were trying to save.

The log directory is 0700 and the files 0600, the same discipline the traffic log gets: everything mcpgw derives from your client configs can carry the tokens in them.

Binding: loopback only

A daemon refuses to install or start on a non-loopback address:

$ mcpgw daemon install --bind 0.0.0.0
Error: refusing to run an unattended gateway on 0.0.0.0: it has no
authentication, so anyone who can reach that address could call your MCP
servers …

mcpgw serve --bind 0.0.0.0 only warns, and the difference is deliberate. A warning works when a person is looking at a terminal and can decide. An unattended service prints its warning into a logfile nobody reads, so the same address that is a judgement call in the foreground is a machine on your network answering MCP calls with no authentication, for as long as it stays up. Loopback is 127.0.0.0/8, ::1 and localhost; put a reverse proxy in front if the gateway has to be reachable from anywhere else.

Port conflicts are refused up front for the same reason — a service that cannot bind fails silently in the background:

Error: something already listens on 127.0.0.1:8137 — run `mcpgw daemon
status` to see whether that is an mcpgw gateway you already started

Watching traffic

While the gateway is serving, it appends one JSON object per upstream request to a daily file. Everything in this chapter reads that file, which is why it works on a gateway that was already running before you started looking — and on yesterday's traffic.

Where it lives

~/.local/share/mcpgw/traffic/2026-09-01.jsonl

One file per day, mode 0600. mcpgw serve --no-capture turns it off.

Following it live

mcpgw watch
watching /Users/you/.local/share/mcpgw/traffic (Ctrl-C to stop)
  now  ✓  [mcp] github__create_issue         87ms
  12s  ✓  [s/linear] linear tools/list        4ms
  30s  ✗  [mcp] github__search_code         210ms  upstream "github" failed after 3 attempt(s)

Age, outcome, the endpoint it arrived on, what was called, how long it took, and the error if there was one. watch replays what's already in today's file before it starts following, so you see context immediately instead of an empty screen.

mcpgw watch --server github        # one upstream
mcpgw watch --tool create_issue    # bare tool name, no server prefix
mcpgw watch --endpoint s/github    # one endpoint (`/s/github` works too)
mcpgw watch --session b1e4c07a     # one downstream client connection
mcpgw watch --json                 # JSONL, for jq
mcpgw watch --json --show-secrets  # …with args/response unmasked
mcpgw watch --json | jq -r 'select(.ok == false) | "\(.server) \(.error)"'

The record format

{
  "ts": 1756742400123,
  "session": "b1e4c07a",
  "endpoint": "mcp",
  "server": "github",
  "tool": "create_issue",
  "kind": "call",
  "duration_ms": 87,
  "ok": true,
  "args": "{\"title\":\"…\"}",
  "response": "{\"content\":[…]}"
}
  • ts — when the request finished, epoch milliseconds. It started duration_ms earlier.

  • session — which downstream client connection the request came from. Over HTTP this is derived from the transport session the client was given at initialize, so two harnesses talking to one gateway get different ids and a client that reconnects gets a new one. It is a fingerprint, not the session id itself: the raw id is a credential and does not belong in a log file. Where the transport has no session — a stdio client, or an HTTP client on MCP 2026-07-28, which removed sessions — it falls back to an id for the gateway process, which cannot tell two clients apart. Same field, and the ids never collide; just a coarser answer.

  • endpoint — which face of the gateway took the request: mcp for the aggregate, s/<server> for a per-server endpoint. Absent on stdio traffic and on lines written before this field existed.

  • kind — which request family the record describes:

    kindmethod
    listtools/list
    calltools/call
    resourcesresources/list
    resource_templatesresources/templates/list
    resource_readresources/read
    promptsprompts/list
    prompt_getprompts/get
    completecompletion/complete

    Everything below call is written only by a per-server endpoint, which is the shape that forwards those families.

  • tool — what the request named: the tool, the prompt, the resource URI or the argument being completed. Absent on the list kinds, which name nothing.

  • ok / errorerror carries the full text; watch's one-line view truncates it, --json doesn't.

  • args / response — see below.

Since it's plain JSONL, tail -f, jq and grep all work on it directly.

Truncation, not redaction

Captured arguments and responses are cut at 2 KB and marked …[truncated]. They are not redacted. If a secret is passed as a tool argument, it lands in that file.

The mitigations today are that the file is 0600 under your own state directory, that mcpgw serve --no-capture disables capture entirely, and that watch does not put those bodies back on your terminal: the one-line view never showed them, and --json replaces each with "***" unless you ask for --show-secrets. That bounds the spread, not the file itself. Redaction at capture time is on the roadmap; until it ships, this is the honest description, and the Trust model puts it beside everything else worth knowing before every call goes through one process.

One server, no gateway

If you just want to know what a single server offers, inspect connects directly and tables its tools and resources:

mcpgw inspect github
mcpgw inspect github --json --timeout 30

No gateway has to be running — it uses the same connect path as doctor --probe.

Trust model

mcpgw now sits between your harnesses and your MCP servers by default: every client entry it writes points at the gateway, and every call your agent makes goes through one process on your machine. That is worth being explicit about. This page is what mcpgw actually does and does not protect, with no marketing in it.

Loopback is the authentication boundary

The gateway has no authentication. It listens on 127.0.0.1:8137, and anything that can open a socket there can call every server you have configured.

That sounds worse than it is, and the reason is worth stating plainly: a process running as you could already do all of it. Your MCP server credentials sit in ~/.cursor/mcp.json, ~/.claude.json, the Claude Desktop config and a dozen files like them, in plaintext, readable by anything with your uid. A process that wanted your Linear token did not need a gateway — it needed cat. mcpgw reaching those same servers over loopback is not a new door into your account; it is the same door, with a socket on it.

So the boundary mcpgw relies on is the user account. Loopback keeps the gateway inside it. That is the whole of the access control, and everything below is about the ways that boundary can be widened or the blast radius behind it can grow.

What the flip actually changed

Pointing every client at the gateway does change two things, and neither is about who can reach what.

Aggregation. Before, each harness held its own credentials and spoke to its own servers. Now one process holds the whole set: every token in your canonical config is loaded by mcpgw serve, and any client that reaches the gateway can use any of them. The credentials did not become more exposed — they were already on the disk — but they became reachable through one place rather than thirteen. A tool allowlist is the answer to that and is on the roadmap; it is not shipped.

One log. Every call now passes a single capture point, and by default it is written down. That is the feature — it is why mcpgw watch can show you what your agent did — and it is also a file that did not exist before. See below.

Captured traffic is truncated, not redacted

The capture log records each request's arguments and the response, cut at 2 KB and marked …[truncated], with nothing removed. A token passed as a tool argument lands in that file in full.

mcpgw serve --no-capture      # no traffic log at all

The file lives under your state directory at mode 0600, and mcpgw watch masks args and response in --json output unless you pass --show-secrets (the human view never printed them). That bounds how far the bodies spread; it does not change what is in the file. Redaction at capture time is on the roadmap. Until it ships, --no-capture is the switch, and Watching traffic is the longer version of this paragraph.

Binding anywhere else

mcpgw serve --bind 0.0.0.0     # warns loudly, then does it

There is no authentication, so this hands your MCP servers — and the credentials behind them — to anything that can reach that address. The warning is real.

A gateway under a service manager refuses the same address outright rather than warning:

$ mcpgw daemon install --bind 0.0.0.0
Error: refusing to run an unattended gateway on 0.0.0.0: it has no
authentication, so anyone who can reach that address could call your MCP
servers …

The difference is deliberate. A warning works when a person is reading a terminal and can decide; an unattended service prints its warning into a log nobody reads and then keeps answering for weeks. Loopback there is 127.0.0.0/8, ::1 and localhost. If the gateway genuinely has to be reachable from another machine, put something that authenticates in front of it.

The Origin check

Binding to loopback is not protection against a browser. Under DNS rebinding a hostile page's own domain resolves to 127.0.0.1, which makes its requests same-origin and lets it POST /mcp with no CORS preflight — a web page driving your MCP servers.

So the gateway rejects any request whose Origin header is not a loopback page (http(s)://localhost, 127.0.0.1 or [::1], with any port) with 403. The null origin a file:// page sends is rejected too. Real MCP clients send no Origin at all and are unaffected.

What is on disk, and who can read it

  • The state directory is 0700; everything mcpgw writes into it — backups of your client configs, managed.json, the traffic logs, the daemon logs — is 0600. Those backups are copies of files that hold tokens, which is why they get the same treatment as the traffic log.
  • The canonical config holds your env values and headers in plaintext, the same way every client config already does. mcpgw list --json masks them; --show-secrets prints them.
  • mcpgw eject puts the original definitions back into every client, so the state above is not a lock-in — see Backing out.

Remote servers and OAuth

A remote MCP server that requires OAuth is authenticated the way it always was: with a token you put in the config as a header. mcpgw forwards it. It does not broker the flow, hold a refresh token, or renew anything on your behalf.

An OAuth 2.1 broker (DCR and PKCE) is on the roadmap, and with it authentication on the gateway itself. Neither is shipped. If you read something that suggests otherwise, this page is the one that is current.

The short version

  • Anything running as you can use the gateway. That was already true of your server credentials.
  • One process now holds all of them, and one log now records every call.
  • The log is not redacted. --no-capture turns it off.
  • Do not --bind past loopback without putting authentication in front.
  • Nothing here is a substitute for not running MCP servers you do not trust.

Configuration reference

One file, TOML, meant to be edited by hand. mcpgw's own write commands go through a syntax tree rather than a serializer, so your comments and ordering survive add, remove, enable and disable.

Location

~/.config/mcpgw/config.toml

The same path on Linux, macOS and Windows — the dev-CLI convention that git, gh and ripgrep use, rather than platform-native config directories. Resolution order:

  1. $MCPGW_CONFIG — a full path to the file, not a directory. Wins over everything.
  2. $XDG_CONFIG_HOME/mcpgw/config.toml
  3. $HOME/.config/mcpgw/config.toml (%USERPROFILE% on Windows)

A missing file is the normal first-run state, not an error.

Shape

# mcpgw canonical config — the single source of truth for your MCP servers.
version = 1

[servers.github]
type = "stdio"
command = "npx"
args = ["-y", "@modelcontextprotocol/server-github"]

[servers.linear]
type = "http"
url = "https://mcp.linear.app/mcp"

version

Required, currently 1. It's read before anything else, so a config from a future mcpgw fails with "unsupported version" instead of a confusing field-level parse error.

[servers.NAME]

NAME must match [a-z0-9-_], and may not contain __ — that sequence is reserved as the gateway's server__tool separator.

Common to both transports:

KeyTypeDefaultMeaning
type"stdio" | "http"requiredwhich transport
enabledbooltruefalse keeps the entry but skips it everywhere
tagslist of strings[]free-form grouping

type = "stdio":

KeyTypeDefaultMeaning
commandstringrequiredexecutable, resolved on PATH
argslist of strings[]passed verbatim
envtable of strings{}added to the child's environment

type = "http":

KeyTypeDefaultMeaning
urlstringrequiredStreamable HTTP endpoint
headerstable of strings{}sent on every request

Everything at once

version = 1

[servers.postgres]
type = "stdio"
command = "mcp-server-postgres"
args = ["--readonly"]
tags = ["work", "db"]

  [servers.postgres.env]
  PGHOST = "localhost"
  PGDATABASE = "app"

[servers.staging]
type = "http"
url = "https://staging.example.com/mcp"
enabled = false
tags = ["work"]

  [servers.staging.headers]
  Authorization = "Bearer sk-…"

Values must come before sub-tables within a section — that's TOML, not mcpgw. env and headers are sub-tables, so they go last.

State directory

~/.local/share/mcpgw/
├── managed.json          which server names mcpgw wrote into which client
├── backups/
│   └── cursor/           timestamped copies, newest 5 kept per client
└── traffic/
    └── 2026-09-01.jsonl  daily capture log, mode 0600

Resolution order:

  1. $MCPGW_STATE_DIR
  2. $XDG_DATA_HOME/mcpgw
  3. $HOME/.local/share/mcpgw

managed.json is how sync knows what it owns. Deleting it is safe: every client entry then counts as unmanaged, and sync stops touching them until you re-adopt them with import.

backups/ is written before every client file is rewritten. The five most recent per client are kept; mcpgw sync --rollback restores the newest.

traffic/ is the capture log — see Watching traffic.

Environment variables

VariableEffect
MCPGW_CONFIGfull path to the canonical config file
MCPGW_STATE_DIRoverrides the state directory
MCPGW_NO_UPDATE_CHECKany non-empty value switches the version notice off
XDG_CONFIG_HOMEbase for the config path when MCPGW_CONFIG is unset
XDG_DATA_HOMEbase for the state dir when MCPGW_STATE_DIR is unset

The mcpgw-specific variables are ignored when set to the empty string. Setting the pair of them is the clean way to run mcpgw against a scratch environment:

MCPGW_CONFIG=/tmp/try/config.toml MCPGW_STATE_DIR=/tmp/try/state mcpgw list

Backing out

mcpgw eject puts every client back the way it was before mcpgw. Your Cursor, Codex and Claude Desktop entries go back to pointing straight at your servers, the gateway entry disappears, and the daemon comes out with them.

mcpgw eject

It exists so that adopting mcpgw is never a one-way door. If the gateway isn't for you, one command undoes it — and you don't need mcpgw installed afterwards for your clients to keep working.

What it writes

Your canonical config still holds every server as you originally defined it — the command, the args, the env, the URL and headers. Gateway mode never replaced those; it only changed what your clients were pointed at. Eject writes the originals back under the same names, so a gateway entry is a plain update over the entry it already occupies, not a remove and an add.

Everything else in the file is left alone: entries mcpgw never wrote, other settings, comments, formatting.

Show and confirm

Like every command that touches your files, eject prints the whole plan first and asks once:

mcpgw eject — putting every client back the way it was.

Cursor — 2 entries restored, 1 removed
  ~ github back to your own definition
  ~ linear back to your own definition
  - mcpgw removed (mcpgw put it there)
  ? mine (not mine — left untouched)

Every file is backed up before it is written, and `mcpgw sync --rollback`
undoes this run like any other.
restore these clients? [Y/n]

--yes skips the question; the plan still prints.

Eject writes through the same machinery as mcpgw sync, so it takes the same backups — mcpgw sync --rollback restores each client from the snapshot taken just before eject wrote it.

The daemon

If a gateway service is installed, eject names it and offers to remove it in the same run:

A gateway service is installed under launchd (~/Library/LaunchAgents/io.mcpgw.gateway.plist).
remove it as well? [Y/n] y
  removed it — your config and captured traffic are untouched

Nothing installed, or a platform whose installer hasn't shipped yet, is one dimmed line and no question.

What eject does not delete

Your data is yours. Eject rewrites client configs and stops there — the canonical config, the state directory and the binary all stay, and the closing screen names them so a full uninstall is three deletions you make yourself:

Nothing of yours was deleted. To remove mcpgw entirely, delete these yourself:
  config   ~/.config/mcpgw/config.toml
  state    ~/.local/share/mcpgw   (backups, logs, captured traffic)
  binary   brew uninstall kennywillbe/tap/mcpgw

The binary line matches how mcpgw was installed — cargo uninstall mcpgw for a cargo install, the path to delete for a downloaded archive.

Keeping the config is also what makes the decision reversible: run mcpgw again and the wizard puts everything back.

Edge cases

  • Nothing was ever synced. Eject says nothing to eject and exits 0 without touching a file.
  • A client config was deleted by hand. It's reported and skipped; eject never recreates a config someone removed.
  • No canonical config. Eject stops with an error: the original definitions live there, and without them there is nothing to restore. mcpgw import pulls what a client still holds back into the config first, and mcpgw sync --rollback restores clients from their most recent backup.
  • An HTTP server on Claude Desktop. Claude Desktop only speaks stdio, so that entry couldn't reach an HTTP server before mcpgw either. Eject writes back exactly what your config says, unchanged — a faithful restore, not a quiet repair.

Roadmap

Ordered by what feedback asks for first, not by what's fun to build.

  • tool allowlist, deny-by-default — decide which tools a client may reach through the gateway at all.
  • tool-definition drift detection — notice when a server quietly changes what a tool does under a name your prompts already trust.
  • log redaction — the missing half of traffic capture; see Watching traffic.
  • rate limiting.
  • OAuth 2.1 with DCR and PKCE — brokering the flow for remote servers that require it, instead of a token you paste into a header, and with it authentication on the gateway itself so --bind stops being a warning.
  • a full TUI for watch.
  • mcpgw connect starting a gateway on its own when no daemon is installed, so a client that dials a gateway nobody started still works.

None of it is shipped.

Already shipped

Things this page used to promise, so the line is clear:

  • The setup wizard — a bare mcpgw on a terminal; see Quickstart.
  • The gateway as the only sync mode. mcpgw sync writes entries that point at mcpgw. There is no direct mode to choose any more; --gateway is an accepted no-op for one release.
  • Per-server endpoints (/s/<name>), on by default.
  • Running as a service on macOS, Linux and Windows — see Running as a daemon.
  • mcpgw eject — every client back the way it was.
  • Config hot reload — a running gateway follows the config file.

Known limits today

  • The gateway is unauthenticated. It binds to 127.0.0.1 by default for that reason, and a daemon refuses anything else. --bind past loopback in the foreground and you are trusting your network — the Trust model is the long version.
  • Captured bodies are truncated, not redacted. A secret passed as a tool argument lands in the traffic file. It's mode 0600, and --no-capture turns it off.
  • A remote server's OAuth is your problem. mcpgw forwards the header you configure; it does not run the flow and does not refresh anything.
  • No linux-arm64 prebuilt binary yet. cargo install mcpgw works there.
  • The aggregate endpoint serves tools only. Resources and prompts reach a client through a per-server endpoint (/s/<name>), because their names cannot be namespaced across servers the way tool names can. See Gateway.

Asking for things

Open an issue at github.com/kennywillbe/mcpgw/issues. "Here's what I was trying to do" is more useful than a feature name — the order above is going to change based on that.

Patches welcome; see CONTRIBUTING.md.