Back to Blog
TroubleshootingNov 8, 202610 min read

MCP Server Environment Variables Not Working? 8 Fixes (Mac & Windows)

NT

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's env block.
  • Exports in .zshrc never arrive. Put every key the server needs in env, 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 in claude_desktop_config.json.
  • A .env file 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 NAME in args as well as the value in env.

Table of Contents

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.

  1. Your shell to the client app. Apps opened from the Dock, Finder or the Start menu don't read .zshrc, .bash_profile or a terminal session. On Windows, an app keeps the environment it had when it started.
  2. The client to the server process. MCP clients don't pass their whole environment to a stdio server. They pass a short allow-list.
  3. The server to its config loader. If the server reads a .env file, 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 LinuxHOME, LOGNAME, PATH, SHELL, TERM, USER
WindowsAPPDATA, 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' from os.environ["OPENAI_API_KEY"], or None passed on to an API client.
  • Node: undefined sent as the key, so the API answers 401 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", not 8080. 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, not GITHUB_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 DesktopNot 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 promptedenvFile
MCP Playground DesktopWrite the value in Environment variablesNo

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.json code lens after editing env.

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_PROXYTimeouts or ECONNREFUSED calling APIs from a company network
NODE_EXTRA_CA_CERTS, SSL_CERT_FILE, REQUESTS_CA_BUNDLEself-signed certificate in certificate chain or CERTIFICATE_VERIFY_FAILED behind a TLS-inspecting proxy
AWS_PROFILE, AWS_REGIONThe default profile is used, so you hit the wrong account or region
GOOGLE_APPLICATION_CREDENTIALS"Could not load the default credentials"
KUBECONFIG, DOCKER_HOSTThe 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 .env paths 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, and envFile isn't read, which shows you which values are really set.
  • On a Mac, your login shell's PATH is loaded at startup, so npx, uv and 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?+
Apps opened from the Dock or Finder don't read .zshrc. And even when they have the variable, MCP clients built on the official SDKs pass a stdio server only HOME, LOGNAME, PATH, SHELL, TERM and USER, plus the config's env block. Put the variable in env.
Can I use ${VAR} in claude_desktop_config.json?+
It isn't documented for Claude Desktop, so write the real value. Claude Code supports ${VAR} and ${VAR:-default} in .mcp.json. Cursor and VS Code use ${env:VAR}.
Why isn't my .env file loaded when Claude Desktop starts the server?+
Node's dotenv reads .env from the working directory, and the client doesn't start the server in your project folder. Pass an absolute path to dotenv, use node --env-file with a full path, or uv run --env-file.
How do I pass an environment variable to a Docker MCP server?+
Set the value in the config's env block and add "-e", "NAME" to args before the image name. Without the -e line, the variable reaches the docker command but not the container.
I changed an environment variable on Windows. Why does the server still get the old value?+
Variables set with setx or in System Properties only reach programs started afterwards. Quit the client from the system tray, not the X button, and open it again.
How do I check which environment variables my MCP server received?+
Log the variable names and whether each is set to stderr when the server starts, plus the working directory. Never log the values, and never write to stdout on a stdio server.

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.

Test your local MCP server with MCP Playground Desktop β†’

NT

Written by Nikhil Tiwari

15+ years in product development. AI enthusiast building developer tools that make complex technologies accessible to everyone.

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.

Try for Free β†’