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

> Source: <https://dev.to/rulestack/six-var-forms-five-mcpjson-fields-two-tiny-servers-what-claude-code-mcp-expansion-actually-5a1f>
> Published: 2026-09-28 02:17:00+00:00

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.*
