Skip to content
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Fixed

- MCP server installation now respects the `targets:` whitelist exactly like `apm install`: drop a non-listed runtime even when its `.cursor/`, `.codex/`, or other on-disk signal exists. Previously the MCP install path called `active_targets()` reading the singular `target:` key only, so projects whitelisting `targets: [copilot]` could still receive `~/.codex/config.toml` and `.cursor/mcp.json` writes from foreign signals. The fix audits both paths: (a) the call site at `local_bundle_handler.py` now forwards the canonical plural list; (b) the gate now delegates to the same `resolve_targets` resolver that backs `apm install` skills, so a malformed `targets:` field (conflicting `target:` + `targets:`, `targets: []`, or unknown target name) fails closed with the same `[x]` red error voice + remediation block. The same delegation closes a related asymmetry: a greenfield project (no `targets:`, no `--target` flag, no detected signals) used to silently fall back to `[copilot]` for MCP-only invocations, while `apm install` raised `NoHarnessError` on the same input -- both surfaces now error consistently. Drop lines now use the `[i] Skipped MCP config for X (active targets: Y)` format mirroring the canonical `Targets: X (source: Y)` provenance line. The `-g`/`--global` carve-out is unchanged: `apm install -g --mcp NAME` writes to user-scope (`~/.config/...`, `~/.codex/`, etc.) bypassing the project-scope gate by design. (#1335)
- Gemini CLI: `apm install -g --mcp NAME` now correctly writes to `~/.gemini/settings.json` (user scope) and `apm install` from outside the target project writes to `<project_root>/.gemini/settings.json` instead of `cwd`. Previously `--global` had no effect on Gemini and project-scope writes silently landed in the wrong directory. The matching opt-in gate and cleanup paths in `MCPIntegrator` are aligned in the same change. (#1299)
- `apm install --target claude` now preserves self-defined stdio MCP `env` values from `apm.yml` and writes non-string values such as `PORT: 3000` and `DEBUG: false` as MCP-compatible strings. (#1222)
- Non-skill integrators (agent, instruction, prompt, command, hook script-copy) silently adopt byte-identical pre-existing files so a degraded `deployed_files=[]` lockfile no longer permanently blocks installs gated by `required-packages-deployed`. (#1313)
Expand Down
53 changes: 40 additions & 13 deletions docs/src/content/docs/consumer/install-mcp-servers.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,19 +86,46 @@ writes a runtime-specific MCP config file. The schemas differ; the
| OpenCode | `opencode.json` | project (only if `.opencode/` exists) | JSON `mcp` |
| Windsurf | `~/.codeium/windsurf/mcp_config.json` | global | JSON `mcpServers` |

Cursor, Gemini, and OpenCode are opt-in by directory in project scope:
APM only writes their config when the corresponding `.cursor/`,
`.gemini/`, or `.opencode/` directory already exists in the project.
This avoids creating runtime artifacts for tools you do not use.
Gemini's user-scope path (`~/.gemini/settings.json`, selected with
`-g`) is unconditional and creates `~/.gemini/` if needed.

`apm install -g --mcp NAME` routes the write to each runtime's
user-scope MCP config when that runtime supports user scope -- for
example Copilot CLI writes to `~/.copilot/mcp-config.json`, Codex
CLI to `~/.codex/config.toml`, and Gemini CLI to
`~/.gemini/settings.json`. Workspace-only runtimes (VS Code, Cursor,
OpenCode) are skipped at user scope.
## How `targets:` gates which configs get written

MCP install honors the same target resolution chain as `apm install`
for any other dependency: see
[Where files land](../install-packages/#where-files-land).
In short: `--target` wins, then `apm.yml`'s `targets:`, then
auto-detect from harness directories.

When a runtime is outside the active target set, APM does NOT write
its MCP config -- and announces the drop on stdout so you can confirm
the gate took effect:

```text
[i] Skipped MCP config for claude, codex (active targets: copilot)
```

This single rule replaces two older ones that used to coexist:

- A "directory opt-in" carve-out for Cursor / Gemini / OpenCode -- now
redundant, because `targets:` (or auto-detection) drives the gate
for those runtimes too.
- The pre-#1335 silent skip path, which dropped non-listed runtimes
without telling you.

A malformed `targets:` field (both `target:` and `targets:` set,
`targets: []`, or an unknown target name) fails closed: no MCP files
are written and an `[x]` error names the field to fix. A greenfield
project with no `targets:`, no `--target` flag, AND no detected
signals (`.github/copilot-instructions.md`, `.cursor/`, etc.) also
fails closed with the same `[x]` voice -- consistent with how
`apm install` treats the same input. Pin a target with `--target` or
declare one in `apm.yml`. (#1335)

`apm install -g --mcp NAME` is a deliberate carve-out: it routes the
write to each runtime's user-scope MCP config (Copilot CLI to
`~/.copilot/mcp-config.json`, Codex CLI to `~/.codex/config.toml`,
Gemini CLI to `~/.gemini/settings.json`) and does not consult the
project-scope `targets:` whitelist -- user-scope writes are by
definition not project-bound. Workspace-only runtimes (VS Code,
Cursor, OpenCode) are skipped at user scope.

## stdio vs HTTP servers

Expand Down
14 changes: 13 additions & 1 deletion docs/src/content/docs/reference/targets-matrix.md
Original file line number Diff line number Diff line change
Expand Up @@ -182,7 +182,19 @@ To restore the pre-convergence per-target layout (skills land under each target'

## MCP servers

MCP is not a `TargetProfile` primitive; it is wired by a separate integrator that writes per-client config files (e.g. `.vscode/mcp.json`, `.cursor/mcp.json`, `.claude.json`) for every target with an MCP client adapter. The matrix above marks `mcp` supported when an adapter exists. See [`apm mcp`](../cli/mcp/) for the runtime surface.
MCP is not a `TargetProfile` primitive; it is wired by a separate
integrator that writes per-client config files (e.g.
`.vscode/mcp.json`, `.cursor/mcp.json`, `.claude.json`) for every
target in the active set that has an MCP client adapter. Active set
follows the same `--target` > `targets:` > auto-detect chain as
`apm install`: a runtime with an adapter but outside the active set
is skipped and APM emits an `[i] Skipped MCP config for X (active
targets: Y)` line so the gate decision is observable. The matrix
above marks `mcp` supported when an adapter exists; whether the
config gets written on a given install is a function of the active
target set, not just adapter availability. See
[Install MCP servers](../../consumer/install-mcp-servers/) for the
gate behavior and [`apm mcp`](../cli/mcp/) for the runtime surface.

## See also

Expand Down
2 changes: 1 addition & 1 deletion packages/apm-guide/.apm/skills/apm-usage/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@

| Command | Purpose | Key flags |
|---------|---------|-----------|
| `apm install [PKGS...]` | Install APM and MCP dependencies (supports APM packages, Claude skills (SKILL.md), and plugin collections (plugin.json)) | `--update` (deprecated; prefer `apm update`) refresh refs, `--force` overwrite (does NOT refresh refs; use `apm update` for that), `--frozen` CI-safe install that fails fast when `apm.lock.yaml` is missing or out of sync with `apm.yml` (mutually exclusive with `--update`; structural presence check only -- use `apm audit` for SHA integrity), `--dry-run`, `--verbose`, `--only [apm\|mcp]`, `--target` (comma-separated, e.g. `--target claude,cursor`; highest-priority entry in the resolution chain `--target` > apm.yml `targets:` > auto-detect; `--target all` deprecated, see `apm compile --all`; use `copilot-cowork` with `--global` after `apm experimental enable copilot-cowork`), `--dev`, `-g` global, `--trust-transitive-mcp`, `--parallel-downloads N`, `--allow-insecure`, `--allow-insecure-host HOSTNAME`, `--skill NAME` install named skill(s) from SKILL_BUNDLE (repeatable; persisted in apm.yml; `'*'` resets to all), `--legacy-skill-paths` restore per-client skill dirs, `--mcp NAME` add MCP entry, `--transport`, `--url`, `--env KEY=VAL`, `--header KEY=VAL`, `--mcp-version`, `--registry URL` custom MCP registry |
| `apm install [PKGS...]` | Install APM and MCP dependencies (supports APM packages, Claude skills (SKILL.md), and plugin collections (plugin.json)) | `--update` (deprecated; prefer `apm update`) refresh refs, `--force` overwrite (does NOT refresh refs; use `apm update` for that), `--frozen` CI-safe install that fails fast when `apm.lock.yaml` is missing or out of sync with `apm.yml` (mutually exclusive with `--update`; structural presence check only -- use `apm audit` for SHA integrity), `--dry-run`, `--verbose`, `--only [apm\|mcp]`, `--target` (comma-separated, e.g. `--target claude,cursor`; highest-priority entry in the resolution chain `--target` > apm.yml `targets:` > auto-detect; `--target all` deprecated, see `apm compile --all`; use `copilot-cowork` with `--global` after `apm experimental enable copilot-cowork`), `--dev`, `-g` global, `--trust-transitive-mcp`, `--parallel-downloads N`, `--allow-insecure`, `--allow-insecure-host HOSTNAME`, `--skill NAME` install named skill(s) from SKILL_BUNDLE (repeatable; persisted in apm.yml; `'*'` resets to all), `--legacy-skill-paths` restore per-client skill dirs, `--mcp NAME` add MCP entry (NAME goes through the same `--target` > `targets:` > auto-detect resolver as APM packages, so a project whitelisting `targets: [copilot]` will not write `.cursor/mcp.json` even if `.cursor/` exists; `apm install -g --mcp NAME` writes user-scope and bypasses the project-scope gate by design), `--transport`, `--url`, `--env KEY=VAL`, `--header KEY=VAL`, `--mcp-version`, `--registry URL` custom MCP registry |
| `apm targets` | Show resolved deployment targets for the current project (Click group; reads filesystem signals; works with or without `apm.yml`) | `--all` also include the `agent-skills` meta-target (only meaningful with `--json`), `--json` machine-readable output. No provenance line is printed (the table is the provenance). |
| `apm uninstall PKGS...` | Remove packages | `--dry-run`, `-g` global |
| `apm prune` | Remove orphaned packages | `--dry-run` |
Expand Down
23 changes: 18 additions & 5 deletions src/apm_cli/commands/install.py
Original file line number Diff line number Diff line change
Expand Up @@ -978,7 +978,12 @@ def _handle_mcp_install(
"mcp_name",
default=None,
metavar="NAME",
help="Add an MCP server entry to apm.yml. Use with --transport, --url, --env, --header, --mcp-version, or post-- stdio command.",
help=(
"Add an MCP server entry to apm.yml. Use with --transport, --url, --env, "
"--header, --mcp-version, or a stdio command after `--`. Resolves active "
"targets the same way `apm install` does (--target > apm.yml targets: > "
"auto-detect); writes only for active targets, skips others with [i]."
),
)
@click.option(
"--transport",
Expand Down Expand Up @@ -1792,10 +1797,18 @@ def _install_apm_packages(ctx, outcome):
# Continue with MCP installation (existing logic)
mcp_count = 0
new_mcp_servers: builtins.set = builtins.set()
mcp_apm_config = {
"target": apm_package.target,
"scripts": apm_package.scripts or {},
}
# Forward only the targets-key the user actually declared so parse_targets_field
# in the gate sees the same dict shape it sees from raw apm.yml. Including a
# `targets: None` placeholder when the user wrote `target:` (singular) would
# falsely trip the conflict-mutex check (see core.apm_yml.parse_targets_field).
# This restores parity with `apm install` for users on the modern `targets:`
# plural form -- without this, `targets:` was silently dropped at the call
# site and the gate fell back to permissive directory detection (#1335).
mcp_apm_config: dict = {"scripts": apm_package.scripts or {}}
if apm_package.targets is not None:
mcp_apm_config["targets"] = apm_package.targets
elif apm_package.target is not None:
mcp_apm_config["target"] = apm_package.target
if should_install_mcp and mcp_deps:
mcp_count = MCPIntegrator.install(
mcp_deps,
Expand Down
14 changes: 7 additions & 7 deletions src/apm_cli/install/local_bundle_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -364,16 +364,16 @@ def _wire_bundle_mcp_servers(

from apm_cli.integration.mcp_integrator import MCPIntegrator

target_csv = ",".join(t.name for t in targets)
apm_config = {"target": target_csv, "scripts": {}}
target_names = [t.name for t in targets]
apm_config = {"targets": target_names, "scripts": {}}
try:
count = MCPIntegrator.install(
deps,
verbose=verbose,
apm_config=apm_config,
project_root=project_root,
user_scope=user_scope,
explicit_target=target_csv,
explicit_target=target_names,
logger=logger,
)
except Exception as exc:
Expand All @@ -385,15 +385,15 @@ def _wire_bundle_mcp_servers(
return 0

if count:
logger.success(
f"Wired {count} MCP server(s) from bundle .mcp.json (target(s): {target_csv})"
)
joined = ", ".join(target_names)
logger.success(f"Wired {count} MCP server(s) from bundle .mcp.json (target(s): {joined})")
elif deps:
# Bundle declared servers but none applied (e.g. resolved targets
# all gated out, or all servers already configured). Emit an info
# line so users have a paper-trail.
joined = ", ".join(target_names)
logger.info(
f"Bundle .mcp.json declared {len(deps)} server(s); "
f"no new MCP config changes for target(s): {target_csv}"
f"no new MCP config changes for target(s): {joined}"
)
return count
Loading
Loading