MCP Server Environment Variables Not Working? 8 Fixes (Mac & Windows)
Nikhil Tiwari
MCP Playground
π TL;DR
- A stdio MCP server gets almost none of your environment. The official SDKs pass six variables on Mac and Linux (
HOME,LOGNAME,PATH,SHELL,TERM,USER) plus the config'senvblock. - Exports in
.zshrcnever arrive. Put every key the server needs inenv, with the real value. - Placeholder syntax differs per client:
${VAR}in Claude Code,${env:VAR}in Cursor and VS Code. Don't rely on placeholders inclaude_desktop_config.json. - A
.envfile is read from the working directory, which is not your project folder when a client starts the server. Use an absolute path. - Docker needs
-e NAMEinargsas well as the value inenv.
Table of Contents
- Why your MCP server doesn't see your environment variables
- What a missing variable looks like
- 1. Put the variable in the env block
- 2. Use your client's placeholder syntax (or none)
- 3. Load .env files from an absolute path
- 4. Restart the client the right way
- 5. Docker MCP servers: pass -e NAME
- 6. Proxy, certificate and cloud variables
- 7. Don't overwrite PATH by accident
- 8. Check what the server actually received
- FAQ
This guide is for developers whose MCP server environment variables are not working: the server starts, but the API key, token or setting you exported isn't there. It covers Claude Desktop, Claude Code, Cursor and VS Code on Mac and Windows.
The pattern is always the same. The server works when you run it in a terminal. Inside the client, the first tool call fails with "API key is required" or a 401.
Nothing is wrong with your key. The client starts the server with a different environment from your terminal, and most of what you exported is left behind.
I'll show where each variable gets lost, the exact syntax each client expects, and how to prove what the server received. If you're new to the protocol, start with what the Model Context Protocol is.
Why your MCP server doesn't see your environment variables
A variable has to survive three hops before your server can read it. It can be dropped at each one.
- Your shell to the client app. Apps opened from the Dock, Finder or the Start menu don't read
.zshrc,.bash_profileor a terminal session. On Windows, an app keeps the environment it had when it started. - The client to the server process. MCP clients don't pass their whole environment to a stdio server. They pass a short allow-list.
- The server to its config loader. If the server reads a
.envfile, it looks in its working directory, which the client chooses.
Hop 2 surprises most people. Here is the allow-list in the official TypeScript SDK (getDefaultEnvironment() in client/stdio):
| Platform | Variables passed to the server by default |
|---|---|
| Mac and Linux | HOME, LOGNAME, PATH, SHELL, TERM, USER |
| Windows | APPDATA, HOMEDRIVE, HOMEPATH, LOCALAPPDATA, PATH, PROCESSOR_ARCHITECTURE, SYSTEMDRIVE, SYSTEMROOT, TEMP, USERNAME, USERPROFILE, PROGRAMFILES (v2 of the SDK adds a few more, such as PATHEXT and WINDIR) |
The Python SDK uses the same list. Both then merge the config's env block on top. Anything else is dropped on purpose, so a server can't read secrets meant for other programs.
So even if you start the client from a terminal, OPENAI_API_KEY from your shell won't reach a server built on these SDKs. It has to be in env.
What a missing variable looks like
The server usually starts fine, because most servers only read keys when a tool runs. The failure shows up on the first tool call, not at connect.
- Python:
KeyError: 'OPENAI_API_KEY'fromos.environ["OPENAI_API_KEY"], orNonepassed on to an API client. - Node:
undefinedsent as the key, so the API answers401 Unauthorized. - Server-specific text: "API key is required", "Missing GITHUB_PERSONAL_ACCESS_TOKEN", "Bad credentials".
- Silent fallbacks: a default region, an empty base URL, or logging at the wrong level.
If the server doesn't start at all, the cause is more likely PATH. That's covered in fixing spawn npx ENOENT.
1. Put the variable in the env block
This is the fix for most cases. Every variable the server needs goes in the server's env object, as a string, with the real value.
{
"mcpServers": {
"github": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-github"],
"env": {
"GITHUB_PERSONAL_ACCESS_TOKEN": "ghp_your_token_here",
"LOG_LEVEL": "debug"
}
}
}
}
Three details trip people up:
- Quote every value. Write
"PORT": "8080", not8080. Some clients accept numbers, but a string always works. - No shell syntax.
$HOME,~and%USERPROFILE%inside a value are passed as plain text. Write the full path. - Check the variable name in the server's README. The GitHub server wants
GITHUB_PERSONAL_ACCESS_TOKEN, notGITHUB_TOKEN.
The config file holds the secret in plain text. Keep it out of git, and prefer a client's secret prompt (below) for shared configs.
2. Use your client's placeholder syntax (or none)
Placeholders let you keep the secret out of the file. Each client uses a different syntax, and a placeholder the client doesn't understand is passed to the server as literal text.
| Client | Read a variable | Load a .env file |
|---|---|---|
| Claude Desktop | Not documented. Write the value. | No |
Claude Code (.mcp.json) | ${VAR} or ${VAR:-default} | No |
Cursor (mcp.json) | ${env:VAR} | envFile (stdio only) |
VS Code (mcp.json) | ${env:VAR}, or ${input:id} to be prompted | envFile |
| MCP Playground Desktop | Write the value in Environment variables | No |
Claude Code
Add a variable when you register the server with -e. Put another option between --env and the server name, or the CLI reads the name as another pair:
claude mcp add --env GITHUB_PERSONAL_ACCESS_TOKEN=ghp_xxx --transport stdio github \
-- npx -y @modelcontextprotocol/server-github
In a shared .mcp.json, use ${GITHUB_PERSONAL_ACCESS_TOKEN}. If the variable isn't set, the server gets the literal text ${GITHUB_PERSONAL_ACCESS_TOKEN}, and claude mcp list shows a missing-variable warning. That's in the Claude Code MCP docs.
Cursor
Cursor resolves ${env:NAME}, ${userHome} and ${workspaceFolder} in command, args, env, url and headers. envFile loads a whole file, but only for stdio servers (Cursor MCP docs):
"my-server": {
"command": "node",
"args": ["${workspaceFolder}/build/index.js"],
"envFile": "${workspaceFolder}/.env"
}
${env:NAME} reads Cursor's own environment. If Cursor was opened from the Dock, that's not your shell's environment either, so the value can still be empty.
VS Code
VS Code has the cleanest answer for secrets. An ${input:id} placeholder makes VS Code ask for the value the first time the server starts, so it never sits in the file (VS Code MCP reference):
{
"inputs": [
{ "type": "promptString", "id": "gh-token", "description": "GitHub token", "password": true }
],
"servers": {
"github": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-github"],
"env": { "GITHUB_PERSONAL_ACCESS_TOKEN": "${input:gh-token}" }
}
}
}
Note the top-level key is servers in VS Code, not mcpServers. envFile and ${input:} only work in VS Code's own format, not in portable .mcp.json files.
Claude Desktop
Claude Desktop's config has an env block, but variable expansion in claude_desktop_config.json isn't documented. Write the real value. If you see ${...} text in your server's log, that's why.
3. Load .env files from an absolute path
Your server calls dotenv, the .env file sits next to package.json, and it works in the terminal. In the client, every variable is missing.
Node's dotenv looks for .env in process.cwd(). In a terminal, that's your project. When a client starts the server, it's whatever folder the client picks, often / on a Mac. The file is simply never found, and dotenv doesn't throw.
Fix it in the server, so it works under every client:
// Node (ESM, Node 20.11+)
import { config } from "dotenv";
import { join } from "node:path";
config({ path: join(import.meta.dirname, "..", ".env") });
Or skip the library and let Node load the file. --env-file exists since Node 20.6:
"command": "node",
"args": ["--env-file=/Users/you/projects/my-server/.env", "/Users/you/projects/my-server/build/index.js"]
Python is more forgiving. python-dotenv's load_dotenv() starts searching from the folder of the file that calls it and walks up. It only falls back to the working directory in a REPL, a debugger, or with usecwd=True.
For a uv project, uv run --env-file loads the file before your code starts. Pair it with --directory and use absolute paths for both:
"command": "uv",
"args": ["run", "--directory", "/Users/you/projects/my-server",
"--env-file", "/Users/you/projects/my-server/.env", "server.py"]
The full uv setup is in testing a Python MCP server locally with uv.
4. Restart the client the right way
Clients read their MCP config at startup. Closing the window isn't quitting.
- Mac: press βQ in Claude Desktop, or quit it from the menu bar, then open it again.
- Windows: right-click the Claude icon in the system tray and choose Quit. The X button only hides the window.
- Cursor and VS Code: restart the server from the MCP settings or the
mcp.jsoncode lens after editingenv.
On Windows there's a second trap. A variable set with setx or in System Properties only reaches programs started afterwards. A client that was already running keeps its old copy until you quit it fully.
On a Mac, don't try to fix this with launchctl setenv for secrets. It's easy to forget and it applies to every app. The env block is explicit and scoped to one server.
Want to rule out the client entirely? Test any MCP server free β in the browser for remote servers, or use the desktop app for local ones (section 8).
5. Docker MCP servers: pass -e NAME
With a Docker-based server, env sets the variable for the docker command, not inside the container. The container only gets variables you name with -e.
This is the pattern the official GitHub MCP server uses:
"github": {
"command": "docker",
"args": ["run", "-i", "--rm",
"-e", "GITHUB_PERSONAL_ACCESS_TOKEN",
"ghcr.io/github/github-mcp-server"],
"env": { "GITHUB_PERSONAL_ACCESS_TOKEN": "ghp_your_token_here" }
}
-e NAME with no value copies NAME from the docker process into the container (Docker docs). Forget the -e line and the container never sees the token, even though it's in env.
Keep -e lines before the image name. Anything after the image is passed to the server as its own arguments.
6. Proxy, certificate and cloud variables
Some variables you never typed into a config still matter. Your terminal has them; the server doesn't, because they're not on the allow-list.
| Variable | Symptom when it's missing |
|---|---|
HTTPS_PROXY, HTTP_PROXY, NO_PROXY | Timeouts or ECONNREFUSED calling APIs from a company network |
NODE_EXTRA_CA_CERTS, SSL_CERT_FILE, REQUESTS_CA_BUNDLE | self-signed certificate in certificate chain or CERTIFICATE_VERIFY_FAILED behind a TLS-inspecting proxy |
AWS_PROFILE, AWS_REGION | The default profile is used, so you hit the wrong account or region |
GOOGLE_APPLICATION_CREDENTIALS | "Could not load the default credentials" |
KUBECONFIG, DOCKER_HOST | The server talks to the default cluster or Docker socket, not yours |
HOME is passed, so files under it still work. ~/.aws/credentials and ~/.kube/config are found. Only the variables that pick between them are lost. Add those to env too.
7. Don't overwrite PATH by accident
The config's env is merged over the defaults. If you set PATH in env, it replaces the inherited PATH entirely; it isn't appended.
Set "PATH": "/opt/homebrew/bin" to help npx resolve, and now /usr/bin is gone. Anything the server shells out to (git, python3, sh) stops being found.
If you must set it, write the whole list:
"env": {
"PATH": "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin"
}
The better fix is usually a full path in command, such as /opt/homebrew/bin/npx. Windows has its own PATH rules, covered in MCP servers on Windows.
8. Check what the server actually received
Stop guessing. Have the server report which variables it can see when it starts. Log names and whether they're set, never the values.
Write to stderr. On stdio, stdout carries JSON-RPC, and a stray line there breaks the connection (see why stdout breaks stdio servers).
// Node
const keys = ["GITHUB_PERSONAL_ACCESS_TOKEN", "HTTPS_PROXY", "AWS_PROFILE"];
console.error("env check:", Object.fromEntries(keys.map((k) => [k, Boolean(process.env[k])])));
console.error("cwd:", process.cwd());
# Python
import os, sys
keys = ["OPENAI_API_KEY", "HTTPS_PROXY", "AWS_PROFILE"]
print("env check:", {k: k in os.environ for k in keys}, file=sys.stderr)
print("cwd:", os.getcwd(), file=sys.stderr)
Then read stderr where your client keeps it. Claude Desktop writes one log per server: ~/Library/Logs/Claude/mcp-server-NAME.log on a Mac, %APPDATA%\Claude\logs\ on Windows.
The cwd line is worth having. If it prints /, you've found your .env problem (section 3).
Test it outside the client
MCP Playground Desktop starts stdio servers the same way MCP clients do: the SDK's default list plus the variables you add. It doesn't read your .zshrc exports either, so it shows you exactly what a client would.
What it adds is visibility:
- An Environment variables list on each connection, plus a Working directory field. Set the project folder there and relative
.envpaths resolve. - A stderr tab next to the JSON-RPC log, so your env check prints right in the app.
- Import from Claude Desktop, Cursor, VS Code, Windsurf and Claude Code. Values are copied as written: a
${...}placeholder stays text, andenvFileisn't read, which shows you which values are really set. - On a Mac, your login shell's PATH is loaded at startup, so
npx,uvand Homebrew tools are found.
Fix the variables there, call a tool, and copy the working env back into your client. Get MCP Playground Desktop for Mac and Windows β
FAQ
Why can't my MCP server see variables I exported in .zshrc?+
Can I use ${VAR} in claude_desktop_config.json?+
Why isn't my .env file loaded when Claude Desktop starts the server?+
How do I pass an environment variable to a Docker MCP server?+
I changed an environment variable on Windows. Why does the server still get the old value?+
How do I check which environment variables my MCP server received?+
Conclusion
A stdio MCP server gets a short allow-list of variables plus the config's env block. Anything else, from .zshrc exports to proxy settings, has to be listed in env.
After that, check the two quieter causes: a .env file read from the wrong folder, and a client that was never fully restarted. A stderr env check settles it in one run.
Written by Nikhil Tiwari
15+ years in product development. AI enthusiast building developer tools that make complex technologies accessible to everyone.
Free MCP Tools (no install)
Build, compare & ship MCP agents
Connect any MCP server, run evals on it, compare 90+ models side-by-side, deploy hosted servers, and save reusable agents you can export as an API β all in your browser.