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

    No labels
    No labels

    Type

    No type

    Projects

    Status

    Todo

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions