Skip to content

Repository files navigation

mcp-proxy

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)

Why this exists

The problem: restarting an MCP server breaks the client, not just the server

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.

The fix: put a durable hop in the middle

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 401 response 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 tokenSource is 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_changed notifications 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.

Two things that fall out of it

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.


How it works

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, Server instance 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.
  • unhandledRejection is logged, never fatal. Transient fetch failures during a reconnect must not take down the daemon whose job is staying up.

Install

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 Configuration

config.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.

Running

./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 development

Or under PM2, to survive reboots:

pm2 start ecosystem.config.cjs
pm2 save
pm2 startup             # run the printed sudo command once

Point your client at it

Any MCP client that speaks Streamable HTTP. For Claude Code:

{
  "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" }
  }
}

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.


Configuration

Top-level keys

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

Per-upstream keys

Shared: name, path, type (http | stdio), plus optional waitMs, callTimeoutMs and smokeCall.

  • type: "http" — url, optional headers, optional tokenSource.
  • type: "stdio" — command, args, cwd, optional env.

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}" }

tokenSource

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-grant endpoint 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.

smokeCall

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

Endpoints

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)

Security

The defaults assume a proxy fronting privileged servers on a workstation:

  • Loopback bind by default. host defaults to 127.0.0.1. Widening it exposes every upstream behind the proxy, so it must be set deliberately.
  • DNS-rebinding protection. MCP endpoints validate Host and Origin, so a web page you happen to have open can't fetch() your local servers. Add legitimate browser origins to allowedOrigins; non-browser clients, which send no Origin, are always allowed.
  • No secrets in the repo. Credentials live in env vars, or in ~/.mcp-proxy/credentials.json (mode 0600, directory 0700). config.json holds ${ENV_VAR} placeholders, never literals — and is gitignored anyway.
  • No default passwords. scripts/start.sh deliberately does not default the upstream admin credentials. Unset means a 401 from the upstream, which is better than a guessable default that works.
  • The admin GUI fails closed. With MCP_PROXY_ADMIN_SECRET unset, every /admin route returns 503 with a setup hint. It is never reachable unauthenticated.

Rotating upstream credentials

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/admin

start.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.


Operational notes

  • Don't kill the proxy while bouncing an upstream. lsof -ti :3848 matches 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. Use lsof -ti :3848 -sTCP:LISTEN, or kill the upstream PID directly.
  • Watch it work: tail -f logs/proxy.out logs/proxy.err while restarting an upstream shows the close, the backoff, the reconnect and the parked call completing.

License

Project Sandstar Source-Available License (PSSL) v1.1 — see LICENSE.

About

Reconnecting MCP proxy. Fronts multiple HTTP/stdio MCP servers behind one port so clients survive upstream restarts instead of losing their session.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages