Summary
tools/dev-proxy/dev-proxy.mjs reaps orphaned Docker backends by published port only:
// _killDockerContainer()
if (!BACKEND_CMD || !BACKEND_CMD.match(/^docker\s+run/)) return;
const ids = execSync(`docker ps -q --filter publish=${BACKEND_PORT}`, ...)
A stdio-mode Docker backend (docker run -i --rm … stdio) publishes no port, so that filter never matches it and the container is never killed. _ensurePortFree() additionally returns early for stdio (if (this.backendTransport === 'stdio') return;), and _forceKillPid() kills the local docker client process — which detaches from, but does not stop, the container. --rm therefore never fires, because the containerised server process never exits.
Evidence
On a dev box that has been running the three Docker dev-proxy variants for two weeks:
$ docker ps -q | wc -l
84
# split by whether a port is published:
unpublished (stdio-mode): 80
published (http/sse) : 4 <- the 4 actually-live backends
$ docker inspect --format '{{.Name}} AutoRemove={{.HostConfig.AutoRemove}} Cmd={{json .Config.Cmd}} Started={{.State.StartedAt}}' <oldest>
/focused_mendel AutoRemove=true Cmd=["stdio"] Started=2026-08-12T21:09:38Z
Every orphan is Cmd=["stdio"]. The oldest had been running 14 days. Each holds a Node process plus loaded adapters, so the footprint grows with every proxy restart — and dev_rebuild_and_restart is called many times per session.
Repro
- Configure a dev-proxy with
DEV_PROXY_BACKEND_TRANSPORT=stdio and a DEV_PROXY_BACKEND_CMD of docker run -i --rm mcp-debugger:local stdio.
docker ps — one container.
- Call
dev_restart_debugger.
docker ps — two containers. The old one is still running.
Repeat for as many restarts as you like; they all stay.
Why the port filter can't be fixed in place
There is no port to filter on. The reap needs a different handle. Options, roughly in order of robustness:
- Spawn the container with an explicit
--name mcp-debugger-devproxy-<transport>-<pid> (or a --label devproxy=<id>) and reap with docker rm -f / --filter label=…. This makes the container addressable regardless of transport.
- On shutdown, close the
docker client's stdin and wait briefly before force-killing, so --rm can fire normally; keep the force-kill as the fallback.
- Run
_killDockerContainer() unconditionally on the stdio path too, using whichever handle the above provides, rather than early-returning in _ensurePortFree().
Impact
Dev-only (the proxy is not shipped to users), but it degrades the machine it runs on and silently: nothing in the proxy logs or dev_server_status reveals the accumulation. Found during a full /testdebugger everything sweep — noticed only because docker ps was run for an unrelated reason.
Summary
tools/dev-proxy/dev-proxy.mjsreaps orphaned Docker backends by published port only:A stdio-mode Docker backend (
docker run -i --rm … stdio) publishes no port, so that filter never matches it and the container is never killed._ensurePortFree()additionally returns early for stdio (if (this.backendTransport === 'stdio') return;), and_forceKillPid()kills the localdockerclient process — which detaches from, but does not stop, the container.--rmtherefore never fires, because the containerised server process never exits.Evidence
On a dev box that has been running the three Docker dev-proxy variants for two weeks:
Every orphan is
Cmd=["stdio"]. The oldest had been running 14 days. Each holds a Node process plus loaded adapters, so the footprint grows with every proxy restart — anddev_rebuild_and_restartis called many times per session.Repro
DEV_PROXY_BACKEND_TRANSPORT=stdioand aDEV_PROXY_BACKEND_CMDofdocker run -i --rm mcp-debugger:local stdio.docker ps— one container.dev_restart_debugger.docker ps— two containers. The old one is still running.Repeat for as many restarts as you like; they all stay.
Why the port filter can't be fixed in place
There is no port to filter on. The reap needs a different handle. Options, roughly in order of robustness:
--name mcp-debugger-devproxy-<transport>-<pid>(or a--label devproxy=<id>) and reap withdocker rm -f/--filter label=…. This makes the container addressable regardless of transport.dockerclient's stdin and wait briefly before force-killing, so--rmcan fire normally; keep the force-kill as the fallback._killDockerContainer()unconditionally on the stdio path too, using whichever handle the above provides, rather than early-returning in_ensurePortFree().Impact
Dev-only (the proxy is not shipped to users), but it degrades the machine it runs on and silently: nothing in the proxy logs or
dev_server_statusreveals the accumulation. Found during a full/testdebugger everythingsweep — noticed only becausedocker pswas run for an unrelated reason.