Skip to content

fix: resolve env-var placeholders in Codex/Gemini self-defined stdio MCP (closes #1266)#1277

Open
edenfunf wants to merge 2 commits into
microsoft:mainfrom
edenfunf:fix/codex-gemini-stdio-env-1266
Open

fix: resolve env-var placeholders in Codex/Gemini self-defined stdio MCP (closes #1266)#1277
edenfunf wants to merge 2 commits into
microsoft:mainfrom
edenfunf:fix/codex-gemini-stdio-env-1266

Conversation

@edenfunf
Copy link
Copy Markdown
Contributor

Summary

Self-defined stdio MCP servers declared in apm.yml had their env block written verbatim to ~/.codex/config.toml and .gemini/settings.json. All three placeholder syntaxes (${VAR}, ${env:VAR}, legacy <VAR>) ended up as literal strings, causing the MCP child process to fail authentication.

This PR resolves them at install time, matching the Copilot/Cursor pattern and consistent with the documented manifest-schema behaviour.

Root cause

  1. Codex and Gemini's _raw_stdio branch assigned raw["env"] directly to config["env"], skipping the resolver entirely.
  2. Even adapters that did call _resolve_environment_variables (Cursor) hit a latent gap: the legacy-mode branch only handled the registry list-of-dict shape, so the dict-shape input from self-defined stdio deps was iterated as keys, every key failed isinstance(env_var, dict), and the env block came out as {}.
  3. Codex extends MCPClientAdapter directly (not CopilotClientAdapter), so the resolver wasn't even available on the class.

Fix

  • Move _resolve_environment_variables, _resolve_env_variable, _resolve_variable_placeholders, the env-placeholder regex helpers, and the per-server placeholder state to MCPClientAdapter so every adapter shares one resolver contract.
  • Add a legacy-mode dict-shape branch to _resolve_environment_variables (matches the fix in fix: preserve Claude stdio MCP env #1224 for Claude). Cursor's silent-drop latent bug dies as a byproduct.
  • Rewrite Codex/Gemini's _raw_stdio branch to route env through the resolver and args through _resolve_variable_placeholders, with the same shape as the existing Cursor/Copilot branches.
  • Centralise the prompt-suppression policy in a single _should_skip_env_prompts helper.

Production code is net ~0 lines (mostly relocation); tests add ~180 lines of regression coverage.

Related

Test plan

  • Existing 258 adapter unit tests pass
  • New regression tests: Codex (3), Gemini (3), Cursor (1, pins the latent bug), base-resolver dict-shape branch (3)
  • End-to-end apm install -t codex,gemini,cursor with all three placeholder syntaxes in apm.yml; resolved values written to disk for legacy-mode adapters; Copilot translate-mode unchanged (placeholders kept as runtime substitution)
  • ruff check + ruff format --check clean

…MCP (closes microsoft#1266)

The Codex and Gemini adapters wrote the env block of self-defined stdio
MCP servers to disk verbatim, bypassing the env-var resolution pipeline.
All three placeholder forms (${VAR}, ${env:VAR}, legacy <VAR>) landed as
literal strings in ~/.codex/config.toml and .gemini/settings.json, so
the MCP child processes received e.g. ATLASSIAN_API_TOKEN=${ATLASSIAN_API_TOKEN}
and authentication failed.

Root cause was twofold. The Codex and Gemini _raw_stdio branches never
called _resolve_environment_variables. Even adapters that did call it
(Cursor) hit a latent gap: the legacy-mode branch only handled the
registry list-of-dict shape, so the dict-shape input from self-defined
stdio deps was iterated as keys, every key failed isinstance(..., dict),
and the env block came out as {}. Codex additionally extends
MCPClientAdapter directly, not CopilotClientAdapter, so the resolver
method wasn't even available on the class.

Fix moves _resolve_environment_variables, _resolve_env_variable,
_resolve_variable_placeholders, the env-placeholder regex helpers, and
the per-server placeholder state to MCPClientAdapter so every adapter
shares one resolver contract. Adds a legacy-mode dict-shape branch
(matches the fix in microsoft#1224 for Claude) and rewrites Codex/Gemini's
_raw_stdio branches to route env and args through the resolver.

Cursor's silent-drop latent bug dies as a byproduct of consolidating
the resolver onto the base; pinned by a new regression test.
Copilot AI review requested due to automatic review settings May 11, 2026 13:25
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR fixes placeholder resolution for self-defined stdio MCP servers declared in apm.yml, ensuring Codex and Gemini no longer write ${VAR} / ${env:VAR} / <VAR> placeholders verbatim into their generated config files. It also centralizes env/arg placeholder resolution logic so all adapters share the same behavior, and adds regression coverage for Codex/Gemini/Cursor plus the shared resolver.

Changes:

  • Move env/arg placeholder resolution helpers into MCPClientAdapter so all client adapters share a single resolver contract.
  • Update Codex and Gemini _raw_stdio formatting to route env through _resolve_environment_variables and args through _resolve_variable_placeholders.
  • Add regression tests covering the fixed placeholder resolution behavior and the previously latent dict-shape env drop.

Reviewed changes

Copilot reviewed 8 out of 8 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
src/apm_cli/adapters/client/base.py Adds shared env/arg placeholder resolution and prompt-suppression policy to the base adapter.
src/apm_cli/adapters/client/copilot.py Removes duplicated resolver helpers and imports shared helpers from the base adapter.
src/apm_cli/adapters/client/codex.py Ensures self-defined stdio env/args flow through the shared resolver pipeline.
src/apm_cli/adapters/client/gemini.py Ensures self-defined stdio env/args flow through the shared resolver pipeline.
tests/unit/test_copilot_adapter.py Adds base-resolver regression tests for legacy-mode dict-shaped env blocks.
tests/unit/test_cursor_mcp.py Adds a regression test proving Cursor no longer drops dict-shaped stdio env blocks.
tests/unit/test_gemini_mcp.py Adds Gemini regression tests for resolving all placeholder syntaxes (env + args).
tests/test_codex_empty_string_and_defaults.py Adds Codex regression tests for resolving all placeholder syntaxes (env + args).

Comment thread src/apm_cli/adapters/client/base.py Outdated
Comment thread src/apm_cli/adapters/client/codex.py
Comment thread tests/unit/test_cursor_mcp.py Outdated
- Loosen translate-mode dict ``translated`` annotation. The branch passes
  non-string YAML scalars through verbatim, so ``dict[str, str]`` was a
  type liar; ``dict`` keeps the existing behaviour without misleading
  mypy.
- Move ``TestCursorSelfDefinedStdioEnvResolution`` above the
  ``__main__`` guard in tests/unit/test_cursor_mcp.py so direct
  execution (``python tests/unit/test_cursor_mcp.py``) includes the
  new regression class. pytest discovery was unaffected.
- Remove the stale manifest-schema line claiming Codex passes
  ``${VAR}`` / ``${env:VAR}`` through verbatim; after this PR Codex
  resolves all three placeholder syntaxes consistent with the table's
  Copilot CLI / Codex grouping.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants