fix: resolve env-var placeholders in Codex/Gemini self-defined stdio MCP (closes #1266)#1277
Open
edenfunf wants to merge 2 commits into
Open
fix: resolve env-var placeholders in Codex/Gemini self-defined stdio MCP (closes #1266)#1277edenfunf wants to merge 2 commits into
edenfunf wants to merge 2 commits into
Conversation
…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.
Contributor
There was a problem hiding this comment.
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
MCPClientAdapterso all client adapters share a single resolver contract. - Update Codex and Gemini
_raw_stdioformatting to routeenvthrough_resolve_environment_variablesandargsthrough_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). |
- 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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Self-defined stdio MCP servers declared in
apm.ymlhad theirenvblock written verbatim to~/.codex/config.tomland.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
_raw_stdiobranch assignedraw["env"]directly toconfig["env"], skipping the resolver entirely._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 failedisinstance(env_var, dict), and the env block came out as{}.MCPClientAdapterdirectly (notCopilotClientAdapter), so the resolver wasn't even available on the class.Fix
_resolve_environment_variables,_resolve_env_variable,_resolve_variable_placeholders, the env-placeholder regex helpers, and the per-server placeholder state toMCPClientAdapterso every adapter shares one resolver contract._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._raw_stdiobranch to routeenvthrough the resolver andargsthrough_resolve_variable_placeholders, with the same shape as the existing Cursor/Copilot branches._should_skip_env_promptshelper.Production code is net ~0 lines (mostly relocation); tests add ~180 lines of regression coverage.
Related
Test plan
apm install -t codex,gemini,cursorwith all three placeholder syntaxes inapm.yml; resolved values written to disk for legacy-mode adapters; Copilot translate-mode unchanged (placeholders kept as runtime substitution)ruff check+ruff format --checkclean