Stdio MCP server silently dropped when `${VAR}` substitution fails — docs promise "fail to parse" but REPL silently reports "No MCP servers configured"; `--mcp-config` spawns with unsubstituted value causing downstream auth failure
Three related defects affecting stdio MCP servers configured in .mcp.json with ${VAR} substitution:
- Silent skip contradicts documented "fail to parse". The MCP docs state:
> "If a required environment variable is not set and has no default value, Claude Code will fail to parse the config."
Actual behavior in the REPL: the server is silently dropped. /mcp reports "No MCP servers configured", no log file is created, no error or warning is shown.
- Inconsistent behavior between launch paths for the same root cause:
- Plain
claudeREPL → silent drop (per #1) claude --mcp-config /path/.mcp.json→ server IS spawned but${VAR}was not substituted, so the empty/literal value reaches the server. In our case (slack-mcp-server) this surfaced asinvalid_authonly at the downstream service — extremely confusing root-cause-wise.claude mcp list/claude -p→ works correctly if the env var happens to be in the launching shell.
settings.local.json'senvblock is not a source for${VAR}substitution in.mcp.json. The settings docs describe theenvblock as "Environment variables that will be applied to every session". A natural reading suggests these would be in scope for in-session${VAR}expansion. They are not — and there is no documentation pointing to this gap. This is what led to a multi-hour debug for us: the token was insettings.local.json, every documented check passed, but the MCP server never saw it.
Reproducer
mkdir -p /tmp/repro/.claude && cd /tmp/repro
cat > .mcp.json <<'EOF'
{
"mcpServers": {
"echo": {
"command": "node",
"args": ["-e", "process.stderr.write('TOKEN=' + (process.env.API_TOKEN || '<empty>') + '\\n'); process.exit(1)"],
"env": { "API_TOKEN": "${TEST_API_TOKEN}" }
}
}
}
EOF
cat > .claude/settings.local.json <<'EOF'
{ "env": { "TEST_API_TOKEN": "would-be-secret" }, "enabledMcpjsonServers": ["echo"] }
EOF
unset TEST_API_TOKEN # must NOT be in shell env
# Case A — auto-discovery
claude
# Observed in /mcp: "No MCP servers configured"
# Expected per docs: parse error OR substitution from settings.local.json env
# Case B — explicit --mcp-config
claude --mcp-config $PWD/.mcp.json
# Observed in /mcp: server attempts to spawn, fails
# Inspecting ~/Library/Caches/claude-cli-nodejs/.../mcp-logs-echo/:
# server stderr shows TOKEN=<empty> — substitution returned empty
# Case C — token in shell env
export TEST_API_TOKEN=would-be-secret
claude # /mcp now shows the server
Expected behavior
Choose one (or some combination):
- Honor the docs and
settings.local.json'senvblock as a substitution source (most user-friendly). - Or: hard-fail with a clear error if substitution can't resolve, in all launch paths — matching the documented "fail to parse" behavior.
- Document the limitation explicitly in both the MCP docs and the settings docs.
- At minimum, eliminate the divergence between REPL /
--mcp-config/mcp listfor the same configuration.
Environment
- Claude Code v2.1.138 (Homebrew cask)
- macOS 15 / Darwin 25.4.0 / arm64
- Plain zsh, no wrapper/alias
- Server tested:
slack-mcp-server@latest(stdio)
Cost of debugging
Reporting this took ~3.5 hours and ~15.3M tokens (≈$76 Opus 4.7 API-equivalent) across multiple Claude Code sessions. Documented placement of the secret in settings.local.json (per the "applied to every session" wording) is exactly the path a security-conscious user would take, so this defect has high cost-per-encounter.
Related issues (distinct from this one)
- #2065 — original env-var question (closed)
- #4276, #46889 — feature requests for substitution in
settings.json - #6204, #51581 — HTTP transport headers (different code path)
- #24657, #16402 —
enabledMcpjsonServersin settings.local.json (different setting)
This issue has 3 comments on GitHub. Read the full discussion on GitHub ↗