Yesterday, I discovered that my laptop was running 66 zombie Docker containers, all tied to MCP servers that I use.
$ docker ps
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
7572e9799a20 mcp/grafana "/app/mcp-grafana --…" 5 hours ago Up 5 hours 8000/tcp condescending_jennings
cb85cdbcae27 crystaldba/postgres-mcp "/app/docker-entrypo…" 6 hours ago Up 6 hours 8000/tcp stupefied_grothendieck
2e3d6f4f4fe6 crystaldba/postgres-mcp "/app/docker-entrypo…" 6 hours ago Up 6 hours 8000/tcp goofy_davinci
b4aca442edf2 mcp/grafana "/app/mcp-grafana --…" 6 hours ago Up 6 hours 8000/tcp busy_borg
779a8099a445 crystaldba/postgres-mcp "/app/docker-entrypo…" 6 hours ago Up 6 hours 8000/tcp sad_dijkstra
...
The culprit was Claude Code's MCP server configuration. We allow Claude read-access to some of our databases so it can investigate issues and run analyses. Our .mcp.json file had several tools defined like so:
{
"mcpServers": {
"staging-db": {
"command": "docker",
"args": ["run", "-i", "--rm", "-e", "DATABASE_URI", "crystaldba/postgres-mcp", "--access-mode=restricted"],
"env": {
"DATABASE_URI": "postgresql://user:${STAGING_DB_PASSWORD}@host:5432/db"
}
},
"grafana": {
"command": "docker",
"args": ["run", "-i", "--rm", "-e", "GRAFANA_URL", "-e", "GRAFANA_SERVICE_ACCOUNT_TOKEN", "mcp/grafana", "-t", "stdio"],
"env": {
"GRAFANA_URL": "https://grafana-host",
"GRAFANA_SERVICE_ACCOUNT_TOKEN": "${GRAFANA_SERVICE_ACCOUNT_TOKEN}"
}
},
}
}
When starting a new session, Claude Code would spin up new containers for the enabled tools. But when the session ended, the containers would continue running.
The problem
Claude Code communicates with local MCP servers over stdio. It launches docker run -i, pipes JSON over stdin, reads responses from stdout. When you close the Claude Code session, the docker run CLI process ends. But the container is a separate process managed by the Docker daemon, which has no idea the MCP session is over. The container keeps running, holding a database connection open, waiting for stdin that will never come.
The --rm flag doesn't help in this case: it removes a container after it stops. But these containers never stop.
When I was digging into this, I thought "wait, when I run docker run -i in my terminal and hit Ctrl+C, the container stops just fine." Ctrl+C sends SIGINT to the docker run CLI, which catches it and explicitly asks the Docker daemon to stop the container. But when Claude Code exits, no SIGINT is sent. The stdin pipe just closes, and closed pipe is not a signal. The docker run process may die from the broken pipe, but it never gets the chance to tell the daemon "please stop my container." The container is orphaned.
To be clear, Docker is doing exactly what it's designed to do. Containers survive the death of the process that started them. That's why you can SSH into a server, start a container using docker run -d and log out without killing it. The problem is that MCP tooling treats docker run as if it were a regular subprocess, which it very much isn't.
The solution: Use uvx instead of Docker
For us, the solution was to not use Docker. Both postgres-mcp and mcp-grafana are available as packages you can run directly:
{
"mcpServers": {
"staging-db": {
"command": "uvx",
"args": ["postgres-mcp", "--access-mode=restricted", "postgresql://user:${STAGING_DB_PASSWORD}@host:5432/db"]
},
"grafana": {
"command": "uvx",
"args": ["mcp-grafana", "-t", "stdio"],
"env": {
"GRAFANA_URL": "https://grafana-host",
"GRAFANA_SERVICE_ACCOUNT_TOKEN": "${GRAFANA_SERVICE_ACCOUNT_TOKEN}"
}
}
}
}
uvx runs the server as a normal child process. When Claude Code exits, the process gets cleaned up. npx and pnpx work the same way.
If you haven't done so recently, it may be worth checking how many MCP containers you're currently running.
docker ps | grep -c mcp
FutureSearch lets you run your own team of AI researchers and forecasters on any dataset. Try it for yourself.