A gateway that lets many Claude Code sessions share one stdio MCP server.
Claude Code spawns a fresh stdio MCP server for every session. Fifteen sessions means
fifteen copies of every server. Register them with npx -y <pkg> and each one drags a
wrapper process along, doubling the count again.
Measured on a real machine (17 sessions, 5 MCP servers): 169 processes, 6.7 GB RSS.
mmux starts each backend once and exposes it over HTTP. The cost stops scaling with session count.
| Today (stdio) | mmux | |
|---|---|---|
| 3 servers × 15 sessions | 90 processes · 3.48 GB | 7 processes · 456 MB |
| Adding sessions | grows linearly | no change |
A method-agnostic frame relay. It does exactly two things:
- Rewrites request ids. Client ids are local to each client, so sessions A and B
both send
id=1. Outbound requests get a global id; responses are matched back to the original id and restored. - Intercepts
initialize. The backend is initialized once at startup. Clientinitializecalls are answered from the cached result — forwarding them would re-initialize the backend and break every session already attached.
Everything else passes through untouched, so new MCP methods need no code changes.
For contrast,
mcp-proxyregisters a handler per method and runs 1,274 lines, which is why it breaks whenever the MCP SDK shifts. mmux never parsesparams.
~/.config/mmux/config.json:
{
"listen": "127.0.0.1:9090",
"servers": {
"memory": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-memory"],
"env": { "MEMORY_FILE_PATH": "/Users/me/.claude/memory.jsonl" }
},
"filesystem": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-filesystem", "/Users/me/projects"]
}
}
}Copy command, args, and env straight from your existing Claude Code config —
the MCP server cannot tell the difference between being launched by Claude Code and
being launched by mmux.
Per-server fields:
| Field | Required | Notes |
|---|---|---|
command |
yes | executable; the ambient PATH is inherited |
args |
argument array | |
env |
added to the inherited environment, not a replacement | |
cwd |
working directory |
Top level: listen (default 127.0.0.1:9090) and protocolVersion (default 2025-06-18).
Unknown fields are rejected at startup, so a typo like "comand" fails loudly instead
of being silently ignored.
Each key becomes a route: http://<listen>/<key>/mcp. Adding a server to the config
makes its endpoint appear — no code changes.
The config is read once at startup. There is no hot reload — restart mmux after editing it.
0. Decide whether it is safe to share. This is the only hard part; see What is safe to share. The test is whether the server keeps per-session state inside its own process. Read its source and look for accumulation on an instance field — an array it always appends to, a map keyed by something the client chose, a handle it holds open. If you find one, do not share it. When in doubt, don't: a broken tool costs more than the memory it saves.
1. Add it to the config.
"sqlite": {
"command": "uvx",
"args": ["mcp-server-sqlite", "--db-path", "/Users/me/data.db"]
}2. Restart mmux and confirm the backend came up.
pkill -x mmux && mmux
curl -s localhost:9090/status | python3 -m json.toolThe new entry must show "up": true. If it stays false, run mmux -debug and read
the backend's stderr.
3. Register it with Claude Code.
claude mcp add --scope user sqlite --transport http http://127.0.0.1:9090/sqlite/mcpOr edit ~/.claude.json directly:
{
"mcpServers": {
"sqlite": { "type": "http", "url": "http://127.0.0.1:9090/sqlite/mcp" }
}
}Verify with claude mcp list — it should report ✔ Connected.
Do not put credentials in env as plaintext. mmux execs command with args
verbatim, so a shell wrapper that reads from the OS keychain works as-is and keeps the
secret out of both the config file and the wider environment:
"somesvc": {
"command": "sh",
"args": ["-c", "API_KEY=$(security find-generic-password -s somesvc-api -w) exec npx -y some-mcp-server"]
}Register the secret once (-w last, with no value, so it is prompted for rather than
landing in shell history and the process table):
security add-generic-password -s somesvc-api -a API_KEY -U -wOn Linux, substitute secret-tool lookup service somesvc-api or equivalent.
go build -o mmux . && ./mmux
./mmux -config /path/to/config.json -debugTo run it as a command from anywhere, symlink the binary onto your PATH. A symlink
rather than a copy means a rebuild takes effect immediately:
ln -sf "$PWD/mmux" ~/.local/bin/mmux
mmux -versionIt runs in the foreground and holds the terminal. Ctrl+C shuts it down and takes the backends with it, process group and all.
Claude Code's config points at mmux, so every session loses those servers whenever it is not running. Install it as a launchd agent and that stops being your problem:
mmux install # writes the plist, loads it, waits for the backends
mmux uninstall # unloads and removes itinstalled: /Users/me/Library/LaunchAgents/dev.mmux.plist
binary: /Users/me/orca/mmux/mmux
config: /Users/me/.config/mmux/config.json
log: /Users/me/Library/Logs/mmux.log
filesystem up pid=13907 http://127.0.0.1:9090/filesystem/mcp
memory up pid=13906 http://127.0.0.1:9090/memory/mcp
It starts at login and restarts within a second if it dies. install validates the
config before touching launchd and then polls /status until every backend reports up,
so a broken setup fails at install time instead of silently later.
The generated plist captures the PATH of the shell that ran install. This is not
incidental: launchd starts a service with a bare PATH, and MCP servers are usually
launched through npx, which commonly lives in a version-manager shim directory. Without
it mmux comes up looking healthy while every backend dies with executable file not found. Re-run mmux install if your toolchain paths change.
launchctl list | grep mmux # is it registered, what did it exit with
tail -f ~/Library/Logs/mmux.log # logs
mmux install # also the way to apply a rebuilt binaryOn Linux, write a systemd --user unit that runs mmux -config <path>; install is
launchd-only and will tell you so.
The proxy makes sharing possible, not safe. Sharing a server that keeps per-session state in memory is not a saving — it is a bug.
Rule of thumb: a server is shareable when a request carries everything needed to answer it. It is not shareable when the server remembers what a particular client did earlier. Stateless lookups, writes to an external store, and roots fixed at launch are fine; in-process accumulation and held-open handles are not.
| Server | Share | Why |
|---|---|---|
server-memory |
✅ a net win | N instances used to race on the same memory.jsonl; one process serializes the writes |
server-filesystem |
✅ | allowed roots are fixed in config, identical for every session |
context7 |
✅ | stateless document fetches |
sequential-thinking |
❌ | keeps the thought chain in a single in-process array; sessions interleave and corrupt it |
chrome-devtools |
❌ | holds browser and tab state; sessions would steal each other's tabs |
- Single point of failure. If a backend dies, every attached session loses that tool at once. mmux restarts it after 2s and fails in-flight requests immediately so sessions do not hang until their timeout.
- No server-initiated requests. There is no basis for choosing which session should
answer
sampling/createMessage. mmux declares empty capabilities at initialize, so a well-behaved server never sends one. notifications/cancelledis dropped. Itsparams.requestIdis a client-local id; forwarding it verbatim would cancel another session's request.- Notifications are broadcast. Routing
notifications/progressback to the original requester would need aprogressToken→ session map. The shareable servers do not emit progress. - No JSON-RPC batching. MCP 2025-06-18 removed it.
npx -y <pkg> runs an npm exec wrapper (~85 MB) alongside the real server (~65 MB).
Install globally and point at the binary to drop the wrapper entirely — roughly
456 MB → 220 MB for three servers.
npm i -g @modelcontextprotocol/server-memory
# config.json: {"command": "mcp-server-memory"}go test ./... -raceTestConcurrentSessionsDoNotCrossTalk is the one that matters: two sessions issue the
same id concurrently and the responses come back in reverse order. If id rewriting
regresses, one session receives the other's answer.
TestShutdownKillsGrandchild reproduces the npx shape (wrapper → grandchild) and
verifies the grandchild dies too. Without process-group cleanup the grandchild survives
holding the stdout pipe open, which hangs the restart loop rather than merely leaking.