Six ${VAR} forms, five .mcp.json fields, two tiny servers: what Claude Code MCP expansion actually produced A developer tested how Claude Code 2.1.278 expands environment variables in .mcp.json by wiring a dependency-free stdio MCP server into the config seven times and logging what the spawned process actually received. The probe found that ${VAR} and ${VAR:-default} expand in all five documented fields, bare $VAR never expands, an unset ${VAR} arrives as literal text, and nested ${A:-${B}} expands only in headers because that field is processed twice. 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 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 . 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 and five locations: command , args , env , url and headers . For the unset case it says: 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, and no mention of nesting. Those two were the gaps I most wanted to measure. 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 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. js const fs = require 'fs' ; const path = require 'path' ; const tag = process.argv 2 || 'untagged'; const snapshot = { tag, argv: process.argv.slice 2 , env: Object.fromEntries Object.entries process.env .filter k = k.startsWith 'PROBE ' || k === 'CLAUDE PROJECT DIR' , }; fs.appendFileSync path.join dirname, 'spawn-log.jsonl' , JSON.stringify snapshot + '\n' ; function handle { id, method, params } { if id === undefined return null; if method === 'initialize' return { jsonrpc: '2.0', id, result: { protocolVersion: params.protocolVersion, capabilities: { tools: {} }, serverInfo: { name: 'probe-' + tag, version: '0.0.1' } } }; if method === 'tools/list' return { jsonrpc: '2.0', id, result: { tools: { name: 'echo env', description: 'Returns argv and PROBE env verbatim.', inputSchema: { type: 'object', properties: {} } } } }; if method === 'tools/call' return { jsonrpc: '2.0', id, result: { content: { type: 'text', text: JSON.stringify snapshot, null, 2 } } }; return { jsonrpc: '2.0', id, error: { code: -32601, message: 'Method not found' } }; } let buf = ''; process.stdin.setEncoding 'utf8' .on 'data', c = { buf += c; let i; while i = buf.indexOf '\n' = 0 { const line = buf.slice 0, i .trim ; buf = buf.slice i + 1 ; if line continue; const reply = handle JSON.parse line ; if reply process.stdout.write JSON.stringify reply + '\n' ; } } ; 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. { "mcpServers": { "probe-args-env": { "command": "node", "args": "server.js", "args-env", "${PROBE SET}", "${PROBE UNSET:-arg-fallback}", "${PROBE SET:-arg-fallback}", "$PROBE SET", "${PROBE UNSET:-${PROBE SET}}", "${PROBE UNSET}" , "env": { "PROBE F1 BRACES SET": "${PROBE SET}", "PROBE F2 DEFAULT UNSET": "${PROBE UNSET:-env-fallback}", "PROBE F3 DEFAULT SET": "${PROBE SET:-env-fallback}", "PROBE F4 BARE DOLLAR": "$PROBE SET", "PROBE F5 NESTED": "${PROBE UNSET:-${PROBE SET}}", "PROBE F6 UNSET NO DEFAULT": "${PROBE UNSET}", "PROBE PD WITH DEFAULT": "${CLAUDE PROJECT DIR:-.}", "PROBE PD NO DEFAULT": "${CLAUDE PROJECT DIR}" } }, "cmd-braces-set": { "command": "${NODE BIN}", "args": "server.js", "cmd-braces-set" }, "cmd-default-unset": { "command": "${NODE BIN UNSET:-node}", "args": "server.js", "cmd-default-unset" }, "cmd-default-set": { "command": "${NODE BIN:-node}", "args": "server.js", "cmd-default-set" }, "cmd-bare-dollar": { "command": "$NODE BIN", "args": "server.js", "cmd-bare-dollar" }, "cmd-nested": { "command": "${NODE BIN UNSET:-${NODE BIN}}", "args": "server.js", "cmd-nested" }, "cmd-unset-no-default": { "command": "${NODE BIN UNSET}", "args": "server.js", "cmd-unset-no-default" } } } A .claude/settings.local.json containing { "enableAllProjectMcpServers": true } approves the project servers for non-interactive runs. Then, from the shell: unset PROBE UNSET NODE BIN UNSET export PROBE SET=hello-from-shell NODE BIN="$ which node " claude mcp list claude -p "Call the echo env tool of the MCP server named probe-args-env and print the tool result verbatim." \ --output-format stream-json --verbose --allowedTools "mcp probe-args-env echo env" < /dev/null cat spawn-log.jsonl 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. args and env The spawn log and the echo env tool result agreed byte for byte. With PROBE SET=hello-from-shell set and PROBE UNSET unset, this is what the server process saw: | Form written in .mcp.json | In args | In env | |---|---|---| | ${PROBE SET} | hello-from-shell | hello-from-shell | | ${PROBE UNSET:-arg-fallback} | arg-fallback | env-fallback same form, different literal | | ${PROBE SET:-arg-fallback} | hello-from-shell | hello-from-shell | | $PROBE SET | $PROBE SET literal | $PROBE SET literal | | ${PROBE UNSET:-${PROBE SET}} | ${PROBE SET} literal | ${PROBE SET} literal | | ${PROBE UNSET} | ${PROBE UNSET} literal | ${PROBE UNSET} literal | The two documented forms behave exactly as documented, and the two fields behave identically. The bare $PROBE SET is not touched at all, which is worth knowing if you have shell habits: the server receives a six-character string starting with a dollar sign. The unset form arrives as the literal ${PROBE UNSET} , which matches the doc's "uses the unexpanded ${VAR} text as-is". The nested form is the interesting one. ${PROBE UNSET:-${PROBE SET}} did not become hello-from-shell ; it became the literal ${PROBE SET} . That result is consistent with a matcher that stops at the first closing brace: it sees ${PROBE UNSET:-${PROBE SET} as one reference whose default is the text ${PROBE SET , substitutes that text because PROBE UNSET is unset, and leaves the trailing } in place. The pieces reassemble into a string that looks like a reference but is never expanded again. claude mcp list shows the same reading from the other side: it prints that argument as ${PROBE UNSET}} , with the doubled brace. To confirm there is no second pass for stdio fields, I added a seventh probe: a shell variable PROBE INDIRECT whose value is the text ${PROBE SET} , referenced as ${PROBE INDIRECT} in both args and env . It arrived as ${PROBE SET} , unexpanded. One pass, then done. command field: three connected, three failed The command field gave the same answers, but with a harsher failure mode, since a wrong command means no server at all. The init event of the stream-json output lists every configured server with a status, and the spawn log shows which processes actually started: ${NODE BIN} connected, ${NODE BIN UNSET:-node} connected, ${NODE BIN:-node} connected. All three wrote a spawn record. $NODE BIN , ${NODE BIN UNSET:-${NODE BIN}} and ${NODE BIN UNSET} were reported as "status": "failed" and never wrote a spawn record. So the rule is uniform across the three stdio fields. The only difference is that a literal $NODE BIN in args is a harmless string, while in command it is an executable that does not exist. url and headers : headers get a second pass To cover the two remaining fields I wrote a second no-dependency server, this time a plain http.createServer that accepts the Streamable HTTP transport POST with a JSON-RPC body, JSON reply and logs every request's path and headers. Its .mcp.json entry was: "probe-http": { "type": "http", "url": "http://127.0.0.1:${PORT SET}/mcp/${PROBE UNSET:-url-fallback}/${PROBE UNSET}/${PROBE INDIRECT}/${PROBE UNSET:-${PROBE SET}}", "headers": { "X-F1-Braces-Set": "${PROBE SET}", "X-F2-Default-Unset": "${PROBE UNSET:-hdr-fallback}", "X-F3-Default-Set": "${PROBE SET:-hdr-fallback}", "X-F4-Bare-Dollar": "$PROBE SET", "X-F5-Nested": "${PROBE UNSET:-${PROBE SET}}", "X-F6-Unset-No-Default": "${PROBE UNSET}", "X-F7-Indirect": "${PROBE INDIRECT}", "X-Credential-Npm-Token": "${NPM TOKEN}", "X-Credential-Npm-Token-Default": "${NPM TOKEN:-cred-fallback}" } } The request the server logged had this path: /mcp/url-fallback/$%7BPROBE UNSET%7D/$%7BPROBE SET%7D/$%7BPROBE SET%7D ${PORT SET} expanded the request reached port 18765 , the default fired, the unset reference stayed literal and was percent-encoded by the HTTP client, and both the indirect and the nested forms came out as the literal ${PROBE SET} . The url field is single-pass, exactly like args and env . The headers told a different story. The echo request tool result, which returns the last request's headers verbatim, contained: "x-f1-braces-set": "hello-from-shell", "x-f2-default-unset": "hdr-fallback", "x-f3-default-set": "hello-from-shell", "x-f4-bare-dollar": "$PROBE SET", "x-f5-nested": "hello-from-shell", "x-f6-unset-no-default": "${PROBE UNSET}", "x-f7-indirect": "hello-from-shell", In headers , the nested form resolved to hello-from-shell , and so did the indirect probe. The only mechanism that produces both results is expansion applied twice: the first pass turns ${PROBE UNSET:-${PROBE SET}} into ${PROBE SET} the same first-brace behaviour as everywhere else , and a second pass resolves that. The indirect probe proves it is a genuine second pass and not special nesting support, because ${PROBE INDIRECT} contains no nesting at all and still ended up fully resolved. I did not find this documented anywhere on the page, and I would not rely on it; a later version could remove the extra pass and quietly change a header value. The doc has a paragraph on credential variables in remote url and headers : In a remote server's url and headers , Claude Code reads credential variables from your environment as empty rather than expanding them. and, for the fallback: :-default fallback on it is ignored. NPM TOKEN is named as a covered variable, so I exported NPM TOKEN=npm-secret-should-be-empty and referenced it two ways. Both headers arrived as empty strings: "x-credential-npm-token": "" and "x-credential-npm-token-default": "" . The default cred-fallback never appeared. That matches the doc precisely, and it is the one place where an unset-looking result is not a bug in your shell but a deliberate refusal to forward a credential to a server named by a project file. CLAUDE PROJECT DIR The same doc page says Claude Code sets CLAUDE PROJECT DIR in the spawned server's environment, and adds: This variable is set in the server's environment, not in Claude Code's own environment, so referencing it via ${VAR} expansion in the command or args of a project-scoped .mcp.json entry ... requires a default such as ${CLAUDE PROJECT DIR:-.} . The measurement bears that out in a slightly confusing way. In the server's environment, CLAUDE PROJECT DIR itself was set to the project directory. But the two probe keys that referenced it in the env block came out as "PROBE PD WITH DEFAULT": "." and "PROBE PD NO DEFAULT": "${CLAUDE PROJECT DIR}" . Expansion is evaluated against Claude Code's own environment before the server exists, so the default fires and the no-default form stays literal, even though the very same process could read the real value from its own process.env . If you need the project root in args , read the variable inside the server instead of interpolating it. claude mcp list versus claude -p One observation about the tooling itself. With the approvals in .claude/settings.local.json , claude -p connected all seven stdio entries and the HTTP one on the first try. claude mcp list and claude mcp get , in the same directory with the same environment, reported every project server as "Pending approval run claude to approve " and did not spawn anything. The doc explains the difference: as of v2.1.196 those two commands read .mcp.json approvals "only from settings files that aren't checked into the repository until you trust the workspace by running claude in it and accepting the workspace trust dialog", and a fresh mktemp directory has never been trusted. The listing is still useful because it prints the configured forms by name the doc says these surfaces show "a ${VAR} reference by name rather than as its resolved value" and because it raises the missing-variable warnings: The warning for probe-args-env named both PROBE UNSET and CLAUDE PROJECT DIR ; the warning for cmd-unset-no-default named NODE BIN UNSET . No warning was raised for the bare $PROBE SET or $NODE BIN , which is consistent with the parser not recognising them as references at all. .mcp.json ${VAR} and ${VAR:-default} only. They work the same in all five fields. $VAR . It is passed through as text in every field, and in ${A:-${B}} becomes the literal ${B} in Setup for this measurement: Claude Code 2.1.278 on macOS with Node v22.22.2, six claude -p invocations four made a model call, two failed on argument order before any turn , and every value above copied from spawn-log.jsonl , the HTTP request log, or the verbatim tool result inside the stream-json transcript. Rulestack publishes MCP server configurations, skills and rules for Claude Code at rulestack.gumroad.com https://rulestack.gumroad.com?utm source=devto&utm medium=article&utm campaign=six-var-forms-five-mcp-json-fields-two-tiny-servers-what-claude-code-mcp-expansion-actually-produced . Every .mcp.json in our packs now uses only the ${VAR} and ${VAR:-default} forms that survived the six-form test above. If a later Claude Code release changes which fields expand, the rerun of this lab goes out on @ai-shop.bsky.social https://bsky.app/profile/ai-shop.bsky.social before it goes anywhere else.