{"slug": "six-var-forms-five-mcp-json-fields-two-tiny-servers-what-claude-code-mcp", "title": "Six ${VAR} forms, five .mcp.json fields, two tiny servers: what Claude Code MCP expansion actually produced", "summary": "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.", "body_md": "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.\n\nThe 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.\n\nI 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:\n\n`${VAR}`: expands to the value of environment variable `VAR`\n\n`${VAR:-default}`: expands to `VAR` if set, otherwise uses `default`\n\nand five locations: `command`, `args`, `env`, `url` and `headers`. For the unset case it says:\n\nIf 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.\n\nThere is no mention of `$VAR` without braces, and no mention of nesting. Those two were the gaps I most wanted to measure.\n\nEverything ran in a directory created with `mktemp -d`, so no project instructions or memory files were in play. The whole setup is three files.\n\nThe 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.\n\n``` js\nconst fs = require('fs'); const path = require('path');\nconst tag = process.argv[2] || 'untagged';\nconst snapshot = {\n  tag, argv: process.argv.slice(2),\n  env: Object.fromEntries(Object.entries(process.env)\n    .filter(([k]) => k.startsWith('PROBE_') || k === 'CLAUDE_PROJECT_DIR')),\n};\nfs.appendFileSync(path.join(__dirname, 'spawn-log.jsonl'), JSON.stringify(snapshot) + '\\n');\nfunction handle({ id, method, params }) {\n  if (id === undefined) return null;\n  if (method === 'initialize') return { jsonrpc: '2.0', id, result: {\n    protocolVersion: params.protocolVersion, capabilities: { tools: {} },\n    serverInfo: { name: 'probe-' + tag, version: '0.0.1' } } };\n  if (method === 'tools/list') return { jsonrpc: '2.0', id, result: { tools: [{\n    name: 'echo_env', description: 'Returns argv and PROBE_* env verbatim.',\n    inputSchema: { type: 'object', properties: {} } }] } };\n  if (method === 'tools/call') return { jsonrpc: '2.0', id, result: {\n    content: [{ type: 'text', text: JSON.stringify(snapshot, null, 2) }] } };\n  return { jsonrpc: '2.0', id, error: { code: -32601, message: 'Method not found' } };\n}\nlet buf = '';\nprocess.stdin.setEncoding('utf8').on('data', (c) => {\n  buf += c; let i;\n  while ((i = buf.indexOf('\\n')) >= 0) {\n    const line = buf.slice(0, i).trim(); buf = buf.slice(i + 1);\n    if (!line) continue;\n    const reply = handle(JSON.parse(line));\n    if (reply) process.stdout.write(JSON.stringify(reply) + '\\n');\n  }\n});\n```\n\nThe `.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.\n\n```\n{\n  \"mcpServers\": {\n    \"probe-args-env\": {\n      \"command\": \"node\",\n      \"args\": [\"server.js\", \"args-env\",\n        \"${PROBE_SET}\", \"${PROBE_UNSET:-arg-fallback}\", \"${PROBE_SET:-arg-fallback}\",\n        \"$PROBE_SET\", \"${PROBE_UNSET:-${PROBE_SET}}\", \"${PROBE_UNSET}\"],\n      \"env\": {\n        \"PROBE_F1_BRACES_SET\": \"${PROBE_SET}\",\n        \"PROBE_F2_DEFAULT_UNSET\": \"${PROBE_UNSET:-env-fallback}\",\n        \"PROBE_F3_DEFAULT_SET\": \"${PROBE_SET:-env-fallback}\",\n        \"PROBE_F4_BARE_DOLLAR\": \"$PROBE_SET\",\n        \"PROBE_F5_NESTED\": \"${PROBE_UNSET:-${PROBE_SET}}\",\n        \"PROBE_F6_UNSET_NO_DEFAULT\": \"${PROBE_UNSET}\",\n        \"PROBE_PD_WITH_DEFAULT\": \"${CLAUDE_PROJECT_DIR:-.}\",\n        \"PROBE_PD_NO_DEFAULT\": \"${CLAUDE_PROJECT_DIR}\"\n      }\n    },\n    \"cmd-braces-set\":       { \"command\": \"${NODE_BIN}\",                  \"args\": [\"server.js\", \"cmd-braces-set\"] },\n    \"cmd-default-unset\":    { \"command\": \"${NODE_BIN_UNSET:-node}\",      \"args\": [\"server.js\", \"cmd-default-unset\"] },\n    \"cmd-default-set\":      { \"command\": \"${NODE_BIN:-node}\",            \"args\": [\"server.js\", \"cmd-default-set\"] },\n    \"cmd-bare-dollar\":      { \"command\": \"$NODE_BIN\",                    \"args\": [\"server.js\", \"cmd-bare-dollar\"] },\n    \"cmd-nested\":           { \"command\": \"${NODE_BIN_UNSET:-${NODE_BIN}}\", \"args\": [\"server.js\", \"cmd-nested\"] },\n    \"cmd-unset-no-default\": { \"command\": \"${NODE_BIN_UNSET}\",            \"args\": [\"server.js\", \"cmd-unset-no-default\"] }\n  }\n}\n```\n\nA `.claude/settings.local.json` containing `{ \"enableAllProjectMcpServers\": true }` approves the project servers for non-interactive runs. Then, from the shell:\n\n```\nunset PROBE_UNSET NODE_BIN_UNSET\nexport PROBE_SET=hello-from-shell NODE_BIN=\"$(which node)\"\nclaude mcp list\nclaude -p \"Call the echo_env tool of the MCP server named probe-args-env and print the tool result verbatim.\" \\\n  --output-format stream-json --verbose --allowedTools \"mcp__probe-args-env__echo_env\" < /dev/null\ncat spawn-log.jsonl\n```\n\nOne 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.\n\n`args` and `env`\nThe 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:\n\n| Form written in `.mcp.json` | In `args` | In `env` | \n|---|---|---|\n| `${PROBE_SET}` | `hello-from-shell` | `hello-from-shell` | \n| `${PROBE_UNSET:-arg-fallback}` | `arg-fallback` | `env-fallback` (same form, different literal) | \n| `${PROBE_SET:-arg-fallback}` | `hello-from-shell` | `hello-from-shell` | \n| `$PROBE_SET` | `$PROBE_SET` (literal) | `$PROBE_SET` (literal) | \n| `${PROBE_UNSET:-${PROBE_SET}}` | `${PROBE_SET}` (literal) | `${PROBE_SET}` (literal) | \n| `${PROBE_UNSET}` | `${PROBE_UNSET}` (literal) | `${PROBE_UNSET}` (literal) | \n\nThe 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\".\n\nThe 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.\n\nTo 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.\n\n`command` field: three connected, three failed\nThe `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:\n\n`${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.\nSo 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.\n\n`url` and `headers`: headers get a second pass\nTo 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:\n\n```\n\"probe-http\": {\n  \"type\": \"http\",\n  \"url\": \"http://127.0.0.1:${PORT_SET}/mcp/${PROBE_UNSET:-url-fallback}/${PROBE_UNSET}/${PROBE_INDIRECT}/${PROBE_UNSET:-${PROBE_SET}}\",\n  \"headers\": {\n    \"X-F1-Braces-Set\": \"${PROBE_SET}\",\n    \"X-F2-Default-Unset\": \"${PROBE_UNSET:-hdr-fallback}\",\n    \"X-F3-Default-Set\": \"${PROBE_SET:-hdr-fallback}\",\n    \"X-F4-Bare-Dollar\": \"$PROBE_SET\",\n    \"X-F5-Nested\": \"${PROBE_UNSET:-${PROBE_SET}}\",\n    \"X-F6-Unset-No-Default\": \"${PROBE_UNSET}\",\n    \"X-F7-Indirect\": \"${PROBE_INDIRECT}\",\n    \"X-Credential-Npm-Token\": \"${NPM_TOKEN}\",\n    \"X-Credential-Npm-Token-Default\": \"${NPM_TOKEN:-cred-fallback}\"\n  }\n}\n```\n\nThe request the server logged had this path:\n\n```\n/mcp/url-fallback/$%7BPROBE_UNSET%7D/$%7BPROBE_SET%7D/$%7BPROBE_SET%7D\n```\n\n`${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`.\n\nThe headers told a different story. The `echo_request` tool result, which returns the last request's headers verbatim, contained:\n\n```\n\"x-f1-braces-set\": \"hello-from-shell\",\n\"x-f2-default-unset\": \"hdr-fallback\",\n\"x-f3-default-set\": \"hello-from-shell\",\n\"x-f4-bare-dollar\": \"$PROBE_SET\",\n\"x-f5-nested\": \"hello-from-shell\",\n\"x-f6-unset-no-default\": \"${PROBE_UNSET}\",\n\"x-f7-indirect\": \"hello-from-shell\",\n```\n\nIn `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.\n\nThe doc has a paragraph on credential variables in remote `url` and `headers`:\n\nIn a remote server's `url` and `headers`, Claude Code reads credential variables from your environment as empty rather than expanding them.\n\nand, for the fallback:\n\n`:-default` fallback on it is ignored.\n\n`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.\n\n`CLAUDE_PROJECT_DIR`\nThe same doc page says Claude Code sets `CLAUDE_PROJECT_DIR` in the spawned server's environment, and adds:\n\nThis 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:-.}`.\n\nThe 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.\n\n`claude mcp list` versus `claude -p`\nOne 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:\n\nThe 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.\n\n`.mcp.json`\n`${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.\n\n*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.*\n\n*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.*", "url": "https://wpnews.pro/news/six-var-forms-five-mcp-json-fields-two-tiny-servers-what-claude-code-mcp", "canonical_source": "https://dev.to/rulestack/six-var-forms-five-mcpjson-fields-two-tiny-servers-what-claude-code-mcp-expansion-actually-5a1f", "published_at": "2026-09-28 02:17:00+00:00", "updated_at": "2026-09-28 02:48:40.740830+00:00", "lang": "en", "topics": ["ai-agents", "agent-protocols", "developer-tools", "ai-tools"], "entities": ["Claude Code", "Anthropic", "Model Context Protocol"], "also_reported_by": [], "alternates": {"html": "https://wpnews.pro/news/six-var-forms-five-mcp-json-fields-two-tiny-servers-what-claude-code-mcp", "markdown": "https://wpnews.pro/news/six-var-forms-five-mcp-json-fields-two-tiny-servers-what-claude-code-mcp.md", "text": "https://wpnews.pro/news/six-var-forms-five-mcp-json-fields-two-tiny-servers-what-claude-code-mcp.txt", "jsonld": "https://wpnews.pro/news/six-var-forms-five-mcp-json-fields-two-tiny-servers-what-claude-code-mcp.jsonld"}}