A reconnecting proxy for MCP servers. It fronts any number of HTTP and stdio MCP servers behind a single HTTP port, holds the upstream connections itself, and transparently reconnects when an upstream restarts — so the MCP client never sees a disconnect.
┌─► some-mcp (HTTP :3848)
MCP client ──HTTP──► mcp-proxy ─┼─► other-mcp (HTTP :3847)
(session never drops) └─► local-mcp (stdio child)
MCP clients open a long-lived session to each configured server. That session is stateful: it carries a negotiated protocol version, a session id, a cached tool/prompt/resource list, and — for HTTP transports — an open stream. The session is established once, at client startup.
Nothing in that model survives the server going away. When you restart an MCP server, the failure is not confined to the server you restarted:
- The session dies and does not come back. The transport closes, or worse, half-closes: the stream stays open while the server has forgotten the session id entirely. The next tool call fails with a "session not found" error that never fired a close event, so nothing downstream even knows to retry.
- The client does not transparently re-establish it. In practice you restart the client, or toggle the server off and on, to get a working connection back.
- Restarting the client costs you the thing you were actually doing. An agent session's accumulated context, your place in a long task, the other servers that were working fine — all of it goes, because one unrelated server restarted.
- It happens constantly while developing an MCP server. Every build, every schema tweak, every crash-and-fix is a restart. The edit-test loop on an MCP server is exactly the loop this breaks, so the cost lands hardest on the person writing the server.
- Token expiry looks identical. When an OAuth access token's TTL elapses, the server closes the stream. Same broken session, same manual recovery, on a timer you didn't choose.
The result is a tax on every restart, paid by the client. And it scales the wrong way: the more MCP servers you run, the more often something is restarting, and the more often every client gets disrupted by a server it wasn't even using.
mcp-proxy splits the one fragile connection into two independent halves and
absorbs the failure in between:
client ──── session A ────► mcp-proxy ──── session B ────► upstream server
(durable) (disposable)
- Session A — client → proxy — never drops. The proxy is a long-running daemon that isn't rebuilt when an upstream is. As far as the client is concerned, the server is simply always up.
- Session B — proxy → upstream — is disposable and rebuilt on demand. The proxy owns one upstream connection per configured server and reconnects with exponential backoff (500 ms doubling to a 10 s ceiling) whenever it closes.
- Calls that arrive during the gap wait instead of failing. A request that
lands mid-outage parks inside the proxy for up to
waitMs(default 15 s) and then runs against the freshly rebuilt connection. From the client's side a restart is a slow call, not an error. - The half-open case is handled explicitly. A stale-session or
401response force-closes the dead transport, rebuilds, and retries the request once — so the failure mode that produces no close event still recovers without the caller noticing. - Token refresh rides the same path. A
tokenSourceis re-invoked on every (re)connect, so an expired token becomes an ordinary reconnect and a freshly minted one. There is no separate refresh daemon to run or get wrong. - The client's cached tool list is corrected after a reconnect. If an
upstream comes back with different tools, the proxy fans
tools/prompts/resources list_changednotifications out to every connected session, so nobody keeps calling a tool that no longer exists.
Restart an upstream, and the client keeps working. That is the whole point.
One port, many servers. Each upstream gets its own path
(/<name>/mcp), so a client config points at one host and port and gets
every server. Adding a server is a config entry and a proxy restart, not a
change to every client on the machine.
Stdio servers stop being spawned per client. Normally each client spawns its own child process for a stdio MCP server. Run three clients and you get three processes contending for the same on-disk state — caches, index or graph databases, per-project lock files. The proxy spawns one child and shares it across all downstream sessions, which removes the contention and cuts the startup cost to once.
| Layer | File | Responsibility |
|---|---|---|
| HTTP listener | server.mjs |
reads config, builds one adapter per upstream, mounts routes |
| Downstream sessions | routes/mcpRoute.mjs |
one MCP session per client, all sharing a single upstream client; forwards requests and fans out notifications |
| HTTP upstream | upstreams/httpUpstream.mjs |
long-lived client + Streamable HTTP transport, reconnect, stale-session/401 retry |
| Stdio upstream | upstreams/stdioUpstream.mjs |
shared child process, respawn, crash-loop guard |
| Token providers | upstreams/tokenSources.mjs |
pluggable bearer tokens, re-minted per connect |
| Credential store | upstreams/credentialsStore.mjs |
~/.mcp-proxy/credentials.json, mode 0600 |
| Admin GUI | routes/adminRoute.mjs |
rotate upstream credentials without a restart |
| Install docs | routes/docs.mjs, docs/install.md.tmpl |
live install page rendered from the running config |
A few details worth knowing:
- Sessions are per client, the upstream connection is shared. Every client
that connects to the same path gets its own session id,
Serverinstance and transport, but they all multiplex onto one upstream connection. - Progress notifications are routed, not broadcast. If a client sends a
_meta.progressToken, progress from the upstream is re-emitted to that request's stream under the client's original token — no cross-session leakage, and long-running tools report progress normally through the proxy. - A crash-looping stdio child is contained. Five exits inside 500 ms backs the respawn off to the 10 s ceiling and surfaces an error to connected sessions, rather than spinning on a child that cannot start.
- A bad upstream entry is skipped, not fatal. Unknown type, missing keys, a duplicate path, or a stdio command that doesn't exist is logged and dropped; startup fails only if no valid upstream remains.
unhandledRejectionis logged, never fatal. Transient fetch failures during a reconnect must not take down the daemon whose job is staying up.
Requires Node 18+.
git clone https://github.com/Project-SandStar/mcp-proxy.git
cd mcp-proxy
npm install
cp config.example.json config.json # then edit it — see Configurationconfig.json is gitignored: it names your real hosts, ports and local paths,
so it stays out of version control. config.example.json is the template.
./scripts/start.sh # background; PID in .mcp-proxy.pid, logs in logs/
./scripts/restart.sh # idempotent restart
./scripts/stop.sh # SIGTERM, then SIGKILL after a 5s grace period
npm start # foreground, for developmentOr under PM2, to survive reboots:
pm2 start ecosystem.config.cjs
pm2 save
pm2 startup # run the printed sudo command onceAny MCP client that speaks Streamable HTTP. For Claude Code:
Note that local-mcp is a stdio server, but the client talks HTTP to it — the
proxy owns the stdio child. Restart the client once after editing this. From
then on, restarting any upstream is invisible to it.
| Key | Default | Meaning |
|---|---|---|
port |
9191 |
TCP port to listen on |
host |
127.0.0.1 |
bind address; loopback only unless you change it deliberately |
waitMs |
15000 |
how long a request waits for a (re)connect before failing; per-upstream overridable |
callTimeoutMs |
120000 |
per-request timeout once connected; raise it per upstream for slow tools |
logLevel |
info |
error | warn | info | debug |
allowedOrigins |
[] |
extra browser origins for CORS and DNS-rebinding checks |
Shared: name, path, type (http | stdio), plus optional waitMs,
callTimeoutMs and smokeCall.
type: "http"—url, optionalheaders, optionaltokenSource.type: "stdio"—command,args,cwd, optionalenv.
headers values support ${ENV_VAR} interpolation, resolved on every
reconnect so rotating an env var takes effect without a code change:
"headers": { "Authorization": "Bearer ${MY_TOKEN}" }A bearer-token provider invoked on every connect and reconnect — the mechanism that turns token expiry into an ordinary reconnect:
static—{ "type": "static", "token": "${MY_TOKEN}" }fantom-admin-grant— posts Basic Auth credentials to an/admin/oauth/token-grantendpoint and mints a fresh short-lived token
Cached tokens are reused while within TTL, so a flapping upstream doesn't mint a token per reconnect.
Optionally exercise a real tools/call in the smoke test. A tools/list-only
check is green on transport but silent on function — it won't catch a dropped
argument bag:
"smokeCall": { "name": "some_tool", "arguments": { "query": "ping", "limit": 1 } }npm run smoke # lists tools against every endpoint, runs any smokeCall| Endpoint | Purpose |
|---|---|
GET / and /docs |
install page rendered from the live config |
GET /status |
JSON: per-upstream type, path, connection state, session count |
GET /config.json |
the running config, for debugging |
POST /<name>/mcp |
the MCP endpoint for each configured upstream |
/admin |
credential-rotation GUI (requires MCP_PROXY_ADMIN_SECRET) |
The defaults assume a proxy fronting privileged servers on a workstation:
- Loopback bind by default.
hostdefaults to127.0.0.1. Widening it exposes every upstream behind the proxy, so it must be set deliberately. - DNS-rebinding protection. MCP endpoints validate
HostandOrigin, so a web page you happen to have open can'tfetch()your local servers. Add legitimate browser origins toallowedOrigins; non-browser clients, which send noOrigin, are always allowed. - No secrets in the repo. Credentials live in env vars, or in
~/.mcp-proxy/credentials.json(mode 0600, directory 0700).config.jsonholds${ENV_VAR}placeholders, never literals — and is gitignored anyway. - No default passwords.
scripts/start.shdeliberately does not default the upstream admin credentials. Unset means a401from the upstream, which is better than a guessable default that works. - The admin GUI fails closed. With
MCP_PROXY_ADMIN_SECRETunset, every/adminroute returns503with a setup hint. It is never reachable unauthenticated.
Set a strong secret, restart, then open /admin:
export MCP_PROXY_ADMIN_SECRET="$(openssl rand -hex 32)"
./scripts/restart.sh
# then visit http://localhost:9191/adminstart.sh generates this secret on first run if it's unset and persists it to
.admin-secret (mode 0600) so it survives restarts. That file is gitignored
and must stay that way — it is the key to the admin routes.
The GUI shows connection state, last mint time and TTL remaining per upstream, with a form for username / password / URL override. Submitting persists the change and immediately reconnects that upstream with the new credentials — no proxy restart, and no interruption to connected clients. Stored credentials take precedence over the env-var defaults on subsequent startups; delete the file, or a key within it, to fall back to env.
Both operations are also available programmatically:
curl -sS -X POST http://localhost:9191/admin/save \
-H "Authorization: Bearer $MCP_PROXY_ADMIN_SECRET" \
-H 'Content-Type: application/json' \
-d '{"name":"some-mcp","username":"USERNAME","password":"NEW_PASSWORD"}'
curl -sS "http://localhost:9191/admin/status?secret=$MCP_PROXY_ADMIN_SECRET"The admin secret itself rotates from the GUI's "Admin password" card without a
restart (written atomically to .admin-secret, in-process env updated), or by
editing .admin-secret and restarting.
- Don't kill the proxy while bouncing an upstream.
lsof -ti :3848matches the proxy's outbound connection to that port as well as the upstream's listener, so a stop script built on it will take the proxy down too. Uselsof -ti :3848 -sTCP:LISTEN, or kill the upstream PID directly. - Watch it work:
tail -f logs/proxy.out logs/proxy.errwhile restarting an upstream shows the close, the backoff, the reconnect and the parked call completing.
Project Sandstar Source-Available License (PSSL) v1.1 — see LICENSE.
{ "mcpServers": { "some-mcp": { "type": "http", "url": "http://localhost:9191/some-mcp/mcp" }, "other-mcp": { "type": "http", "url": "http://localhost:9191/other-mcp/mcp" }, "local-mcp": { "type": "http", "url": "http://localhost:9191/local-mcp/mcp" } } }