- In short
- Environment variable expansion lets a .mcp.json file reference values by name instead of hardcoding them, so a shared project config can pull each developer machine-specific token from the environment at startup. Credentials never enter the committed file, which keeps secrets out of version control.
Why MCP environment variables exist
A .mcp.json file is meant to be committed to version control so a whole team shares one set of servers. That collaboration goal collides with a hard rule of security: secrets must never be committed. MCP environment variables resolve the collision. They let the shared file describe which token a server needs without ever storing the token itself, and each developer supplies their own value from their own environment at startup. The result is one config in git that works on everyone's machine, with no secret in sight.
This knowledge point is rated at the apply Bloom level for a reason. The exam does not just want you to recall that the feature exists; it wants you to wire it correctly in a scenario, knowing exactly which syntax to reach for and what happens when a value is missing.
- Environment variable expansion
- A mechanism in .mcp.json that substitutes a named environment variable into a string field at startup, so credentials and machine-specific paths are resolved per developer instead of being hardcoded in the shared, version-controlled file.
How MCP environment variables expand in .mcp.json
Claude Code supports two forms of expansion, and you should be fluent in both.
${VAR}expands to the value of the environment variableVAR. Use it for required values such as an API token.${VAR:-default}expands toVARif it is set, and otherwise uses the literaldefaultyou provide. Use it for optional values where a sensible fallback exists.
Expansion is not limited to one field. Claude Code will substitute variables inside the command, args, env, url, and headers fields, which covers both stdio servers that launch a local process and remote HTTP servers that authenticate with a bearer token. A GitHub server, for instance, declares an env entry whose value is ${GITHUB_TOKEN}; the config names the token, the developer's shell provides it, and the two meet only at runtime.
The fail-fast guarantee
There is one behavior that separates a confident answer from a guess on this topic. If a configuration references a required variable with ${VAR}, the variable is not set, and no default is supplied, Claude Code does not start the server with an empty string. It fails to parse the config outright. That is a feature, not an inconvenience: a missing credential becomes a loud startup error instead of a silent authentication failure three tool calls later. When a value is genuinely optional, the ${VAR:-default} form is the escape hatch that lets the config parse cleanly without the variable present.
Expansion locations, field by field
It pays to know exactly where expansion runs, because the right field depends on how the server is launched. For a local stdio server, the command field names the executable and the args field lists its arguments, and both accept variable references, which is useful when a path differs from one machine to the next. The env field passes environment variables into the spawned server process, and it is where an API token for a stdio server almost always belongs, expressed as a reference rather than a literal. For a remote HTTP server, there is no local process, so credentials travel in the headers field instead, typically as a bearer token built from a referenced variable, while the url field can itself be parameterized when an endpoint varies by environment.
The practical takeaway is that stdio and remote servers put their secrets in different places. A stdio server authenticates through env; a remote server authenticates through headers. In both cases the value is a reference, and in both cases the literal secret lives only in each developer's environment. Knowing which field carries the credential for which transport is exactly the kind of applied detail the exam can hinge a question on.
Helper variables and the project-directory fallback
Claude Code also exposes a few helper variables you can lean on rather than define. When it launches an MCP helper process, it sets CLAUDE_CODE_MCP_SERVER_NAME and CLAUDE_CODE_MCP_SERVER_URL in that process's environment, so a wrapper script can tell which server it is acting for without you wiring those values in by hand. The most useful built-in, though, is CLAUDE_PROJECT_DIR, which points at the project root and is handy for building paths that should be relative to the repository rather than to wherever Claude Code was launched.
There is a catch the ${VAR:-default} form is made for. A variable referenced from a project- or user-scoped .mcp.json command or args field must exist in the server's own environment, and CLAUDE_PROJECT_DIR is not guaranteed to be set in every context. The recommended pattern is therefore to pair it with a fallback, as in ${CLAUDE_PROJECT_DIR:-.}, so the reference still resolves to the current directory when the variable is absent. Plugin-provided MCP configurations are the exception: Claude Code substitutes ${CLAUDE_PROJECT_DIR} into them directly, so those configs do not need the default fallback.
One transport footnote belongs here too, because it trips people configuring servers by hand. In the JSON config surfaces, .mcp.json, ~/.claude.json, and claude mcp add-json, the type field accepts streamable-http as an alias for http. The two are interchangeable there, so a config that reads streamable-http and one that reads http describe the same remote transport, and neither is more correct than the other.
A migration recipe for an already-hardcoded config
Teams rarely start clean; more often they inherit a .mcp.json with a token already pasted in. The migration is mechanical once you know the steps. First, treat any token that has touched the repository as compromised and rotate it, because indirection protects future commits but cannot retract a value already in history. Second, replace each literal in the file with a named reference, choosing the correct field for the transport: env for stdio, headers for remote. Third, document the required variable names so teammates know what to export, and add the safe ${VAR:-default} form only where a real fallback makes sense, such as an optional cache directory. Fourth, confirm the fail-fast behavior works as intended by unsetting a required variable and watching the config refuse to parse, which proves the safety net is in place.
Done in that order, the migration converts a leaky shared file into one that names every dependency and stores none of them. The configuration that ships in the repo becomes a manifest of what the integration needs, while the actual secrets stay distributed across the developers who hold them.
How expansion relates to secret managers
Environment variable expansion is deliberately simple: it reads from the environment and substitutes. That simplicity is a strength, because it composes with whatever secret management a team already uses. A developer might export the variable directly in a shell profile, or have it injected by a secret manager, a vault tool, or a CI system at startup. Claude Code does not care where the value came from; it only cares that the variable is set when the server launches. So expansion is not a competitor to a secret manager, it is the seam that lets one feed credentials into MCP servers without ever writing them to disk in the project.
This is also why the pattern scales from a solo developer to a large team without changing. The shared .mcp.json is identical for everyone, and the difference between machines is entirely in the environment each one provides. That uniform file plus per-machine environment is the same separation the scoping hierarchy established, now applied to secrets rather than to sharing.
Where this sits in the integration picture
Environment variable expansion is the natural successor to the scoping hierarchy. Once you know that project scope shares .mcp.json through version control, the immediate question is how to share a server that needs a secret without leaking that secret. Expansion is the answer, and it is also a prerequisite for the broader integration best practices knowledge point, which treats env-based credentials as one of three non-negotiable habits alongside correct scoping and strong tool descriptions.
It also pairs with the build-versus-use decision. Community servers for GitHub, Slack, and Jira are written to read their credentials from named variables precisely because they expect to be installed through a shared config. Knowing the expansion syntax is what lets you adopt those servers cleanly instead of forking them to hardcode a key.
A note on visibility and logging
One more habit protects the whole scheme: never defeat the indirection by printing the expanded value. Because the secret exists only at runtime, it is tempting to echo it while debugging a server that will not start, but a value written to a terminal, a screen share, or a shared CI log has effectively leaked again. Keep the reference in the file, keep the value in the environment, and resist the urge to surface the resolved secret anywhere it might be captured. The entire point of expansion is that the literal lives in exactly one place per machine, and a stray log line is the easiest way to undo that guarantee. When you do need to confirm a variable is set, check that it has a value, not what the value is, so the diagnostic never becomes the leak.
Why it matters for the exam
A scenario for this knowledge point usually hands you a security mistake in progress. A developer hardcodes a personal access token directly into .mcp.json, the file gets committed, and now a live credential is in the git history for everyone with repo access to find. The exam wants you to identify the correct fix: replace the literal token with a ${VAR} reference, rotate the leaked token, and have each developer export the variable locally. The deeper point being tested is that a shared config file and a private secret can coexist only through indirection, and expansion is the indirection the protocol gives you.
Worked example
A team adopts the community GitHub MCP server. The first engineer pastes their personal access token straight into .mcp.json and opens a pull request, and a reviewer flags it.
The reviewer is right to block the merge. As written, the token would be committed to version control, visible in the diff and in history, and shared with every teammate, which violates both the no-secrets-in-git rule and the principle that each developer should authenticate as themselves.
The fix has three moves. First, in .mcp.json, replace the literal token with a named reference so the env entry for the GitHub server reads ${GITHUB_TOKEN} instead of the raw string. The committed file now describes the dependency without containing the secret. Second, because the original token already reached a pull request, treat it as compromised and rotate it; indirection protects future commits but cannot un-leak a value that was already pushed. Third, each engineer exports GITHUB_TOKEN in their own shell or secret manager, so at startup Claude Code expands the reference to that person's value.
Now test the failure path. A new teammate clones the repo but forgets to set the variable. Because the reference is required and has no default, Claude Code refuses to parse the config and reports the missing variable immediately. That is the fail-fast guarantee doing its job: the teammate sees a clear setup error at startup instead of a confusing permission error from GitHub partway through a task.
Common misreadings to avoid
Misconception
Because .mcp.json is shared with the team, putting the token directly in the file is the easiest way to make sure everyone can authenticate.
What's actually true
Misconception
If an environment variable referenced in .mcp.json is not set, Claude Code just substitutes an empty value and starts the server anyway.
What's actually true
How it shows up on the exam
A shared .mcp.json defines a stdio server whose env block sets API_KEY to ${ANALYTICS_KEY}. A teammate clones the repo, runs Claude Code, and immediately sees an error that the configuration cannot be parsed because ANALYTICS_KEY is not set. What is the correct interpretation and fix?
People also ask
How do I use environment variables in .mcp.json?
What is the default-value syntax in MCP config?
How do I keep API keys out of version control with MCP?
Where do MCP server credentials go in the config?
Watch and learn
Official Anthropic Academy lessons first, then hand-picked walkthroughs. Videos load only when you press play.
MCP in Claude Code
Why watch: Anthropic's own walkthrough of adding and scoping MCP servers in Claude Code, the exact context where ${VARIABLE} expansion in .mcp.json keeps credentials out of version control.
More videos for this concept
References & primary sources
Master this concept with Archie
Practice it inside an adaptive study session. Archie, your Socratic AI tutor, tracks your mastery with Bayesian Knowledge Tracing and schedules the perfect next review.