Skip to content

[BUG] Self-defined stdio MCP env ${VAR} placeholders written verbatim instead of resolved at install time (Codex/Claude) #1963

Description

@cffnpwr

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.

  1. 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}"
  1. Install with the real value in the environment:
MY_TOKEN="repro-secret-not-a-real-token" apm install --target claude
  1. Inspect the generated .mcp.json:
cat .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.jsonMY_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

Metadata

Metadata

Assignees

No one assigned

    Labels

    area/content-securityUnicode scanning, Glassworm, apm audit content checks, SARIF output.area/mcp-configMCP server configuration depth, transports, variable resolution.status/acceptedDirection approved, safe to start work.status/triagedInitial agentic triage complete; pending maintainer ratification (silence = approval).theme/securitySecure by default. Content scanning, lockfile integrity, MCP trust boundaries.type/bugSomething does not work as documented.

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    Status
    Done

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions