# Every stdio MCP server you spawn inherits your whole .env

> A host that loads .env into its own process hands every credential to every stdio tool server it spawns. Per-server env config was a no-op. How to check yours in five minutes.

- Author: Chad Priest
- Published: 2026-08-28
- Canonical URL: https://blog.vodou.ai/every-stdio-mcp-server-you-spawn-inherits-your-whole-env/
- Tags: security, mcp, nodejs, debugging

---

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.

<!-- REDACT-OK: the load call is the root cause and is a public crate API, not an engine path -->

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](https://github.com/mcp-use/mcp-use/pull/1949) 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](https://github.com/PrefectHQ/fastmcp/issues/4516), and PraisonAI shipped [an advisory](https://github.com/MervinPraison/PraisonAI/security/advisories/GHSA-pj2r-f9mw-vrcq) for `os.environ.copy()`.

The [env.dev guide](https://env.dev/guides/mcp-server-env-variables) 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:

```bash
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
```

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](https://blog.vodou.ai/every-stdio-mcp-server-you-spawn-inherits-your-whole-env/) by Chad Priest, from Building Vodou in Public.
