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, zoo —
mcpgw 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__tool — github__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:
KeepAliveis a dictionary,SuccessfulExit = false. A gateway that crashes comes straight back; a gateway you stopped stays stopped. The bareKeepAlive = truemost generators emit cannot tell those apart, and restarts the one you just asked it to stop.PATHis captured at install time. A launch agent otherwise starts with/usr/bin:/bin:/usr/sbin:/sbin, and almost every stdio MCP server is annpx,uvxorbunxliving somewhere else — so the gateway would come up with every stdio server failing to spawn. The cost is that thePATHis frozen: change it, or move your config, and re-runinstall.
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 somethingmcpgw daemon statusalready checks better — by asking the gateway for an HTTP response on the address it was installed for.Restart=on-failure, notalways. A gateway that crashes comes straight back; a gateway you stopped stays stopped.PATHis captured at install time. A user unit otherwise starts with the manager's own minimalPATH, and almost every stdio MCP server is annpx,uvxorbunxliving under~/.local/binor a version manager's shim directory — so the gateway would come up with every stdio server failing to spawn. The cost is that thePATHis frozen: change it, or move your config, and re-runinstall.
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
SYSTEMtoo. A server that needs something only your account has — an entry on yourPATH, a credential in your user profile, a tool installed per-user — will not find it there. If a server works undermcpgw serveand 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 startedduration_msearlier. -
session— which downstream client connection the request came from. Over HTTP this is derived from the transport session the client was given atinitialize, 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:mcpfor 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/listcalltools/callresourcesresources/listresource_templatesresources/templates/listresource_readresources/readpromptsprompts/listprompt_getprompts/getcompletecompletion/completeEverything below
callis 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/error—errorcarries the full text;watch's one-line view truncates it,--jsondoesn'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 — is0600. 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
envvalues and headers in plaintext, the same way every client config already does.mcpgw list --jsonmasks them;--show-secretsprints them. mcpgw ejectputs 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-captureturns it off. - Do not
--bindpast 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:
$MCPGW_CONFIG— a full path to the file, not a directory. Wins over everything.$XDG_CONFIG_HOME/mcpgw/config.toml$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:
| Key | Type | Default | Meaning |
|---|---|---|---|
type | "stdio" | "http" | required | which transport |
enabled | bool | true | false keeps the entry but skips it everywhere |
tags | list of strings | [] | free-form grouping |
type = "stdio":
| Key | Type | Default | Meaning |
|---|---|---|---|
command | string | required | executable, resolved on PATH |
args | list of strings | [] | passed verbatim |
env | table of strings | {} | added to the child's environment |
type = "http":
| Key | Type | Default | Meaning |
|---|---|---|---|
url | string | required | Streamable HTTP endpoint |
headers | table 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:
$MCPGW_STATE_DIR$XDG_DATA_HOME/mcpgw$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
| Variable | Effect |
|---|---|
MCPGW_CONFIG | full path to the canonical config file |
MCPGW_STATE_DIR | overrides the state directory |
MCPGW_NO_UPDATE_CHECK | any non-empty value switches the version notice off |
XDG_CONFIG_HOME | base for the config path when MCPGW_CONFIG is unset |
XDG_DATA_HOME | base 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 ejectand 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 importpulls what a client still holds back into the config first, andmcpgw sync --rollbackrestores 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
--bindstops being a warning. - a full TUI for
watch. mcpgw connectstarting 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
mcpgwon a terminal; see Quickstart. - The gateway as the only sync mode.
mcpgw syncwrites entries that point at mcpgw. There is no direct mode to choose any more;--gatewayis 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.1by default for that reason, and a daemon refuses anything else.--bindpast 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-captureturns 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 mcpgwworks 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.