Describe the bug
When a self-defined stdio MCP server (registry: false, transport: stdio) declares a ${VAR} placeholder in its env block, targets that resolve variables at install time (Codex, Claude) write the placeholder string verbatim into the target config — even when the real value is present in os.environ. The MCP child process then receives an unresolved string like MY_TOKEN=${MY_TOKEN} and fails authentication.
On the same target, HTTP server header values resolve correctly. The problem is limited to the self-defined stdio env block.
To Reproduce
Requirements: apm v0.23.0.
- Create a directory with the following
apm.yml, and create .claude/ as the Claude target signal:
name: repro
version: 1.0.0
dependencies:
mcp:
- name: env-demo
registry: false
transport: stdio
command: echo
args: ["noop"]
env:
MY_TOKEN: "${MY_TOKEN}"
- Install with the real value in the environment:
MY_TOKEN="repro-secret-not-a-real-token" apm install --target claude
- Inspect the generated
.mcp.json:
Codex behaves the same (project scope writes to .codex/config.toml):
MY_TOKEN="repro-secret-not-a-real-token" apm install --target codex
cat .codex/config.toml
Expected behavior
docs/.../reference/manifest-schema.md §4.2.4 "Variable References in headers and env" normatively specifies install-time os.environ resolution for targets without runtime interpolation:
Codex, Gemini, and Cursor have no runtime interpolation, so APM resolves ${VAR}, ${env:VAR}, and the legacy <VAR> at install time using os.environ (or an interactive prompt when missing).
The same table's ${VAR} row (Codex/Gemini/Cursor column) also states "Resolved at install time from env (or interactive prompt)".
Claude is not in that table, but src/apm_cli/adapters/client/claude.py requires the same behaviour:
# Claude Desktop / Code's mcp config does NOT support runtime env-var
# substitution -- the value in ``env`` must be a literal string. This
# adapter MUST keep the legacy install-time resolution behaviour.
_supports_runtime_env_substitution: bool = False
So env: { MY_TOKEN: "${MY_TOKEN}" } should be written resolved to the real value of os.environ["MY_TOKEN"].
Actual behavior
Claude .mcp.json — MY_TOKEN is unresolved:
{
"mcpServers": {
"env-demo": {
"type": "stdio",
"command": "echo",
"env": { "MY_TOKEN": "${MY_TOKEN}" },
"args": ["noop"]
}
}
}
Codex .codex/config.toml — identical:
[mcp_servers.env-demo.env]
MY_TOKEN = "${MY_TOKEN}"
For contrast, on the same Claude target, writing the value into an HTTP server header as "Authorization": "Bearer ${MY_TOKEN}" resolves correctly ("Bearer repro-secret-not-a-real-token"). This difference between the stdio env block and the header narrows down the cause.
Environment
- OS: macOS 26.5.1
- Python Version: runs on APM's bundled Python 3.12 build (Homebrew)
- APM Version: 0.23.0 (01b337f)
- Affected targets: codex, claude
Additional context
Likely cause: for the self-defined stdio env block, the same dep.env dict is passed into the resolution pipeline as both the values to resolve and the env_overrides map. The lookup env_overrides.get(name) or os.getenv(name) then returns the placeholder string (truthy) and never reaches os.getenv, so ${MY_TOKEN} "resolves" to itself. Header values are not keys in that map, so they fall through to os.getenv and resolve correctly — matching the contrast above. I can add a file/line walkthrough and a note on the test gap in a comment.
Not a duplicate
Describe the bug
When a self-defined stdio MCP server (
registry: false,transport: stdio) declares a${VAR}placeholder in itsenvblock, targets that resolve variables at install time (Codex, Claude) write the placeholder string verbatim into the target config — even when the real value is present inos.environ. The MCP child process then receives an unresolved string likeMY_TOKEN=${MY_TOKEN}and fails authentication.On the same target, HTTP server header values resolve correctly. The problem is limited to the self-defined stdio env block.
To Reproduce
Requirements: apm v0.23.0.
apm.yml, and create.claude/as the Claude target signal:MY_TOKEN="repro-secret-not-a-real-token" apm install --target claude.mcp.json:Codex behaves the same (project scope writes to
.codex/config.toml):MY_TOKEN="repro-secret-not-a-real-token" apm install --target codex cat .codex/config.tomlExpected behavior
docs/.../reference/manifest-schema.md§4.2.4 "Variable References inheadersandenv" normatively specifies install-timeos.environresolution for targets without runtime interpolation:The same table's
${VAR}row (Codex/Gemini/Cursor column) also states "Resolved at install time from env (or interactive prompt)".Claude is not in that table, but
src/apm_cli/adapters/client/claude.pyrequires the same behaviour:So
env: { MY_TOKEN: "${MY_TOKEN}" }should be written resolved to the real value ofos.environ["MY_TOKEN"].Actual behavior
Claude
.mcp.json—MY_TOKENis unresolved:{ "mcpServers": { "env-demo": { "type": "stdio", "command": "echo", "env": { "MY_TOKEN": "${MY_TOKEN}" }, "args": ["noop"] } } }Codex
.codex/config.toml— identical:For contrast, on the same Claude target, writing the value into an HTTP server header as
"Authorization": "Bearer ${MY_TOKEN}"resolves correctly ("Bearer repro-secret-not-a-real-token"). This difference between the stdio env block and the header narrows down the cause.Environment
Additional context
Likely cause: for the self-defined stdio
envblock, the samedep.envdict is passed into the resolution pipeline as both the values to resolve and theenv_overridesmap. The lookupenv_overrides.get(name) or os.getenv(name)then returns the placeholder string (truthy) and never reachesos.getenv, so${MY_TOKEN}"resolves" to itself. Header values are not keys in that map, so they fall through toos.getenvand resolve correctly — matching the contrast above. I can add a file/line walkthrough and a note on the test gap in a comment.Not a duplicate
.mcp.jsondrops env for self-defined stdio MCP servers (the env block was emitted empty; that path now carries the env)._resolve_environment_variables. This report is the next layer: the env block now reaches the resolver, but the resolver shadowsos.environwith the placeholder dict, so the value is still not resolved (and the [BUG] Codex and Gemini adapters pass self-defined stdio env verbatim, skipping placeholder resolution #1266 fix is what now routes Codex through that shadowing path).