Skip to content

[BUG] Codex and Gemini adapters pass self-defined stdio env verbatim, skipping placeholder resolution #1266

@hansonkim

Description

@hansonkim

Summary

For self-defined stdio MCP servers declared in apm.yml, the Codex and Gemini adapters write the env dict to the target config verbatim, bypassing the env-var resolution / substitution pipeline. Placeholder forms ${VAR}, ${env:VAR}, and even the legacy <VAR> are all passed through as literal strings, which results in the MCP child process receiving e.g. ATLASSIAN_API_TOKEN=${ATLASSIAN_API_TOKEN} and failing authentication.

This is the same class of defect as #1222 / #1224 (Claude adapter), but in two additional adapters that PR #1224 does not touch.

Repro

packages/common/apm.yml:

dependencies:
  mcp:
    - name: bitbucket
      registry: false
      transport: stdio
      command: pnpx
      args: ["@aashari/mcp-server-atlassian-bitbucket@3.1.0"]
      env:
        ATLASSIAN_API_TOKEN: "${ATLASSIAN_API_TOKEN}"   # also tried <ATLASSIAN_API_TOKEN> and ${env:...}
        ATLASSIAN_USER_EMAIL: "user@example.com"
export ATLASSIAN_API_TOKEN="real-token-here"
apm install -g ./packages/common -t claude,codex,gemini --force

Expected

~/.codex/config.toml [mcp_servers.bitbucket.env].ATLASSIAN_API_TOKEN should contain the literal token resolved from os.environ at install time, consistent with the Copilot adapter and with the documented behavior in manifest-schema ("Codex currently resolves only the legacy <VAR> placeholder at install time").

Actual

~/.codex/config.toml contains the placeholder verbatim:

[mcp_servers.bitbucket.env]
ATLASSIAN_API_TOKEN = "${ATLASSIAN_API_TOKEN}"
ATLASSIAN_USER_EMAIL = "user@example.com"

Tested with <ATLASSIAN_API_TOKEN>, ${ATLASSIAN_API_TOKEN}, ${env:ATLASSIAN_API_TOKEN} — all three are passed through unchanged. Gemini exhibits the same behavior on workspace-scope installs.

Root Cause

Both adapters short-circuit on _raw_stdio, assigning the raw env dict directly without routing through _resolve_environment_variables:

src/apm_cli/adapters/client/codex.py (around line 218–225):

raw = server_info.get("_raw_stdio")
if raw:
    config["command"] = raw["command"]
    config["args"] = [self.normalize_project_arg(arg) for arg in raw["args"]]
    if raw.get("env"):
        config["env"] = raw["env"]   # <-- no substitution; raw dict goes straight in
        self._warn_input_variables(raw["env"], server_info.get("name", ""), "Codex CLI")
    return config

src/apm_cli/adapters/client/gemini.py (around line 119–126): same pattern.

Compare with src/apm_cli/adapters/client/copilot.py (around line 512–530), which routes raw stdio env through the resolution pipeline:

raw = server_info.get("_raw_stdio")
if raw:
    config["command"] = raw["command"]
    resolved_env_for_args = {}
    if raw.get("env"):
        resolved_env_for_args = self._resolve_environment_variables(
            raw["env"], env_overrides=env_overrides
        )
        config["env"] = resolved_env_for_args
        self._warn_input_variables(raw["env"], server_info.get("name", ""), "Copilot CLI")
    ...

The Cursor adapter (cursor.py:126) also calls _resolve_environment_variables for the raw stdio path.

The fact that both adapters call _warn_input_variables(raw["env"], ...) shows the code already acknowledges that raw["env"] may contain placeholders — but the resolution step that would consume those placeholders is never wired in.

Suggested Fix

Mirror the Copilot/Cursor pattern: route raw["env"] through _resolve_environment_variables (legacy/literal-resolution mode) so install-time placeholder resolution from env_overridesos.environ works for self-defined stdio MCP servers in Codex and Gemini, the same way #1224 fixes it for Claude.

This is independent of #1224's change because Codex and Gemini are separate adapter classes that do not inherit the Copilot raw-stdio path.

Environment

  • APM 0.12.4 (HEAD 0693341 on main at time of analysis)
  • macOS (Darwin 25.4.0)
  • Targets affected: codex, gemini

Related

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    Status

    Todo

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions