[fix] translate bare ${VAR} env-var refs in self-defined MCP server headers (#944)#947
Open
edenfunf wants to merge 1 commit intomicrosoft:mainfrom
Open
[fix] translate bare ${VAR} env-var refs in self-defined MCP server headers (#944)#947edenfunf wants to merge 1 commit intomicrosoft:mainfrom
edenfunf wants to merge 1 commit intomicrosoft:mainfrom
Conversation
…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
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.
What
Self-defined MCP servers (
registry: false) that use bare${VARNAME}or${env:VARNAME}inheaderswere 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:
After:
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:
${env:VAR},${input:VAR}) -- APM's job is to emit a correctly-formatted placeholder, not to bake the secret into the file.<VAR>flow.How
Three minimal, additive changes that follow existing adapter patterns:
base.py-- add a shared_ENV_VAR_REregex (matches${VAR}and${env:VAR}, capturesVAR). Designed with negative lookahead-style ordering so it never matches${input:...}(kept disjoint from_INPUT_VAR_RE) and never matches GitHub Actions${{ ... }}templates.vscode.py-- add a static_translate_env_vars_for_vscodehelper (mirrors the existing_extract_input_variablesstatic-helper style) and call it before writing both remoteheadersand self-defined stdioenv. Translation is purely textual and idempotent, so re-runningapm installis safe.copilot.py-- module-level_COPILOT_ENV_RE(alternation of legacy<VAR>and the new${...}patterns) drives a single-passre.subin_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 viaCopilotClientAdapter.Codex is unchanged (it does not handle remote servers, so headers do not apply).
Why these patterns are safe
_ENV_VAR_REis mathematically disjoint from_INPUT_VAR_RE: the optionalenv:group cannot also satisfyinput:.${{ ... }}(GitHub Actions) is not matched: the second{fails the identifier class.${env:VAR}is captured but the substitution rewrites it back to${env:VAR}, so repeat installs are stable.<VAR>legacy syntax,${input:...}input vars, and the existing input-variable warning paths are all untouched.Test
Unit tests
tests/unit/test_vscode_adapter.pyandtests/unit/test_copilot_adapter.pyadd 13 new tests covering:${VAR}translation in headers and stdio env${env:VAR}and${input:...}round-trip preservation${{ ... }}left untouched<VAR>,${VAR},${env:VAR})${input:...}not resolved by Copilot path${OTHER}text is NOT recursively expanded (verified across all three syntaxes via subTest)Full unit suite: 5,943 passed (5 unrelated env-specific failures:
pythonvspython3PATH check, network-dependent github_downloader tests).Manual end-to-end verification
Built a sandbox
apm.ymlwith a self-defined HTTP MCP server using bare${VAR}headers, ranapm installagainst the local source tree, and inspected the generated.vscode/mcp.jsonand~/.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.