MCP Proxy
Claude Code starts every configured MCP server when a session opens, before any tool is used. With many stdio servers that costs hundreds of MB to a few GB per session, and each session runs its own copy.
aimux mcp proxy puts your stdio MCP servers behind shared HTTP endpoints:
- Lazy: tools are listed from a cached catalog, and a server starts on its first tool call.
- Shared: one process per server serves every session.
- Stopped when idle: a shared server unused for 10 minutes (configurable) is stopped, including any child processes started by
npxoruv. A per-session server (a browser) lives as long as the Claude Code process that uses it, as it would directly. - Current tool lists: when a server starts and lists tools its catalog did not have (an upgrade added one), open sessions get the new list right away.
- Same names: each server keeps its own name, and every tool keeps its original name (
mcp__slack__post_message, not renamed). Permission rules keep working unchanged. - Per-server endpoints: each moved server is served at
/mcp/<server>under its original name, so~/.claude.jsonhas one HTTP line per moved server instead of onemuxentry.
Remote (http, sse) servers are not proxied. They run no local process, so Claude Code keeps connecting to them directly.
Only tools are proxied. Prompts, resources and log messages from the proxied servers are not available through the proxy; keep a server as a direct entry if you rely on those.
Supported clients
Section titled “Supported clients”The proxy is tested with Claude Code only (2.1.319). install moves the servers in ~/.claude.json and nothing else; Codex keeps its own servers from ~/.codex/config.toml, each Codex session still starting its own copies.
What is specific to Claude Code: the limits the proxy matches (a call may run as long as Claude Code’s default MCP_TOOL_TIMEOUT allows, and install sets CLAUDE_CODE_MCP_TOOL_IDLE_TIMEOUT), and how sessions end (the proxy follows the process that holds each session’s connection, which assumes a client keeping its connections open while it runs, as Claude Code does). Codex speaks the same protocol, so pointing it at http://127.0.0.1:7381/mcp/<server> by hand may work, but its timeouts, session handling and progress use have not been tested; until they are, keep Codex’s servers direct.
Switch Claude Code to the proxy
Section titled “Switch Claude Code to the proxy”aimux mcp proxy install --dry-run # what would change, names onlyaimux mcp proxy install--dry-run writes nothing outside a temporary directory. A server missing from the catalog is started once to list its tools into a temporary copy of the catalog; ~/.aimux/mcp-catalog.json and ~/.aimux/mcp-servers.json are not written.
install checks everything before it touches a file, and refuses with a message naming the problem:
- It lists the tools of every server it will move into
~/.aimux/mcp-catalog.json, so tools a server gained in an upgrade are exposed. After that, whenever a moved server starts the proxy lists its tools again, and open sessions get any new ones without a restart or a reload. A server that fails is refused by name; fix it or leave it direct with--skip <name>. - It saves the definitions (command, arguments and
env) of the servers it moves in~/.aimux/mcp-servers.json, an owner-only file the proxy runs them from, and adds them to that file’s moved list, which is what the installed proxy serves. They are saved with${VAR}and${VAR:-default}already resolved in your shell (and the proxy does not expand them a second time, so a value that itself contains${...}stays as it is), because launchd starts the proxy without your shell’s variables. A variable a server reads from your shell without its entry declaring it is not there under the proxy:podman run -e SLACK_XOXC_TOKEN ...passes nothing, and a server that switches tools on by a variable offers fewer. Declare such a variable in the entry’senvas"NAME": "${NAME}"before installing, or add it to a moved server withaimux mcp proxy edit <server>. The saved values are copies: after rotating a credential, update the moved server withaimux mcp proxy edit <server>(re-running install leaves moved servers as they are), or runaimux mcp proxy uninstall, update the direct entry and install again. - It starts the proxy under launchd (
~/Library/LaunchAgents/com.aimux.mcp-proxy.plist, started at login and restarted if it exits, logs in~/.aimux/logs/mcp-proxy/) and waits up to 10 seconds until the proxy answers and has an endpoint for every server it will move. The plist setsPATHandHOMEfrom your shell, sonpx,uvand other tools resolve as they do there; it holds no other values. A proxy already running has its daemon reload in place, so open sessions keep their connections; the daemon restarts only when it crashes (launchd brings it back) or when the aimux binary itself is upgraded (install andstatussay when an upgrade restart is pending). - It has the proxy start each server it will move and list its tools (the probe below, for those servers only). A server that fails under launchd is refused by name; fix it or
--skipit. - After the switch it sets
CLAUDE_CODE_MCP_TOOL_IDLE_TIMEOUT=1800000in theenvof~/.claude/settings.json, unless you set it yourself. Claude Code gives up on a tool call that stays silent for 5 minutes on an HTTP server but 30 on a stdio one; this gives calls through the proxy the stdio limit. It also applies to your other HTTP MCP servers. Uninstall removes it (only if install added it and you did not change it). Re-running install sets it even when there are no new servers to move, so an install from before this step gets it that way. When~/.claude/settings.jsondoes not exist or cannot be edited, install leaves it out and says so; the servers are moved either way, and silent calls through the proxy then time out after 5 minutes.
If install stops before changing ~/.claude.json, it says so, puts ~/.aimux/mcp-servers.json and the launchd plist back as they were, and removes the launchd agent again when it was the one that started it; an agent that was loaded before is reloaded on its previous configuration. Taking removed servers off the proxy (a moved server whose entry you deleted from ~/.claude.json) happens first, before the tool listing and probes, and stays done if a later step fails or is interrupted: those servers are gone from your config either way. --dry-run lists them (“Would take off the proxy”).
Then it changes ~/.claude.json, after backing it up to ~/.aimux/backups/ (owner-only, the newest 10 kept):
Each moved server’s stdio entry becomes {"type": "http", "url": "http://127.0.0.1:7381/mcp/<server>"} under the same name. Remote servers stay. aimux’s own aimux stdio entry moves like any other. Nothing else in the file changes, not even spacing. The edit is atomic: one edit of mcpServers, so there is no half-switched state.
If a server’s entry changes between these checks and the edit (another program saved ~/.claude.json), the edit fails (“changed while installing; run install again”) and the proxy’s files are put back as above. An entry removed in that window is simply not moved, and the proxy stops serving it.
Just before the edit, install records it in ~/.aimux/mcp-proxy-install.json (owner-only): for each server it moves its original entry and the entry install writes, the servers kept direct with --skip, and the proxy’s port. If the record cannot be written, nothing is edited. If the edit does not land, the record goes back to what it was (or is removed on a first install). Because the record comes first, an install killed at any point can be undone with uninstall: entries the edit wrote get their originals back, and entries it never reached are left as they are. A later install with a different --port is refused, since the entries point at the recorded one; uninstall and install again to change the port. A repeat install with nothing new to move leaves ~/.claude.json alone and says so; it still takes off the proxy any moved server whose entry you removed from ~/.claude.json (its saved definition and manifest entry go, and the daemon reloads in place), and sets CLAUDE_CODE_MCP_TOOL_IDLE_TIMEOUT in ~/.claude/settings.json and records that in the manifest when it is not there yet (step 5).
Install says how many Claude Code sessions are open. Sessions already open keep their direct servers until they restart, and one that saves its settings later can write the old entries back; status calls that drift.
Tool names after the switch
Section titled “Tool names after the switch”| Before | After |
|---|---|
mcp__slack__post_message | mcp__slack__post_message |
mcp__aimux__list_sessions | mcp__aimux__list_sessions |
Tool names stay the same, so permission rules keep working unchanged.
Switch back
Section titled “Switch back”aimux mcp proxy uninstalluninstall undoes what the manifest records, in one edit of mcpServers: each moved entry that is still the proxy entry install wrote gets its original bytes back; one you changed or removed since is kept as it is and reported. Servers you added after installing stay. When nothing else changed, the file comes back byte for byte. It also deletes the moved servers’ saved definitions (they hold resolved env values) from ~/.aimux/mcp-servers.json, then removes the manifest and the launchd agent. If ~/.claude.json is gone, every entry is reported as removed and the rest still happens. Running it twice is harmless. If the agent cannot be removed, the config stays restored and running uninstall again removes it.
Without a manifest, uninstall leaves the Claude config alone and removes a leftover launchd agent if there is one. To restore entries by hand, copy only their mcpServers entries from a backup in ~/.aimux/backups/: copying the whole file would also undo everything Claude Code saved since.
New servers after installing
Section titled “New servers after installing”A stdio server added to ~/.claude.json after install runs directly until it is moved. To move it:
aimux mcp proxy installRunning install again moves only the stdio servers not yet moved and not kept direct with --skip (same refresh, daemon prepare and probe, then one atomic edit of just those entries) and adds them to the manifest. The daemon reloads in place, so open sessions keep their connections. Servers already moved are left alone. It also tidies up: a moved server whose entry you removed from ~/.claude.json (for example with claude mcp remove <name>) is taken off the proxy (its saved definition and manifest entry go, and the daemon reloads in place). With no new servers to move, install still does that, sets CLAUDE_CODE_MCP_TOOL_IDLE_TIMEOUT in ~/.claude/settings.json if it is not there yet (step 5 above), and changes nothing else.
Changing a moved server
Section titled “Changing a moved server”Once moved, a server’s command, arguments and env live in ~/.aimux/mcp-servers.json. Editing ~/.claude.json no longer changes the server. To change a moved server:
aimux mcp proxy edit <server>This opens the server’s definition in $EDITOR (an owner-only temp copy that includes its env; vi when $EDITOR is unset). $EDITOR may carry arguments, such as code --wait or vim -n; it is split into words as a shell would split it (single and double quotes and backslashes work, variables and globs are not expanded), and the temp file is passed as the last argument, so quote a path with spaces: EDITOR='"/Applications/My Editor/bin/ed" -w'. When the editor exits, edit validates the definition (valid JSON holding only command, args and env, a command found on the proxy’s PATH) and probes the server with it the way launchd runs it: PATH and HOME from the installed launchd plist plus the definition’s env, nothing else from your shell, with a bare command resolved on that PATH. A definition that only works because of a variable or a PATH entry in your shell fails here. Without an installed plist, edit says so and uses the PATH and HOME install would write from your shell. A server that starts and initializes passes even if it lists no tools. Only then does it write the definition to ~/.aimux/mcp-servers.json, record the tools it listed in the catalog, and reload that one server in place. On any failure before the write the old definition stays. An unchanged definition saves nothing. If the reload fails after the write, the new definition takes effect when the proxy next starts. Env values are never printed. edit waits for no other run: while install or uninstall runs it refuses.
The temp copy is removed when edit ends, also on Ctrl-C or SIGTERM after the editor closed. Some editors write swap or backup files beside it (vim’s .swp, a ~ backup); those hold the same env values, so configure your editor not to keep them for files in your temp directory, or delete them.
The fallback: uninstall, edit ~/.claude.json, install.
aimux mcp proxy statusstatus says which servers install moved, any whose entry drifted from its proxy URL (a running session saved a stale copy, or you edited it), stdio servers not behind the proxy yet (run aimux mcp proxy install), and whether an upgrade restart is pending. Then it reads the running proxy’s /status on 127.0.0.1. Not running is reported, not an error.
Drift: a moved server’s entry is no longer its proxy URL. To fix it:
aimux mcp proxy uninstallaimux mcp proxy installSkip servers
Section titled “Skip servers”Leave specific servers as direct entries:
aimux mcp proxy install --skip slack --skip githubThe --skip choice is recorded in the manifest and kept on later runs until --unskip:
aimux mcp proxy install --unskip slackReload and the restart trade-off
Section titled “Reload and the restart trade-off”The daemon reloads in place when you change a server (edit) or move a new one (re-run install). It does not restart. The daemon restarts only when it crashes (launchd brings it back) or when the aimux binary itself is upgraded.
Open sessions survive a restart. In a live check, a Claude Code session made a tool call through the proxy, the daemon was restarted with launchctl kickstart -k (the same restart launchd does after a crash), and the next tool call from that session worked, with no session restart. The first call after a restart starts that server’s backend again, so it can take a moment longer.
status says when an upgrade restart is pending:
aimux mcp proxy statusTo restart the daemon after an upgrade:
aimux mcp proxy restartIt restarts the daemon in place (launchctl kickstart -k: the launchd job stays loaded, and its plist is not re-read), waits until the new daemon answers on the port the proxy was installed on, and prints its pid and version; a restart that fails or times out says so (and a note if the daemon runs another aimux binary than the one you ran, which aimux mcp proxy install from the new one fixes). You can do this at a convenient time; open sessions keep working.
Tradeoffs
Section titled “Tradeoffs”| Direct stdio servers | Through the proxy | |
|---|---|---|
| Memory | one process per server per session | one process per shared server, stopped when idle; one per session for browser servers, until that Claude Code process exits |
| Session start | every server starts | nothing starts until a tool is called |
| First call to a server | immediate | waits for the server to start |
| Tool names | mcp__<server>__<tool> | mcp__<server>__<tool> (unchanged) |
| Prompts, resources, log messages | available | not available (tools only) |
A server asking you something mid-call (elicitation), roots/list | answered by Claude Code | answered “not supported” |
| Progress of a long call | shown, and resets Claude Code’s idle limit | relayed to the session that made the call, same effect |
| Longest call | Claude Code’s MCP_TOOL_TIMEOUT (default about 27.8 hours) | the same default; a larger MCP_TOOL_TIMEOUT you set is not matched |
| Working directory of a server | the session’s folder | / (a server that writes relative paths writes elsewhere) |
Browser servers (--per-session) | one per session, until Claude Code exits | one per session, until that Claude Code process exits |
| A server crash | that session loses it | every session loses it until the next call restarts it |
| Needs | nothing | the launchd agent running |
| Permission rules | direct rule | same rule (no translation) |
Set it up by hand
Section titled “Set it up by hand”Without install, nothing edits your files:
-
Cache the tool catalog. This starts each stdio server in
~/.claude.jsononce and stops it, and saves their definitions in~/.aimux/mcp-servers.json:Terminal window aimux mcp proxy refresh -
Run the proxy:
Terminal window aimux mcp proxy serve -
Print the Claude Code entries and add them to
mcpServersin~/.claude.jsonyourself (after aninstall,servewithout--onlyserves only the moved servers):Terminal window aimux mcp proxy config
Later refreshes merge: a server still in ~/.claude.json overrides the saved copy, and one you removed is kept. To drop a server from the proxy, remove it from ~/.claude.json and pass --exclude to refresh; while it is still in ~/.claude.json, every refresh and serve adds it back unless you pass --exclude to each.
The catalog is also refreshed whenever a server starts, and tools it did not list before reach open sessions right away (the proxy reloads that server’s tool list in place).
Check its health
Section titled “Check its health”aimux mcp proxy status # one row per configured serveraimux mcp proxy status --probe # start each server once and list its tools firstaimux mcp proxy status --jsonOn a terminal the output is coloured (plain when piped, or with NO_COLOR set): a line with how many servers are behind the proxy and anything to act on, the daemon line, a summary (27 servers · 1 running · 26 not started · 0 failed), and one row per server, failed and active ones first, with its state as ● running (green), ● failed (red), ◐ starting, ○ idle (stopped after --idle without calls; it starts again on the next call and costs no memory meanwhile) or ○ not started, its tool count, last call and last error, followed by the last stderr lines of any server that failed to start. A moved server whose entry you removed from ~/.claude.json is listed as removed (last in the table, not as drift) until aimux mcp proxy install takes it off the proxy. --json keeps the raw states (not_started, starting, running, idle_stopped, failed), the install state (with removed), and says per server whether the proxy has an endpoint for it (endpoint). Error text and stderr have configured env values and token-shaped strings replaced by [redacted]. When the proxy is not running the command says so and exits 0 (--json prints {"running":false}). --probe waits up to --timeout (default 1 minute); a probe counts no tool calls, and a per-session server it starts is stopped again.
The same data is at GET http://127.0.0.1:7381/status (and POST /status/probe?timeout=30s, which needs the header X-Aimux-Probe: 1 so a web page cannot start your servers; add &server=<name> once per server to probe only those), local requests only.
POST http://127.0.0.1:7381/reload makes the running proxy re-read ~/.aimux/mcp-servers.json and the catalog and apply the difference in place, without restarting: a new server gets its endpoint, a removed one loses it, and a changed one is served from its new definition (its running backend finishes in-flight calls first). It needs the same X-Aimux-Probe: 1 header and answers {"added": [...], "removed": [...], "changed": [...]} with server names. install and edit call it for you; you only need it after editing those files by hand.
| Flag | Commands | Default | Meaning |
|---|---|---|---|
--claude-config | refresh, serve, config | ~/.claude.json | Where the stdio servers are read from |
--servers | refresh, serve, config | ~/.aimux/mcp-servers.json | The proxy’s saved backend definitions, written by refresh |
--catalog | refresh, serve, config | ~/.aimux/mcp-catalog.json | Tool catalog file |
--exclude | refresh, serve, config | none | Servers to leave out of the proxy. On serve it applies to that run only: the saved catalog keeps their tools |
--port | serve, config, status, install, restart | 7381 | Port on 127.0.0.1 |
--per-session | serve | playwright,chrome-devtools,linkedin | Servers that get one process per client session, for browser automation |
--only | serve | the moved list, else all | Serve only these servers. Without it, a servers file with a moved list (written by install) is served as exactly that list, even when it is empty; without a list every configured server is served |
--idle | serve | 10m | Stop a shared server after it has been unused this long; also the limit for a session whose Claude Code process cannot be found |
--json | status | off | Print the status as JSON |
--probe | status | off | Start each configured server once and list its tools first |
--timeout | status | 1m | How long --probe may take |
--dry-run | install | off | Print what would change (names only) and exit; servers missing from the catalog are started once to list their tools into a temporary catalog |
--skip | install | none | Stdio servers to leave as direct entries (recorded: later runs keep them direct) |
--unskip | install | none | Servers an earlier --skip kept direct, to move this time |
Security
Section titled “Security”The proxy listens on 127.0.0.1 only, /mcp/<server> endpoints and /status alike. It rejects requests whose Host is not a loopback name (DNS rebinding) and requests a browser sends from another site.
Failure handling
Section titled “Failure handling”- A server that fails to start within 2 minutes returns an error result naming the server. Other servers keep working, and the next call tries again. Calls that arrive while a server is starting share that start; one caller giving up does not stop it for the others.
- A tool call may run up to Claude Code’s default
MCP_TOOL_TIMEOUT(100,000,000 ms, about 27.8 hours), the limit Claude Code puts on a direct call unless you change it. The proxy’s limit is fixed: if you set a largerMCP_TOOL_TIMEOUT, calls through the proxy still stop at the default. A call that times out or loses its connection stops that server, and the next call starts a new one. - A tool that reports an error keeps its server running.
- A server process that exits on its own is marked
failed(exited) within about 10 seconds, also when it was a wrapper such asnpxoruvwhose child still holds its stderr open. - A probe (
status --probe, install’s check) whose tool listing gets no answer returns at its deadline; until that listing returns, further probes of the server fail at once instead of queueing behind it, and if it has not returned after 2 minutes the server is stopped like a timed-out call. A probe that got no answer does not count as use, so the idle reaper still stops the server. aimux mcp proxy refresh, andinstall(with or without--dry-run) while it lists tools, interrupted with Ctrl-C orSIGTERMstop the servers they started (they run in sessions of their own, so the terminal’s interrupt does not reach them), wait for them to close and say they were interrupted; an interrupted install changes nothing.- A manifest from the earlier single-entry design is refused; restore
~/.claude.jsonfrom~/.aimux/backupsand, after moving~/.aimux/mcp-proxy-install.jsonaside, remove the agent withaimux mcp proxy uninstall. - Claude Code does not end its MCP session when it exits. The proxy finds the process on the other end of each session’s connection (one
lsofper minute for sessions it does not know yet, trusting a port only while the connection that carried the session’s request is still open, and the process by its pid and start time) and stops the session’s per-session servers when that process exits, nightlyclaude -pruns included. Only a session whose process cannot be found falls back to--idle, and never while one of its calls is in flight. A long call never loses its session.
Conformance check
Section titled “Conformance check”scripts/mcp-proxy-conformance.sh runs the official MCP conformance suite (@modelcontextprotocol/conformance@0.1.16) against the reference server directly and through the proxy. The proxy run uses scripts/mcp-proxy-conformance-baseline.yaml to baseline known failures:
- Tools only:
logging-*,prompts-*,resources-*scenarios (the proxy serves tools only, as above) - Unknown tool errors:
tools-call-error,tools-call-simple-text(the suite calls tools the reference server lacks; the reference server wraps that error in a tool result, while the proxy returns the JSON-RPC error-32602the specification defines for unknown tools)
The script exits 0 when all failures match the baseline, and exits non-zero on:
- Unexpected failures (new regressions)
- Stale baseline entries (a listed scenario now passes, so the baseline needs updating)
This runs on every relevant push: locally through pre-commit’s pre-push stage (run pre-commit install --hook-type pre-push once per clone; other pre-commit hooks stay on commit), and in GitHub Actions when MCP proxy code changes.
Live check
Section titled “Live check”scripts/mcp-proxy-live.sh probes the owner’s real MCP servers through the proxy. This runs only with explicit approval, and changes nothing:
- Reads
~/.claude.jsonstrictly read-only (copies it to a temp directory) - Uses temporary
--serversand--catalogpaths, not the real~/.aimux/files - Port 17462 (a different port from the installed proxy)
- Cleans up every process it starts on exit, including when it is cancelled during the refresh
It starts a throwaway proxy on port 17462 and runs aimux mcp proxy status --probe against it, so the proxy starts each stdio server once and lists its tools. It calls no tools. Output is one line per server, with error text taken from the proxy’s status (already scrubbed of env values and tokens):
ok <name> <tool count>failed <name> <first error line>The script exits 0 even when servers fail (it is a report), and exits non-zero only if the proxy itself could not run.