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

> Why an MCP server can't see the API key you exported, and how to fix it in Claude Desktop, Claude Code, Cursor and VS Code: the env block, .env files and working directories, Docker's -e flag, proxy and certificate variables, and how to check what the server actually received.

**Source:** https://mcpplaygroundonline.com/blog/mcp-server-env-variables-not-working  
**Author:** Nikhil Tiwari  
**Published:** 2026-11-08  
**Category:** Troubleshooting  
**Reading time:** 10 min read  
**Topics:** mcp server env variables not working, mcp server environment variables, claude desktop mcp env, mcp env block, mcp server api key not found, cursor mcp envFile, claude code mcp env variable expansion, vscode mcp envFile, mcp server dotenv not loading, docker mcp server environment variable

---

📖 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

-   [Why your MCP server doesn't see your environment variables](#why)
-   [What a missing variable looks like](#symptoms)
-   [1\. Put the variable in the env block](#env-block)
-   [2\. Use your client's placeholder syntax (or none)](#clients)
-   [3\. Load .env files from an absolute path](#dotenv)
-   [4\. Restart the client the right way](#restart)
-   [5\. Docker MCP servers: pass -e NAME](#docker)
-   [6\. Proxy, certificate and cloud variables](#hidden)
-   [7\. Don't overwrite PATH by accident](#path)
-   [8\. Check what the server actually received](#check)
-   [FAQ](#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](/blog/what-is-model-context-protocol).

## 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 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](https://github.com/modelcontextprotocol/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](/blog/fix-spawn-npx-enoent-mcp).

## 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 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](https://code.claude.com/docs/en/mcp).

### 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](https://cursor.com/docs/context/mcp)):

```
"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](https://code.visualstudio.com/docs/copilot/reference/mcp-configuration)):

```
{
  "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`](https://nodejs.org/api/cli.html#--env-filefile) 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`](https://docs.astral.sh/uv/reference/cli/) 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](/blog/test-python-mcp-server-locally-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 →](https://mcpplaygroundonline.com/mcp-test-server) 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](https://github.com/github/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](https://docs.docker.com/reference/cli/docker/container/run/#env)). **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](/blog/mcp-server-windows-npx-not-working).

## 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](/blog/mcp-stdio-unexpected-token-json)).

```
// 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](/blog/mcp-playground-desktop-guide) 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 →](/local-mcp-server-testing)

## 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 →](/local-mcp-server-testing)

## Frequently asked questions

### 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.


---

_Canonical page: https://mcpplaygroundonline.com/blog/mcp-server-env-variables-not-working — MCP Playground (mcpplaygroundonline.com), the free browser-based tool for testing MCP servers and building AI agents._
