Skip to content

[fix] translate bare ${VAR} env-var refs in self-defined MCP server headers (#944)#947

Open
edenfunf wants to merge 1 commit intomicrosoft:mainfrom
edenfunf:fix/mcp-bare-env-var-headers-944
Open

[fix] translate bare ${VAR} env-var refs in self-defined MCP server headers (#944)#947
edenfunf wants to merge 1 commit intomicrosoft:mainfrom
edenfunf:fix/mcp-bare-env-var-headers-944

Conversation

@edenfunf
Copy link
Copy Markdown
Contributor

What

Self-defined MCP servers (registry: false) that use bare ${VARNAME} or ${env:VARNAME} in headers were written verbatim into target config files, causing the literal placeholder string to be sent as the header value at runtime instead of the resolved secret.

Before:

# apm.yml
headers:
  Authorization: "Bearer ${MY_SECRET_TOKEN}"
// .vscode/mcp.json
"headers": { "Authorization": "Bearer ${MY_SECRET_TOKEN}" }   // literal, broken
// ~/.copilot/mcp-config.json
"headers": { "Authorization": "Bearer ${MY_SECRET_TOKEN}" }   // literal, broken

After:

// .vscode/mcp.json  -- VS Code resolves ${env:VAR} natively at runtime
"headers": { "Authorization": "Bearer ${env:MY_SECRET_TOKEN}" }
// ~/.copilot/mcp-config.json  -- Copilot has no runtime interpolation, APM resolves at install time
"headers": { "Authorization": "Bearer <actual-token-value>" }

Why

Per issue #944, the user-facing apm.yml syntax ${VARNAME} is a natural choice for env-var placeholders, but APM previously recognized only ${input:<id>} (input variables) and the legacy <VARNAME> (Copilot install-time resolution). Bare ${VARNAME} and ${env:VARNAME} fell through both filters and were copied verbatim, producing a silent failure where MCP servers received the literal placeholder string as their auth token.

The fix is target-specific because the config formats differ:

  • VS Code mcp.json has native runtime env interpolation (${env:VAR}, ${input:VAR}) -- APM's job is to emit a correctly-formatted placeholder, not to bake the secret into the file.
  • Copilot CLI mcp-config.json has no runtime interpolation -- APM must resolve the value at install time, mirroring the existing <VAR> flow.

How

Three minimal, additive changes that follow existing adapter patterns:

  1. base.py -- add a shared _ENV_VAR_RE regex (matches ${VAR} and ${env:VAR}, captures VAR). Designed with negative lookahead-style ordering so it never matches ${input:...} (kept disjoint from _INPUT_VAR_RE) and never matches GitHub Actions ${{ ... }} templates.
  2. vscode.py -- add a static _translate_env_vars_for_vscode helper (mirrors the existing _extract_input_variables static-helper style) and call it before writing both remote headers and self-defined stdio env. Translation is purely textual and idempotent, so re-running apm install is safe.
  3. copilot.py -- module-level _COPILOT_ENV_RE (alternation of legacy <VAR> and the new ${...} patterns) drives a single-pass re.sub in _resolve_env_variable. The existing env_overrides -> os.environ -> interactive-prompt resolution flow now applies uniformly to all three syntaxes. Single-pass substitution preserves the original <VAR> semantics: a resolved value containing literal ${...} text is NOT recursively re-expanded. Gemini inherits this for free via CopilotClientAdapter.

Codex is unchanged (it does not handle remote servers, so headers do not apply).

Why these patterns are safe

  • _ENV_VAR_RE is mathematically disjoint from _INPUT_VAR_RE: the optional env: group cannot also satisfy input:.
  • ${{ ... }} (GitHub Actions) is not matched: the second { fails the identifier class.
  • Idempotency: ${env:VAR} is captured but the substitution rewrites it back to ${env:VAR}, so repeat installs are stable.
  • Backward compatibility: <VAR> legacy syntax, ${input:...} input vars, and the existing input-variable warning paths are all untouched.

Test

Unit tests

tests/unit/test_vscode_adapter.py and tests/unit/test_copilot_adapter.py add 13 new tests covering:

  • ${VAR} translation in headers and stdio env
  • ${env:VAR} and ${input:...} round-trip preservation
  • Idempotency
  • GitHub Actions ${{ ... }} left untouched
  • Empty mapping, None, non-string values
  • Copilot resolution of all three syntaxes (<VAR>, ${VAR}, ${env:VAR})
  • env_overrides precedence
  • Unresolvable refs preserved verbatim
  • ${input:...} not resolved by Copilot path
  • Regression guard: a resolved value containing literal ${OTHER} text is NOT recursively expanded (verified across all three syntaxes via subTest)
  • Mixed syntaxes within a single header value
$ python -m pytest tests/unit/test_vscode_adapter.py tests/unit/test_copilot_adapter.py tests/unit/test_gemini_mcp.py tests/unit/test_codex_runtime.py tests/unit/test_env_variables.py
115 passed in 0.22s

Full unit suite: 5,943 passed (5 unrelated env-specific failures: python vs python3 PATH check, network-dependent github_downloader tests).

Manual end-to-end verification

Built a sandbox apm.yml with a self-defined HTTP MCP server using bare ${VAR} headers, ran apm install against the local source tree, and inspected the generated .vscode/mcp.json and ~/.copilot/mcp-config.json. Verified across 9 scenarios: original issue repro, mixed syntax (<VAR> + ${VAR} + ${env:VAR} + ${input:...} + GHA template + plain text), unresolvable env vars, idempotency (3 consecutive installs), self-defined stdio env, edge cases ($5.99, $HOME, ${}, ${1}, ${MY-VAR}, JSON-quoted values), pre-existing mcp.json merge (top-level extras preserved), and multiple servers sharing env names.

…eaders

Self-defined MCP servers using bare ${VARNAME} or ${env:VARNAME} in headers had
those references written verbatim into .vscode/mcp.json and ~/.copilot/mcp-config.json,
so the literal placeholder string was sent as the header value at runtime.

VS Code mcp.json natively resolves ${env:VAR} and ${input:VAR} but not bare ${VAR},
so the VSCode adapter now translates ${VAR} -> ${env:VAR} before writing. Copilot
mcp-config.json has no native interpolation, so its existing _resolve_env_variable
method (previously matching only legacy <VAR> syntax) now also recognizes ${VAR}
and ${env:VAR}, with single-pass substitution preserving the original "resolve
exactly once" semantics. Gemini inherits the same fix via CopilotClientAdapter.

Fixes microsoft#944
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.

1 participant