最新资讯
reddit.com OpenAI 称 Astra 仅满足 10% 安全任务标准 dev.to FreeToken:在自有 GPU 上运行前沿 MoE 模型及其内存需求 dev.to 当 AI Agent 说“完成”时,究竟完成了什么? dev.to 部署 Underdog Bark 3.0 Pro:逐字转写、自定义后端与分块设置 dev.to 为艰难对话构建 AI:五个 Agent、一条安全正则与一位盲评监督者 techcrunch.com Palo Alto Networks 洽谈以 5 亿美元收购 AI Agent 安全初创公司 Clutch theinformation.com 黄仁勋被要求就涉华间谍指控向国会作简报 www.infoq.com Edera 推出 Object Capability Bounded Authority,试图将凭证与 AI Agent 工作负载隔离 wechat 这波杀疯了!旗舰级模型不限量、1个月视频额度免费领 bloomberg.com AI 抛售蔓延至信用市场,软银领跌亚洲美元债 dev.to 如何在专用 GPU 服务器上用 OpenAI 兼容 API 部署 LLM dev.to 审计 153 份 Claude Code 与 Codex 会话记录:User-Agent 与 URL 中的邮箱泄露
多元动态
登录 注册
AI编码 dev.to · @Rulestack

六种 ${VAR} 写法、五个 .mcp.json 字段、两个微型服务器:Claude Code MCP 变量展开实测结果

原文 我的收藏

作者在 Claude Code 2.1.278 上实测 .mcp.json 的环境变量展开:${VAR} 和 ${VAR:-default} 在 args、env、command 等字段均会展开,$VAR 在任何字段都不展开,未设置的 ${VAR} 会以字面文本原样传入,嵌套 ${A:-${B}} 只在 headers 中生效,因为该字段被展开两次。官方文档仅说明支持两种语法、五个字段会被展开,未涉及裸 $VAR、嵌套默认值、未设置变量及字段间差异。作者用无依赖 stdio MCP server 记录 process.argv 与 process.env 作为证据。

AI推荐理由 作者用自建无依赖 stdio MCP server 实测 Claude Code 2.1.278 的 .mcp.json 环境变量展开行为,给出可复现的具体结论:${VAR} 与 ${VAR:-default} 在 args/env/command 等字段展开,$VAR 从不展开,未设置的 ${VAR} 以字面文本传入,嵌套 ${A:-${B}} 仅在 headers 生效(该字段被展开两次)。这些是文档未覆盖的边界行为,对配置 MCP server 的开发者有直接参考价值。局限:仅单版本、单环境实测,未覆盖其他版本或平台,结论需自行验证。

72

正文

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. What the docs commit to 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. The rerun, in under ten minutes 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. 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'); } }); Enter fullscreen mode Exit fullscreen mode 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"] } } } Enter fullscreen mode Exit fullscreen mode 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 Enter fullscreen mode Exit fullscreen mode 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 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. The 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}" } } Enter fullscreen mode Exit fullscreen mode The request the server logged had this path: /mcp/url-fallback/$%7BPROBE_UNSET%7D/$%7BPROBE_SET%7D/$%7BPROBE_SET%7D Enter fullscreen mode Exit fullscreen mode ${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", Enter fullscreen mode Exit fullscreen mode 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 credential names that read as empty 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. One more twist: 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. What I would write in a shared .mcp.json Use ${VAR} and ${VAR:-default} only. They work the same in all five fields. Never write $VAR. It is passed through as text in every field, and in command it means the server fails to start with no missing-variable warning to point you at the cause. Do not nest. ${A:-${B}} becomes the literal ${B} in command, args, env and url. It only appears to work in headers, because that field is expanded twice, and nothing in the doc promises that. Expect an unset ${VAR} to arrive as the literal text ${VAR}; add a default if the server should still start. Keep credential-named variables out of remote url and headers, or copy the value into a differently named variable as the doc suggests; the default is ignored for those names. Reference CLAUDE_PROJECT_DIR with a default, or read it inside the server, because expansion runs before the server's environment exists. 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. 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 before it goes anywhere else.

来源报道 · 1 篇 · 以下链接均前往原始媒体

← 返回精选动态 原文

更多内容