DEV Community

Cover image for Every stdio MCP server you spawn inherits your whole .env
Chad Priest
Chad Priest

Posted on Originally published at blog.vodou.ai

Every stdio MCP server you spawn inherits your whole .env

Every stdio MCP server Vodou spawns can read FIREWORKS_API_KEY. Also the Stripe key, the GitHub token, and everything else in .env. I found this today while answering a different question ("do my MCP servers share the .env file?") and the answer was worse than sharing. They get all of it, and the per-server connection_config block I had been treating as the scoping mechanism was never read for that purpose.

I believed connection_config scoped secrets for about three months

The design in my head was simple. Each MCP server registered in the core DB has a connection_config JSON column. I assumed that when the engine spawned a server over stdio, the child got PATH plus whatever that column said, and nothing else. That is how Claude Desktop behaves, and it is how the reference @modelcontextprotocol/sdk transport behaves.

What the engine actually does: at startup it calls the Rust dotenv equivalent of dotenv::from_path(".env"), which mutates the parent process environment. Then it spawns each server with a plain process builder that does not touch the env at all. Not touching the env means inheriting the env. connection_config is used for the command and args. Nothing reads it for environment variables.

The gateway has the identical shape in TypeScript, and I had even written it down in June without hearing what it meant: "Both Rust core and gateway (MCP-servers/Vodou-Console/src/index.ts:251) load .env via dotenv. Spawned MCP servers inherit parent process env." I filed that as a DEPENDENCY note about why a board server could see a variable. It was a security note.

The mcp-use fix is right, and it does not cover a dotenv'd parent

The mcp-use PR that stopped leaking the parent env describes the same bug precisely: the connector copied all of process.env and layered the caller's env on top, so configuring one variable handed the child every secret. Their fix is to start from a minimal default env instead. FastMCP has the same issue open, and PraisonAI shipped an advisory for os.environ.copy().

The env.dev guide says an MCP server "inherits only a limited, platform-dependent subset of environment variables." That is true when the host is a desktop app launched from Finder or a shell with nothing exported. It does not cover the host that most people building their own agent actually have: a daemon that loaded .env into itself on line 13 so its own code could find keys. The moment the parent does that, "limited subset" becomes "the entire secrets file", and no client config block fixes it unless the spawn path is written to filter.

The class: a parent that loads secrets is a parent that leaks them

The general failure has nothing to do with MCP. A host process loads a secrets file into its own environment for convenience, then spawns plugins over stdio. Every plugin gets the union of the host's credentials. Docker Compose env_file on a service that then execs helpers, CI runners spawning plugin steps after sourcing a secrets file, LangChain subprocess tools, any Node child_process.spawn or Rust Command with dotenv loaded in the parent. Same shape every time, and the per-tool scoping configured somewhere else is a no-op because the spawn call never consulted it.

Any spawn site in a process that has loaded secrets must construct the child env explicitly from an allowlist; a spawn that omits env inherits everything, so "per-tool scoping" exists only if the spawn site reads it.

Here is the check. Register this as a stdio tool server in whatever host you built, with no env block:

cat > /tmp/envleak.sh <<'EOF'
#!/bin/sh
env | grep -Ei 'key|token|secret|password' | cut -d= -f1 | sort > /tmp/envleak.txt
exec cat  # keep stdio open so the host thinks the handshake is pending
EOF
chmod +x /tmp/envleak.sh
Enter fullscreen mode Exit fullscreen mode

Point the host at it, let it attempt the handshake, then cat /tmp/envleak.txt. Passing output is empty, or lists only what you deliberately granted that server. Failing output is your .env with the values stripped. Mine listed eleven names.

Still open: the fix is a filter at two spawn sites, not one

The fix is small and I have not shipped it yet. Both the engine and MCP-servers/Vodou-Console/src/index.ts need to spawn with an explicit env: PATH, HOME, LANG, plus the keys named in that server's connection_config. The gateway side is spawn(cmd, args, { env: pick(process.env, allow) }). I also want a test that spawns the script above and asserts the file is empty, because I wrote the leak down in June and did not read it. A note is not a gate.

The rule for a codebase that is not mine: grep every spawn(, Command::new(, and subprocess.Popen( for a missing env argument, and treat each hit in a process that ever called dotenv as a credential leak until proven otherwise.


Source: Every stdio MCP server you spawn inherits your whole .env by Chad Priest, from Building Vodou in Public.

Top comments (0)