Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Fixed

- `apm install --target claude` keeps `${VAR}` and `${VAR:-default}` references in the project's `.mcp.json` (`command`, `args`, `env`, `url`, `headers`) instead of prompting for and writing the real values, and reports missing variables by name for every server. Reinstall replaces values an earlier release wrote when APM can confirm it wrote them; otherwise it keeps the entry and names the fields to fix. User scope (`~/.claude.json`) is unchanged. The new MCP variable compatibility reference records every harness. -- by @Aaryan-Dadu (closes #3252)

## [0.35.0] - 2026-10-22

### Added
Expand Down
1 change: 1 addition & 0 deletions docs/astro.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -280,6 +280,7 @@ export default defineConfig({
{ label: 'Lockfile spec', slug: 'reference/lockfile-spec' },
{ label: 'Policy schema', slug: 'reference/policy-schema' },
{ label: 'Targets matrix', slug: 'reference/targets-matrix' },
{ label: 'MCP variable compatibility', slug: 'reference/mcp-variable-compatibility' },
{ label: 'Primitive types', slug: 'reference/primitive-types' },
{ label: 'Package types', slug: 'reference/package-types' },
{ label: 'Baseline checks', slug: 'reference/baseline-checks' },
Expand Down
15 changes: 13 additions & 2 deletions docs/src/content/docs/consumer/install-mcp-servers.md
Original file line number Diff line number Diff line change
Expand Up @@ -428,8 +428,9 @@ credential value is not written into the generated config. Literal-only
targets keep their existing automatic-token behavior. If none of the
listed variables is set, no automatic header is added.

Reinstalling the same declaration does not automatically repair credentials
already written to runtime config. Follow
Except for Claude Code's project `.mcp.json` (see below), reinstalling the
same declaration does not automatically repair credentials already written to
runtime config. Follow
[Repairing existing credentials](#repairing-existing-credentials) to preserve
custom settings and rotate exposed credentials.
For other authenticated remote servers, set headers explicitly with
Expand Down Expand Up @@ -467,6 +468,16 @@ and make the variable available to Cursor. Keep all other fields and servers,
and inspect the diff. An ordinary reinstall of the same declaration does not
perform this repair.

For Claude Code at project scope, the next `apm install` replaces values in
`.mcp.json` that APM wrote earlier, either because APM recorded writing that
exact entry or because the entry matches the declaration expanded from the
current environment. It prints the affected variable names. An entry APM
cannot confirm it wrote is kept, and install reports which fields hold values.
Replace those yourself with `${NAME}` references, for example
`Bearer ${GITHUB_TOKEN}`. Export the variables before starting `claude`.
Claude Code still asks you to approve project servers; APM does not change
that approval.

If you instead regenerate an entry, save its custom fields first, remove only
that server entry, reinstall, and restore those fields without restoring the
old credential. Do not delete the whole config file. Rotate any credential
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -115,11 +115,12 @@ shipped. Do not embed tokens. Two patterns work:
```

Headers and env values are never shell-expanded by APM. For harnesses
that support runtime env placeholders (for example VS Code and Kiro),
APM preserves the placeholder so the harness resolves it when the
server starts or the request is made. For harnesses that require
literal values (for example Claude Code and Codex self-defined stdio
env), APM resolves `${VAR}` from the install process environment,
that support runtime env placeholders (for example VS Code, Kiro, and
Claude Code's project `.mcp.json`), APM preserves the placeholder so the
harness resolves it when the server starts or the request is made. For
harnesses that require literal values (for example Codex self-defined
stdio env) and for Claude Code's user-scope `~/.claude.json`, APM
resolves `${VAR}` from the install process environment,
prompts interactively when the terminal is attached, and leaves
unresolved placeholders unchanged in non-interactive installs. Keep the
real secret in the consumer's environment (or their secret manager).
Expand Down
1 change: 1 addition & 0 deletions docs/src/content/docs/reference/manifest-schema.md
Original file line number Diff line number Diff line change
Expand Up @@ -756,6 +756,7 @@ configurations.
- **JetBrains Copilot** has native `${env:VAR}` interpolation in `mcp.json`; APM normalizes `${VAR}` and legacy `<VAR>` to `${env:VAR}`.
- **Copilot CLI and Kiro** have native `${VAR}` interpolation in their MCP config files; APM normalizes `${env:VAR}` and legacy `<VAR>` to `${VAR}`.
- **Codex and Gemini** have no runtime interpolation for `env`, so APM resolves `${VAR}`, `${env:VAR}`, and the legacy `<VAR>` there at install time using `os.environ` (or an interactive prompt when missing). Resolved values are not re-scanned, so a value containing literal `${...}` text is preserved.
- **Claude Code** expands `${VAR}` and `${VAR:-default}` in `command`, `args`, `env`, `url` and `headers` of the project-scoped `.mcp.json` when it starts a server. An unset variable without a default stays as literal text, and `claude mcp list` warns about it. At project scope APM keeps these references, normalizing `${env:VAR}` and legacy `<VAR>` to `${VAR}`, keeps static values as authored, and never writes referenced values into `.mcp.json` or `apm.lock.yaml`. Reinstall replaces values an earlier APM release expanded into `.mcp.json` when APM can confirm it wrote them; otherwise the entry is kept and install names the affected fields. User-scope installs (`-g`, `~/.claude.json`) keep resolving references at install time. See [MCP variable compatibility](../mcp-variable-compatibility/) for every harness.
- **Cursor** resolves `${env:VAR}` references at runtime. APM translates `${VAR}` and legacy `<VAR>` into that native form in `env`, `args`, and `headers`, so referenced values are not written into the project-local Cursor config. This normalization does not cover `command` or `url`; their existing behavior is unchanged.
- **Codex `headers` are the exception.** Codex does read a remote server's headers from the environment, through dedicated fields rather than from inside `http_headers`, which it documents as static values. APM writes `Authorization: "Bearer ${VAR}"` as `bearer_token_env_var` and a value that is exactly `${VAR}` as `env_http_headers`; Codex resolves both at server start, so no token is written to `config.toml`. A value Codex cannot express that way -- literal text around the reference, or a shell-style default such as `${VAR:-}` -- is skipped with a warning instead of being written as static text.
- **OpenCode** translates `${VAR}`, `${env:VAR}`, and legacy `<VAR>` to native `{env:VAR}` in remote `headers`, local `environment` (manifest `env`), and command arguments. Already-native references, authored static values, command executables, and URLs stay unchanged. Registry `env` overlays preserve authored values only for declared, emitted keys. Export referenced variables when launching OpenCode: APM writes references, not their resolved values. Keep secrets out of authored static values. See [OpenCode config substitution](https://opencode.ai/docs/config/#env-vars).
Expand Down
98 changes: 98 additions & 0 deletions docs/src/content/docs/reference/mcp-variable-compatibility.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
---
title: MCP variable compatibility
description: How each supported harness reads environment references in MCP configuration, what APM writes for it, and the gaps that remain.
sidebar:
order: 7
---

`apm.yml` can reference environment variables in MCP `env`, `args`, `command`,
`url` and `headers` (see [Variable references](../manifest-schema/#424-variable-references-in-headers-and-env)).
Every harness reads references differently, so APM renders them per harness.
This page records, for each supported harness, the native contract, what APM
writes, and the gaps that remain.

The results were checked on 2026-10-24 with the change for
[#3252](https://github.com/microsoft/apm/issues/3252). Evidence is marked as:

- **client**: checked against the native client.
- **docs**: taken from the harness's official documentation only.
- **unverified**: the documentation does not say and no client was available.

A harness that is only documented or unverified is not counted as passing.

## Native contract

| Harness (client checked) | APM writes to | Native syntax and fields | `${VAR:-default}` | Unset, no default | Approval before servers run | Evidence |
|---|---|---|---|---|---|---|
| Claude Code (2.1.310) | `.mcp.json` (project), `~/.claude.json` (user) | `${VAR}` in `command`, `args`, `env`, `url`, `headers` | Supported | Left as literal text; `claude mcp list` warns | Project servers need workspace trust and approval; user servers do not | client |
| GitHub Copilot CLI (1.0.44) | `.github/mcp.json` (project), `~/.copilot/mcp-config.json` (user) | `${NAME}` in `command`, `args`, `env`, header values (portable format) | Supported (portable format) | Not documented | Project servers load only after folder trust | docs; client for file locations |
| VS Code | `.vscode/mcp.json` (project) | `${env:NAME}`, `${input:id}` in `env`, `envFile`, `cwd`, `headers` | Only in the portable format | Not documented | Workspace servers follow Workspace Trust | docs |
| Cursor | `.cursor/mcp.json` (project), `~/.cursor/mcp.json` (user) | `${env:NAME}` in `command`, `args`, `env`, `url`, `headers` | Not documented | Not documented | Tool calls ask for approval by default | docs |
| Codex CLI | `.codex/config.toml` (project), `~/.codex/config.toml` (user) | No interpolation. `env_vars` forwards variables; `bearer_token_env_var` and `env_http_headers` name variables for HTTP | Not applicable | Not applicable | Project config only in trusted projects | docs |
| Gemini CLI | `.gemini/settings.json` (project), `~/.gemini/settings.json` (user) | `$VAR` or `${VAR}` in `env` | Not documented | Empty string | stdio servers connect only in trusted folders | docs |
| Antigravity | `.agents/mcp_config.json` (project), `~/.gemini/config/mcp_config.json` (user) | No interpolation documented; `env` is literal | Not documented | Not documented | Tool calls run in Ask mode by default | docs |
| Hermes Agent | `~/.hermes/config.yaml` (user) | `${VAR}` documented for catalog entries (`command`, `args`, `url`, `headers`); `config.yaml` entries not documented | Not documented | Not documented | `enabled: false` skips a server | unverified |
| OpenCode | `opencode.json` (project), `~/.config/opencode/opencode.json` (user) | `{env:VAR}` in config values | Not documented | Empty string | Not documented | docs |
| Windsurf | `~/.codeium/windsurf/mcp_config.json` (user) | `${env:VAR}` in `command`, `args`, `env`, `serverUrl`, `url`, `headers` | Not documented | Empty string | Team allowlists only | docs (Cascade documentation) |
| Kiro | `.kiro/settings/mcp.json` (project), `~/.kiro/settings/mcp.json` (user) | `${VAR}` (documented in `env` and `headers`) | Not documented | Not documented | Only variables listed in "Mcp Approved Env Vars" are expanded | docs |
| JetBrains Copilot | `$XDG_CONFIG_HOME/github-copilot/intellij/mcp.json` (user) | Not documented | Not documented | Not documented | Not documented | unverified |

## What APM writes

For one self-defined stdio server and one self-defined HTTP server that use a
set variable (`${DEMO_TOKEN}`), a defaulted one (`${DEMO_UNSET:-fallback}`),
an unset one (`${DEMO_MISSING}`) and a static value (`debug`):

| Harness, scope | Set reference | Defaulted reference | Static value | Value written to the file? |
|---|---|---|---|---|
| Claude Code, project | `${DEMO_TOKEN}` in every field | Kept | Kept | No |
| Claude Code, user | `env`, `headers`: resolved at install time (unchanged); `command`, `args`, `url` copied | Kept | Kept | Yes (file is `0o600`, outside the repo) |
| Copilot CLI, project and user | `${DEMO_TOKEN}` | `env`: replaced by `${KEY}`; other fields kept | `env`: replaced by `${KEY}` | No |
| VS Code, project | `env`, `headers`: `${env:DEMO_TOKEN}`; `args`: bare `${DEMO_TOKEN}` | Kept | Kept | No |
| Cursor, project and user | `${env:DEMO_TOKEN}` in `env`, `args`, `headers`; `command` and `url` copied | Kept | Kept | No |
| Codex CLI, project and user | `env`: resolved; `Authorization`: `bearer_token_env_var` | Kept | Kept | Yes |
| Gemini CLI, project and user | Resolved at install time | Kept | Kept | Yes |
| Antigravity, project and user | Resolved at install time | Kept | Kept | Yes |
| Hermes Agent, user | Resolved at install time | Kept | Kept | Yes |
| OpenCode, project and user | `{env:DEMO_TOKEN}` in `env`, `command`, `headers`; `url` copied | Kept as `${...}` | Kept | No |
| Windsurf, user | Resolved at install time | Kept | Kept | Yes |
| Kiro, project and user | `${DEMO_TOKEN}` | `env`: replaced by `${KEY}` | `env`: replaced by `${KEY}` | No |
| JetBrains Copilot, user | `${env:DEMO_TOKEN}` | `env`: replaced by `${env:KEY}` | `env`: replaced by `${env:KEY}` | No |

## Known gaps

These existed before #3252 and are recorded here rather than changed by it:

- **Codex CLI, Gemini CLI, Antigravity, Hermes Agent and Windsurf** write the
resolved value into their configuration.
- **Claude Code user scope** resolves at install time although Claude Code
expands references in `~/.claude.json` too.
- **Targets that resolve at install time** (Claude Code user scope, Codex CLI,
Gemini CLI, Antigravity, Hermes Agent, Windsurf) resolve `env` and `headers`
but copy references in `command`, `args` and `url` unchanged, including
spellings such as `${env:VAR}` that the harness may not read.
- **Cursor and OpenCode** copy references in `url` (and Cursor in `command`)
unchanged.
- **Copilot CLI, Kiro and JetBrains Copilot** replace static `env` values and
defaulted `env` references with a reference named after the key.
- **VS Code** leaves bare `${VAR}` in `args`, which the VS Code format does not
expand.
- **Cursor, VS Code, OpenCode, Codex, Gemini CLI, Antigravity, Hermes Agent and
Windsurf** copy `${VAR:-default}` unchanged, although their documented syntax
has no defaults.
- **Copilot CLI 1.0.44** reads project servers from `.mcp.json` only, not the
`.github/mcp.json` that APM writes. Current GitHub documentation lists both.
- **Every harness**: an MCP `url` must start with a literal `http://` or
`https://`, so the scheme itself cannot be a reference.

## How this was checked

- APM's output: the same declarations were installed for every harness and
scope with the installed CLI in an isolated home, using synthetic values,
and each generated file and the CLI output were searched for those values.
- Claude Code: a project `.mcp.json` and a user `~/.claude.json` using
`${VAR}`, `${VAR:-default}` and an unset variable in every field were loaded
by Claude Code 2.1.310 in an isolated configuration directory. A recording
stdio command and a local HTTP endpoint captured the expanded values.
- Copilot CLI: `copilot mcp list` (1.0.44) in an isolated home showed which
project files it reads.
7 changes: 6 additions & 1 deletion packages/apm-guide/.apm/skills/apm-usage/dependencies.md
Original file line number Diff line number Diff line change
Expand Up @@ -577,7 +577,12 @@ dependencies:
# Kiro: preserved as ${VAR} and resolved at runtime.
# Cursor: translated to ${env:VAR} and resolved at runtime.
# OpenCode: translated to {env:VAR}; export variables at runtime.
# Windsurf/Claude/Gemini: resolved at install time.
# Claude Code project .mcp.json: kept as ${VAR}
# (${VAR:-default} too) in command, args, env,
# url and headers; Claude Code expands them.
# Reinstall replaces values APM wrote earlier.
# Claude user scope (-g)/Windsurf/Gemini:
# resolved at install time.
# Codex: env resolved at install time; a remote
# server's headers are written as
# bearer_token_env_var / env_http_headers and
Expand Down
14 changes: 14 additions & 0 deletions src/apm_cli/adapters/client/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -391,6 +391,20 @@ def matches_managed_server_config(self, native: object, expected: dict) -> bool:
"""Compare the native connection with the adapter's rendered baseline."""
return native == expected

def matches_legacy_managed_config(
self, native: object, expected: dict, *, native_hash: str | None = None
) -> bool:
"""Whether *native* is an older APM rendering of *expected*.

Adapters whose native representation changed override this with the
evidence that proves an existing entry was APM-generated.
"""
return False

def native_conflict_guidance(self, name: str, expected: dict) -> str | None:
"""Extra target-specific guidance for a preserved conflicting entry."""
return None

def managed_server_config(self, native: dict) -> dict:
"""Return connection data for an actual-write fingerprint."""
return dict(native)
Expand Down
Loading