Skip to content

Repository files navigation

mmux — one MCP backend, many sessions

mmux

A gateway that lets many Claude Code sessions share one stdio MCP server.

One backend, many sessions

Why

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

Design

A method-agnostic frame relay. It does exactly two things:

  1. 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.
  2. Intercepts initialize. The backend is initialized once at startup. Client initialize calls 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-proxy registers a handler per method and runs 1,274 lines, which is why it breaks whenever the MCP SDK shifts. mmux never parses params.

Configuration

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

Adding a server

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

The 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/mcp

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

Servers that need a secret

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 -w

On Linux, substitute secret-tool lookup service somesvc-api or equivalent.

Running

go build -o mmux . && ./mmux
./mmux -config /path/to/config.json -debug

To 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 -version

It runs in the foreground and holds the terminal. Ctrl+C shuts it down and takes the backends with it, process group and all.

Running it as a service (macOS)

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 it
installed: /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 binary

On Linux, write a systemd --user unit that runs mmux -config <path>; install is launchd-only and will tell you so.

What is safe to share

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

Limits

  • 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/cancelled is dropped. Its params.requestId is a client-local id; forwarding it verbatim would cancel another session's request.
  • Notifications are broadcast. Routing notifications/progress back to the original requester would need a progressToken → session map. The shareable servers do not emit progress.
  • No JSON-RPC batching. MCP 2025-06-18 removed it.

Further savings

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

Tests

go test ./... -race

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

License

MIT

About

Share one stdio MCP server across many Claude Code sessions. A method-agnostic HTTP frame relay in Go, no dependencies.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages