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_overrides → os.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
Summary
For self-defined stdio MCP servers declared in
apm.yml, the Codex and Gemini adapters write theenvdict 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:Expected
~/.codex/config.toml[mcp_servers.bitbucket.env].ATLASSIAN_API_TOKENshould contain the literal token resolved fromos.environat install time, consistent with the Copilot adapter and with the documented behavior inmanifest-schema("Codex currently resolves only the legacy<VAR>placeholder at install time").Actual
~/.codex/config.tomlcontains the placeholder verbatim: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):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:The Cursor adapter (
cursor.py:126) also calls_resolve_environment_variablesfor the raw stdio path.The fact that both adapters call
_warn_input_variables(raw["env"], ...)shows the code already acknowledges thatraw["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 fromenv_overrides→os.environworks 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
0693341onmainat time of analysis)codex,geminiRelated