AI Skill Certs
Tool Design & MCP Integration·Task 2.4·Bloom: apply·Difficulty 2/5·7 min read·Updated 2026-06-07

MCP Environment Variables: Keep Credentials Out of .mcp.json

Integrate MCP servers into Claude Code and agent workflows

SUBy Solomon UdohReviewed by Solomon UdohAI-assisted · human-reviewed
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 variable VAR. Use it for required values such as an API token.
  • ${VAR:-default} expands to VAR if it is set, and otherwise uses the literal default you 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.

${VAR}
required value, expands from the environment
${VAR:-default}
optional value with a fallback
command/args/env/url/headers
fields where expansion runs

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.

Resolving a token at server startup
Loading diagram...
The committed file carries only the variable name; the real value joins it at startup from each developer's own environment.

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

Hardcoding a token commits a live secret to version control. Reference the value with a ${VAR} expression instead, and have each developer supply the variable from their own environment. The shared file then names the credential without ever storing it.

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

A required ${VAR} with no value and no default causes Claude Code to fail to parse the config. Use the ${VAR:-default} form only when a fallback is genuinely acceptable; otherwise the fail-fast error is what protects you from a silently misconfigured server.

How it shows up on the exam

Check your understanding

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?
Reference the variable by name with dollar-brace syntax inside any string field such as command, args, env, url, or headers. Claude Code substitutes the current value at startup, so the secret stays out of the committed file.
What is the default-value syntax in MCP config?
The dollar-brace-VAR-colon-dash-default form expands to the variable when it is set and otherwise uses the supplied default, which is the safe pattern for optional values like a project path.
How do I keep API keys out of version control with MCP?
Never hardcode the key. Put a named reference in .mcp.json and have each developer export the real value from their own environment, so the shared file names the credential without storing it.
Where do MCP server credentials go in the config?
Into the env field for stdio servers or the headers field for remote HTTP servers, expressed as variable references rather than literals so the actual values live only in each developer environment.

Watch and learn

Official Anthropic Academy lessons first, then hand-picked walkthroughs. Videos load only when you press play.

Claude

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

Adaptive study

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.

Start studying