Spots

Six ${VAR} forms, five .mcp.json fields, two tiny servers: what Claude Code MCP…

I put six environment-variable forms into the args, env and command fields of one project .mcp.json (plus url and headers on a second, HTTP server) and read back what the server process actually received. On Claude Code 2.1.278, ${VAR} and ${VAR:-default} expanded everywhere, $VAR never expanded anywhere, an unset ${VAR} arrived as the literal text, and a nested ${A:-${B}} expanded only in headers because that field is expanded twice.

The MCP page in the Claude Code docs

The MCP page in the Claude Code docs says two syntaxes are supported and five fields are expanded. That leaves a lot unsaid: what happens to a bare $VAR, to a nested default, to a variable that is unset, and whether all five fields behave the same. Rather than guess, I wrote a dependency-free stdio MCP server that reports its own process.argv and process.env, wired it into a .mcp.json several times over, and let Claude Code launch it. Everything below is what the logs and tool results said, not what I expected.

I fetched https://code.claude.com/docs/en/mcp on 2026-09-22 with trafilatura -u

I fetched https://code.claude.com/docs/en/mcp on 2026-09-22 with trafilatura -u. The section "Environment variable expansion in .mcp.json" lists exactly two forms: ${VAR}: expands to the value of environment variable VAR ${VAR:-default}: expands to VAR if set, otherwise uses default ${VAR}: expands to the value of environment variable VAR ${VAR:-default}: expands to VAR if set, otherwise uses default and five locations: command, args, env, url and headers. For the unset case it says:

If a referenced environment variable isn't set and

If a referenced environment variable isn't set and has no default value, the config still loads: Claude Code reports a missing-variable warning for that server in claude mcp list output and uses the unexpanded ${VAR} text as-is.

There is no mention of $VAR without braces

There is no mention of $VAR without braces, and no mention of nesting. Those two were the gaps I most wanted to measure. The rerun, in under ten minutes

Everything ran in a directory created with mktemp

Everything ran in a directory created with mktemp -d, so no project instructions or memory files were in play. The whole setup is three files.

The stdio server, server.js, has no dependencies. It

The stdio server, server.js, has no dependencies. It speaks newline-delimited JSON-RPC over stdin/stdout, answers initialize, ping, tools/list and tools/call, and exposes one tool, echo_env. On startup it also appends a snapshot to spawn-log.jsonl, which matters: the log gives ground truth even when no model turn happens.

The .mcp.json registers that same script seven times

The .mcp.json registers that same script seven times. One entry, probe-args-env, carries all six forms in args and again as six keys in env. Six more entries differ only in the command field, because a command is a single string and can hold one form at a time.

A .claude/settings.local.json containing { "enableAllProjectMcpServers": true }…

A .claude/settings.local.json containing { "enableAllProjectMcpServers": true } approves the project servers for non-interactive runs. Then, from the shell:

One trap I fell into: with the prompt

One trap I fell into: with the prompt placed after --allowedTools, claude -p consumed the prompt as another tool pattern and exited with "Input must be provided either through stdin or as a prompt argument". Put the prompt directly after -p. Two of my six invocations were lost to that mistake and made no model call. What arrived in args and env

News

Six ${VAR} forms, five .mcp.json fields, two tiny servers: what Claude Code MCP expansion actually produced

I put six environment-variable forms into the args, env and command fields of one project .mcp.json (plus url and headers on a second, HTTP server) and read back what the server process actually received.

@spots #dev
Source: Dev.to
See more like this