fix: respect targets: whitelist for all runtimes during MCP install#1336
fix: respect targets: whitelist for all runtimes during MCP install#1336danielmeppiel wants to merge 8 commits into
Conversation
…1335) Two defects caused the targets: field in apm.yml to be silently ignored during MCP server installation: 1. _gate_project_scoped_runtimes read apm_config.get('target') (singular) instead of using parse_targets_field() which handles both target: and targets: keys. 2. Only codex and claude were in _PROJECT_SCOPED_RUNTIMES; all other runtimes (copilot, vscode, cursor, opencode, gemini, windsurf) were never filtered against the user's whitelist. Fix: when an explicit targets field is present (or --target CLI flag), gate ALL runtimes against the active target set. When no targets field exists, preserve backward-compat by gating only project-scoped runtimes. Adds 10 unit tests covering both singular/plural keys, user_scope bypass, explicit_target override, backward-compat auto-detect, and edge cases. Closes #1335 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
This PR fixes MCP server installation to respect the project's targets:/target: whitelist from apm.yml, aligning MCP runtime selection with the existing target-resolution contract (Fixes #1335).
Changes:
- Updates
MCPIntegrator._gate_project_scoped_runtimes()to useparse_targets_field()and (when targets are explicit) filter all runtimes against the resolved active target set. - Adds unit tests covering plural/singular targets, explicit CLI override, and backward-compatible auto-detect behavior.
- Adds a changelog entry under
## [Unreleased] -> Fixed.
Show a summary per file
| File | Description |
|---|---|
src/apm_cli/integration/mcp_integrator.py |
Implements explicit-target parsing/gating logic for MCP runtime selection. |
tests/unit/integration/test_mcp_integrator.py |
Adds unit tests for _gate_project_scoped_runtimes behavior across target configurations. |
CHANGELOG.md |
Documents the MCP install target-whitelist fix under Unreleased/Fixed. |
Copilot's findings
Comments suppressed due to low confidence (3)
tests/unit/integration/test_mcp_integrator.py:817
- This comment includes a non-ASCII em dash character ("—"). The repo's encoding rules require printable ASCII in Python source; please replace with '-' or similar ASCII punctuation.
def test_no_targets_gates_only_codex_claude(self, mock_at, tmp_path):
# active_targets returns copilot only — codex/claude should be gated
mock_at.side_effect = _fake_active_targets(["copilot"])
src/apm_cli/integration/mcp_integrator.py:980
- This comment line uses a non-ASCII em dash character ("—" in "EmptyTargetsListError — validation"). The repo's encoding rules require printable ASCII in Python source; please replace with '-' or similar ASCII punctuation.
except Exception:
# ConflictingTargetsError / EmptyTargetsListError — validation
# should have caught this earlier; fall through to auto-detect.
src/apm_cli/integration/mcp_integrator.py:1003
- This comment uses a non-ASCII em dash character ("—" in "No explicit targets — backward-compat"). The repo's encoding rules require printable ASCII in Python source; please replace with '-' or similar ASCII punctuation.
# No explicit targets — backward-compat: only gate project-scoped
# runtimes whose directory marker was auto-detected.
gated = [rt for rt in MCPIntegrator._PROJECT_SCOPED_RUNTIMES if rt in target_runtimes]
- Files reviewed: 3/3 changed files
- Comments generated: 7
|
|
||
|
|
||
| # =========================================================================== | ||
| # _gate_project_scoped_runtimes — issue #1335 |
There was a problem hiding this comment.
Fixed in 430bd9f (em dashes replaced with ASCII -- across the file).
|
|
||
| ### Fixed | ||
|
|
||
| - `targets:` (plural) in `apm.yml` is now respected during MCP server installation; previously only the singular `target:` key was read, and only Codex / Claude were gated. All runtimes are now filtered when an explicit whitelist is present. (#1335) |
|
|
||
| @patch("apm_cli.integration.targets.active_targets") | ||
| def test_no_targets_no_project_scoped_returns_all(self, mock_at, tmp_path): | ||
| # No codex/claude in list → nothing to gate, return all |
There was a problem hiding this comment.
Fixed in 430bd9f (right-arrow replaced with ->).
| if apm_config: | ||
| try: | ||
| explicit_from_config = parse_targets_field(apm_config) | ||
| except Exception: | ||
| # ConflictingTargetsError / EmptyTargetsListError — validation | ||
| # should have caught this earlier; fall through to auto-detect. | ||
| _log.debug("parse_targets_field failed; falling back to auto-detect") |
There was a problem hiding this comment.
Stale -- this was already addressed in commit 82fe862 (one commit before your review landed). The except is now narrowed to (ConflictingTargetsError, EmptyTargetsListError) and fails closed (returns [] with a _rich_warning naming the field) rather than falling back to auto-detect. Two new tests assert the fail-closed behavior.
| 1. **Explicit targets** (``targets:`` / ``target:`` in *apm.yml*, or | ||
| the ``--target`` CLI flag): ALL runtimes not in the whitelist are | ||
| dropped. This is the contract users expect – see #1335. | ||
| 2. **Auto-detect** (no ``targets`` field): only project-scoped |
There was a problem hiding this comment.
Fixed in 430bd9f (em/en dashes replaced with ASCII -- across the docstring and inline comments).
| user_scope: bool, | ||
| project_root, | ||
| apm_config: dict | None, | ||
| explicit_target: str | None, | ||
| ) -> list[str]: |
There was a problem hiding this comment.
Fixed in 430bd9f (annotation widened to str | list[str] | None and CSV-string acceptance documented in the docstring).
| # ConflictingTargetsError / EmptyTargetsListError — validation | ||
| # should have caught this earlier; fall through to auto-detect. | ||
| _log.debug("parse_targets_field failed; falling back to auto-detect") | ||
|
|
||
| config_target = explicit_target or explicit_from_config or None | ||
| has_explicit_targets = bool(explicit_target or explicit_from_config) | ||
|
|
There was a problem hiding this comment.
Good catch -- legit regression. Fixed in 430bd9f by normalizing CSV strings to a list before calling active_targets(). Added test_explicit_target_csv_string_normalized as a regression guard.
APM Review Panel:
|
| Persona | B | R | N | Takeaway |
|---|---|---|---|---|
| Python Architect | 0 | 0 | 2 | Clean dual-mode gating with correct falsy-list semantics; no architectural concerns. Ship. |
| CLI Logging Expert | 0 | 1 | 1 | Gated-out runtimes should surface at info/summary level, not debug; silent drops violate progressive-disclosure contract. |
| DevX UX Expert | 0 | 1 | 1 | targets: whitelist semantics align with npm/pip mental models; silent gating warrants an info-level hint in install summary. |
| Supply Chain Security Expert | 0 | 1 | 1 | Tightens MCP config surface correctly; one silent-fallback concern on malformed targets field. |
| OSS Growth Hacker | 0 | 0 | 2 | CHANGELOG entry is crisp and user-facing; minor framing tweak would sharpen the repostable hook for release notes. |
| Doc Writer | 0 | 1 | 1 | CHANGELOG entry is accurate and links #1335; the apm install MCP page still describes only auto-detect/directory gating and needs a sentence on targets: whitelist filtering. |
| Test Coverage Expert | 0 | 1 | 0 | 10 unit tests cover the fix well; one recommended gap: no test exercises the parse_targets_field exception fallback path. |
B = blocking-severity findings, R = recommended, N = nits.
Counts are signal strength, not gates. The maintainer ships.
Top 5 follow-ups
- [Test Coverage Expert] Add test for parse_targets_field exception fallback path in _gate_project_scoped_runtimes -- Missing regression trap on a critical-promise surface (install gating). If the except clause is narrowed or removed, no test fails. Evidence: outcome=missing on the degradation contract.
- [Supply Chain Security Expert] Narrow except to known exceptions and consider fail-closed (return []) on unparseable targets -- Malformed manifest currently widens write surface via silent fallback to auto-detect; fail-closed is the safer default on a declared-intent surface.
- [CLI Logging Expert] Promote explicit-whitelist gating message from debug to info/_rich_info -- User declared targets: and install silently skipped runtimes with no feedback. Violates progressive-disclosure contract; user should see confirmation their whitelist took effect.
- [Doc Writer] Update docs/src/content/docs/consumer/install-mcp-servers.md with targets: whitelist semantics -- Canonical MCP install page still describes only auto-detect gating; the new whitelist behavior is a user-visible contract change that needs documentation before next release.
- [OSS Growth Hacker] Polish CHANGELOG entry to lead with user promise rather than yaml key name -- Growth-optimized phrasing improves release-note repostability and reinforces the 'manifest as truth' narrative.
Architecture
classDiagram
direction LR
class MCPIntegrator {
<<Namespace>>
+_PROJECT_SCOPED_RUNTIMES tuple
+_gate_project_scoped_runtimes(target_runtimes, ...) list~str~
}
class parse_targets_field {
<<Pure Function>>
+__call__(yaml_data dict) list~str~
}
class active_targets {
<<IOBoundary>>
+__call__(root, explicit_target) list~TargetProfile~
}
class TargetProfile {
<<ValueObject>>
+name str
+root_dir str
+auto_create bool
}
MCPIntegrator ..> parse_targets_field : calls (lazy import)
MCPIntegrator ..> active_targets : calls (lazy import)
active_targets ..> TargetProfile : returns list of
class MCPIntegrator:::touched
classDef touched fill:#fff3b0,stroke:#d47600
flowchart TD
A["_gate_project_scoped_runtimes(target_runtimes, ...)"] --> B{user_scope?}
B -- Yes --> C[return target_runtimes unchanged]
B -- No --> D["parse_targets_field(apm_config)"]
D --> E{has_explicit_targets?}
E -- Yes --> F["active_targets(root, config_target)"]
F --> G["out = [rt for rt in target_runtimes if rt in active]"]
G --> H{dropped any?}
H -- Yes --> I["[I/O] _log.debug gated-out list"]
I --> J[return out]
H -- No --> J
E -- No --> K{"any _PROJECT_SCOPED_RUNTIMES in target_runtimes?"}
K -- No --> L[return target_runtimes unchanged]
K -- Yes --> M["active_targets(root, None)"]
M --> N{"gated rt in active?"}
N -- Yes --> O[keep rt]
N -- No --> P["[I/O] _log.debug rt gated out"]
P --> Q[remove rt from out]
O --> R[return out]
Q --> R
Recommendation
Merge as-is -- the fix correctly closes #1335, architecture is clean, and 10 tests prove the happy path. Track the exception-path test + fail-closed narrowing as immediate follow-ups (same sprint); the info-level logging and doc update should land before the next release cut. None of these block the bug fix from reaching users.
Full per-persona findings
Python Architect
- [nit] Bare
except Exceptionswallows unexpected errors from parse_targets_field atsrc/apm_cli/integration/mcp_integrator.py:978
The catch at line 978 catches ConflictingTargetsError and EmptyTargetsListError but also any unexpected bug (KeyError, TypeError, AttributeError). Narrowing to the two known exceptions makes debugging easier if parse_targets_field evolves.
Suggested:except (ConflictingTargetsError, EmptyTargetsListError): - [nit] config_target relies on empty-list falsiness -- a brief comment would aid future readers at
src/apm_cli/integration/mcp_integrator.py:983
The expressionexplicit_target or explicit_from_config or Nonesilently falls through when explicit_from_config is [] (returned by parse_targets_field for 'neither key present'). Correct Python but intent is non-obvious.
CLI Logging Expert
- [recommended] Targets-whitelist gating emits debug-only; user gets no explanation for skipped runtimes at
src/apm_cli/integration/mcp_integrator.py:994
Traffic-light rule: info (blue) is for things the user 'should know'. Whentargets: [cursor]and install of MCP for vscode+cursor+windsurf drops 2 of 3 silently, this violates 'lead with the outcome' and 'name the thing'. Debug is for verbose; gating is a user-visible behavioral outcome that passes 'So What?' (user can edit targets or use --target). The NEW explicit-whitelist path (line 994) is the user's own declaration taking effect -- they deserve confirmation.
Suggested: Promote to_rich_infoor logger.info at default verbosity:[i] Skipped MCP config for vscode, windsurf -- not in targets whitelist. Or surface via DiagnosticCollector in the install summary block. - [nit] Debug message on parse_targets_field exception swallows context at
src/apm_cli/integration/mcp_integrator.py:981
Line 981 logs without exc_info=True. For verbose/agent consumers, the actual exception type is valuable diagnostic signal. Other exception-path debugs in this file (line 937) use exc_info=True.
Suggested:_log.debug("parse_targets_field failed; falling back to auto-detect", exc_info=True)
DevX UX Expert
- [recommended] Gated runtimes are only logged at DEBUG; user gets no feedback when targets: silently drops harnesses at
src/apm_cli/integration/mcp_integrator.py:988
Package-manager mental model (Rule 4 - Recovery): when a user sets targets: [claude] and runsapm install, they should see confirmation that the whitelist was honored. Without this, a user migrating from old behavior will not understand why their copilot config stopped updating. npm logs which workspace it installs into; pip --target echoes the path.
Suggested: Promote the 'Targets whitelist gated out' message from _log.debug to a user-facing info line on the happy path. - [nit] CHANGELOG entry could note the backward-compat split more explicitly at
CHANGELOG.md
Entry says 'All runtimes are now filtered when an explicit whitelist is present' but does not say 'repos without a targets: field keep the previous auto-detect behavior'.
Suggested: Append: 'Repos without a targets: field retain previous auto-detect semantics (no behavior change).'
Supply Chain Security Expert
- [recommended] Bare except on parse_targets_field silently widens write surface on malformed manifest at
src/apm_cli/integration/mcp_integrator.py:978
If apm.yml contains a malformed targets field, parse_targets_field raises, the except catches it, has_explicit_targets stays false, and the code falls through to auto-detect -- writing MCP config to MORE runtimes than intended. Violates fail-closed: when the user declared targets but the declaration is unparseable, the safe default is to write NOTHING (empty list), not to fall back to permissive auto-detect.
Suggested:except (ConflictingTargetsError, EmptyTargetsListError): _log.warning('...'); return [] - [nit] user_scope bypass is undocumented from a security-contract perspective at
src/apm_cli/integration/mcp_integrator.py:967
user_scope=True short-circuits all gating. Likely correct (user-global config is the user's own scope), but security model doc does not state this. A one-line in-code comment would prevent future reviewers from questioning it.
Suggested: Add:# Security note: user-scope writes target ~/.config paths the user owns globally; gating applies only to project-scoped writes that could affect shared repos.
OSS Growth Hacker
- [nit] CHANGELOG entry buries the user promise behind implementation detail at
CHANGELOG.md:12
Entry leads with the yaml key name. Growth-optimized phrasing leads with the user promise.
Suggested: Reword: 'MCP server installation now respects the explicittargets:whitelist for all runtimes; previously only Codex and Claude Code were gated, and only when the singulartarget:key was used. ([BUG]targets:in apm.yml is not respected for MCP server installation #1335)' - [nit] Test class is exemplary contribution-pattern material worth calling out in release notes at
tests/unit/integration/test_mcp_integrator.py:734
TestGateProjectScopedRuntimes is a textbook regression-trap suite. Highlighting models the contribution bar.
Auth Expert -- inactive
No auth fast-path file touched; change is a runtime-whitelist gate in MCPIntegrator with no impact on token resolution, host classification, or credential flows.
Doc Writer
- [recommended] docs/src/content/docs/consumer/install-mcp-servers.md does not document the new
targets:whitelist gating for MCP install. atdocs/src/content/docs/consumer/install-mcp-servers.md:89
The 'Whatapm installwrites to disk' section tells the reader APM writes per-runtime MCP config 'For every harness APM detects'. After this PR, an explicittargets:/target:field in apm.yml (or--targeton the CLI) is also a hard whitelist for ALL runtimes during MCP install. That is a user-visible contract change (the explicit reason [BUG]targets:in apm.yml is not respected for MCP server installation #1335 was filed). Page is the canonical source for MCP install behavior under the 'state once, reference elsewhere' rule.
Suggested: After the 'Cursor, Gemini, and OpenCode are opt-in by directory' paragraph, add: 'Whenapm.ymldeclarestargets:(or--targetis passed), MCP install treats that list as a hard whitelist -- runtimes outside the list are skipped even if their harness directory is present. With notargets:field, only project-scoped runtimes (Codex, Claude Code) are gated for backward compatibility ([BUG]targets:in apm.yml is not respected for MCP server installation #1335).' - [nit] CHANGELOG entry could name the user-facing symptom alongside the fix. at
CHANGELOG.md:12
As polish, a one-clause symptom ('e.g.targets: [copilot]no longer leaks an MCP write to~/.codex/config.toml') would let readers self-diagnose without opening the issue.
Test Coverage Expert
- [recommended] No test exercises
parse_targets_fieldexception fallback in_gate_project_scoped_runtimesattests/unit/integration/test_mcp_integrator.py
Lines 974-977 catch any exception fromparse_targets_fieldand degrade to auto-detect. No test in TestGateProjectScopedRuntimes supplies a config dict triggering this path (e.g.,{'target':'claude','targets':['copilot']}raising ConflictingTargetsError). Verified via grep on tests/unit/integration/test_mcp_integrator.py for ConflictingTargetsError/EmptyTargetsListError/exception/fallback -- only hit is unrelated test_plain_string_fallback. The exception tests in test_target_resolution_v2.py cover parse_targets_field in isolation but not the degrade-to-auto-detect behavior in the MCP gate. If the except clause is accidentally deleted or narrowed, no test fails.
Suggested: Add test_conflicting_targets_falls_back_to_auto_detect that patches parse_targets_field to raise ConflictingTargetsError and asserts the method returns the legacy auto-detect result.
Proof (passed):tests/unit/integration/test_mcp_integrator.py::TestGateProjectScopedRuntimes::test_targets_plural_filters_unlisted_runtimes-- proves: targets: [claude] in apm.yml gates all unlisted runtimes out of MCP install
Proof (missing at):::TestGateProjectScopedRuntimes::test_conflicting_targets_falls_back_to_auto_detect-- proves: When parse_targets_field raises, install degrades gracefully to auto-detect rather than crashing
This panel is advisory. It does not block merge. Re-apply the panel-review label after addressing feedback to re-run.
… gating
Implements all five panel follow-ups in-PR rather than deferring:
1. Supply-chain + py-architect: narrow except to (ConflictingTargetsError,
EmptyTargetsListError) and fail closed with _rich_warning + return [].
Malformed apm.yml targets no longer silently widen the MCP write surface
via auto-detect fallback.
2. Test-coverage: two new tests cover the fail-closed path
(test_conflicting_targets_field_fails_closed, test_empty_targets_list_fails_closed).
12/12 TestGateProjectScopedRuntimes pass.
3. CLI-logging + DevX UX: promote whitelist gating from _log.debug to
_rich_info so users see confirmation that their declared targets:
filter took effect ("Targets whitelist active: skipped MCP config for X").
The auto-detect debug log on the backward-compat path is unchanged.
4. Doc-writer: docs/consumer/install-mcp-servers.md now documents the
targets: whitelist semantics and fail-closed behavior in the
canonical 'What apm install writes to disk' section.
5. OSS growth: CHANGELOG entry rewritten to lead with the user promise
('MCP server installation now respects the explicit targets: whitelist
for all runtimes'), name the user-facing symptom, and call out the
backward-compat split + fail-closed semantics.
Plus the supply-chain user_scope security comment and the py-architect
falsy-list rationale comment on config_target.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Triage of 7 Copilot comments:
LEGIT and applied:
- Replace non-ASCII em/en dashes and right-arrows in mcp_integrator.py
and test_mcp_integrator.py with ASCII equivalents (encoding rule).
- Update explicit_target type hint from `str | None` to
`str | list[str] | None`; document CSV-string acceptance via the
_wire_bundle_mcp_servers caller.
- Normalize CSV `explicit_target` ("claude,copilot") to a list
before calling active_targets(), which would otherwise treat the CSV
as one unknown token and drop every runtime. Adds
test_explicit_target_csv_string_normalized as regression guard.
Already addressed in 82fe862 (no-op):
- Broad `except Exception` was already narrowed to
(ConflictingTargetsError, EmptyTargetsListError) with fail-closed
semantics in the prior follow-up commit; comment was stale.
Rejected:
- Suggestion to reference PR # in CHANGELOG: declined. Every recent
CHANGELOG entry references the issue # (#1313, #1289, #1222, #1299,
etc.) -- this is the established repo convention.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Drop the codex/claude backward-compat special case. The gate now mirrors active_targets() resolution (explicit > directory detection > [copilot] fallback) for every runtime, exactly the same model 'apm install' uses for skills, agents, and instructions. A user running 'apm install' on a project with no .codex/ directory no longer leaks an MCP write to ~/.codex/config.toml -- no matter whether 'targets:' is declared. Drops _PROJECT_SCOPED_RUNTIMES constant. Aliases the 'vscode' runtime to the 'copilot' canonical target inside the gate (mirrors the alias active_targets honors for explicit_target). Updates docs, CHANGELOG, and the two backward-compat-specific tests. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
|
Pushed 3a5bc4e -- per maintainer guidance, the MCP gate now mirrors the What changed
Behavior delta vs main
The last row is the one user-visible behavior shift. It's the same contract Verification: |
Paired CLI-logging + DevX-UX advisory review caught five UX
divergences between MCP install gating and the canonical
'apm install' targets engine. The most critical was a hidden
call-site bypass that silently broke the PR's own promise.
Audit-caught fix (B1, devx-ux blocking)
commands/install.py:1795 built mcp_apm_config from APMPackage.target
(singular legacy field) only. APMPackage.from_apm_yml never read
'targets:' plural, so for any user on the canonical syntax
apm_package.target = None, the gate saw an empty config dict, and
fell back to permissive directory detection. The whitelist was
silently bypassed.
- APMPackage now exposes a 'targets: list[str] | None' field and
parses it in from_apm_yml.
- The call site builds mcp_apm_config conditionally, forwarding only
the key the user actually declared (avoids tripping the gate's
target/targets mutex check with a None placeholder).
- Regression test test_apm_package_targets_plural_forwards_through_call_site
locks in the full apm.yml -> APMPackage -> mcp_apm_config path.
CLI-logging UX parity (B1+B2+B3)
- Malformed-targets branch now emits two _rich_error lines (red [x])
instead of a single _rich_warning that glued the multi-line
EmptyTargetsListError body mid-sentence into a yellow [!]. Mirrors
install/phases/targets.py:213's voice. No SystemExit because the
gate runs mid-bundle in local_bundle_handler callers.
- Drop branch now passes symbol='info' explicitly and includes
'(active targets: ...)' in the same ' ' double-space shape as
format_provenance's canonical 'Targets: X (source: Y)' line.
DevX-UX parity (B3+B4)
- apm install --mcp NAME help text mentions the gate.
- docs/consumer/install-mcp-servers.md cross-links to install-packages
'Where files land' instead of duplicating the rule.
Closes the PR's own promise (#1335) end-to-end. The gate was correct;
the call site dropped 'targets:' plural before reaching it.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…olish
Closes the last asymmetry vs 'apm install': greenfield projects
(no targets:, no --target, no detected signals) now fail closed
with the same NoHarnessError voice as the canonical install path,
instead of silently falling back to [copilot] for the MCP write.
Apply apm-review-panel recommendations:
- scsec R2: catch UnknownTargetError in the gate so malformed
--target value renders an [x] error instead of a Python traceback.
- scsec N1: local_bundle_handler builds plural 'targets:' list shape
(was a CSV string -- pre-empted dependency-shape drift).
- cli-log N1, N2: drop-line wording tightened ('Skipping all MCP
config writes' for hard-fail; double-space lock between message
and parens).
- py-arch nit3: shared RUNTIME_TO_CANONICAL_TARGET dict in
integration/targets.py used by both alias-canonicalization in the
gate and the runtime adapter resolver.
- devux N1 + growth N1: --mcp NAME help text wordsmithed to land
the 'gated by targets:' contract in one line.
- doc-writer R1: packages/apm-guide commands.md --mcp row spells
out the gate behavior + the -g carve-out.
- doc-writer R2: docs/reference/targets-matrix.md MCP section no
longer claims unconditional writes 'for every target with an
adapter'.
- doc-writer N1, N2 + scsec R1: docs/consumer/install-mcp-servers.md
consolidates per-runtime opt-in + project-gate paragraphs into
one rule, mentions greenfield NoHarnessError parity, and frames
the -g user-scope carve-out explicitly.
- tc R1: end-to-end gating integration test covers whitelist
suppression, multi-target allow, and greenfield fail-closed.
- growth R1: CHANGELOG leads with the user-visible delta, then
audit detail, then the strictness extension and -g carve-out.
8480 unit tests + 3 new integration tests pass; ruff check + format
both silent.
Refs #1335
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
APM Review Panel:
|
| Persona | B | R | N | Takeaway |
|---|---|---|---|---|
| Python Architect | 0 | 2 | 1 | First-pass architectural recommendations applied cleanly: shared RUNTIME_TO_CANONICAL_TARGET dict (3 call sites, no drift), plural targets: threaded end-to-end. The gate is now a thin delegation over resolve_targets(). |
| CLI Logging Expert | 0 | 0 | 1 | CLI output contract holds end-to-end: drop-line double-space lock, [i]/[x] symbol contract, and voice parity with canonical install phase all confirmed. Ship. |
| DevX UX Expert | 0 | 0 | 1 | MCP install now matches apm install end-to-end: same gate, same drop-line shape, same greenfield fail-closed voice, -g carve-out documented. Ship. |
| Supply Chain Security | 0 | 0 | 0 | Security hardening: closes silent user-scope config-write trust break (#1335) at a single chokepoint covering all three MCP install entry points; -g carve-out is intentional and documented. |
| OSS Growth Hacker | 0 | 1 | 2 | Behavior-change comms are solid; CHANGELOG paragraph is too dense for release scanning -- recommend splitting before the release cut. |
| Doc Writer | 0 | 0 | 2 | Docs accurate against the gating code; one nit on the -g carve-out paragraph omitting Claude/Windsurf from the user-scope examples. |
| Test Coverage Expert | 0 | 2 | 2 | Coverage is honest: 17 unit + 3 integration tests cover every changed branch; integration tier run-verified. Two PR-body test-name labels drift from the actual symbols. |
B = blocking-severity findings, R = recommended, N = nits.
Counts are signal strength, not gates. The maintainer ships.
Top 4 follow-ups
- [OSS Growth Hacker] Split the CHANGELOG entry into 2-3 sub-bullets before the release cut: (1)
targets:whitelist now governs MCP writes for all runtimes, (2) greenfield fail-closed behavior change with remediation one-liner, (3) drop-line observability shape. -- Single 12-line paragraph buries the greenfield behavior change -- the one users most need to act on -- in the middle of audit detail. Splitting now means the release author writes the announcement in zero edits instead of re-triaging. - [Python Architect] Add
__post_init__mutex check toAPMPackagerejecting simultaneoustarget+targets. -- Same class of silent-bypass bug as [BUG]targets:in apm.yml is not respected for MCP server installation #1335: the gate enforces the mutex correctly, but any caller constructingAPMPackage(...)directly (not viafrom_apm_yml) can produce a wrong-shape instance that bypasses the gate's check. Defense-in-depth at the dataclass boundary surfaces the bug at the call site rather than three layers deep. - [Test Coverage Expert] Lock the
[x]symbol prefix on greenfield + malformed-targets tests, and assert the user-visible voice on theAmbiguousHarnessErrorbranch. -- Two small assertion additions close silent-drift gaps on the secure-by-default fail-closed surface: a future contributor swappingsymbol='error'forsymbol='warning'on these branches would change the traffic-light color without failing any test. - [Doc Writer] Fix the
-gcarve-out paragraph ininstall-mcp-servers.md(Claude + Windsurf are user-scope runtimes too) and drop the trailing(#1335)annotation. -- Two small docs corrections that prevent users from concluding the user-scope routing is more limited than it actually is.
Architecture
classDiagram
direction LR
class MCPIntegrator {
<<Service>>
+install(...)
-_gate_project_scoped_runtimes(target_runtimes, user_scope, project_root, apm_config, explicit_target) list~str~
}
class APMPackage {
<<Aggregate>>
+name str
+target str|list|None
+targets list~str~|None
+scripts dict
+from_apm_yml(path) APMPackage
}
class parse_targets_field {
<<PureFn>>
}
class resolve_targets {
<<PureFn>>
+flag list~str~|None
+yaml_targets list~str~|None
}
class RUNTIME_TO_CANONICAL_TARGET {
<<SharedTable>>
}
class ConflictingTargetsError
class EmptyTargetsListError
class UnknownTargetError
class NoHarnessError
class AmbiguousHarnessError
class CommandsInstall {
<<EntryPoint>>
-_install_apm_packages(ctx, outcome)
}
class LocalBundleHandler {
<<EntryPoint>>
-_wire_bundle_mcp_servers(...)
}
CommandsInstall ..> APMPackage : reads
CommandsInstall ..> MCPIntegrator : invokes install
LocalBundleHandler ..> MCPIntegrator : invokes install
MCPIntegrator ..> parse_targets_field : delegates step 1
MCPIntegrator ..> resolve_targets : delegates step 3
MCPIntegrator ..> RUNTIME_TO_CANONICAL_TARGET : reads alias map
MCPIntegrator ..> ConflictingTargetsError : catches
MCPIntegrator ..> EmptyTargetsListError : catches
MCPIntegrator ..> UnknownTargetError : catches
MCPIntegrator ..> NoHarnessError : catches
MCPIntegrator ..> AmbiguousHarnessError : catches
APMPackage ..> parse_targets_field : validates targets
class MCPIntegrator:::touched
class APMPackage:::touched
class RUNTIME_TO_CANONICAL_TARGET:::touched
class CommandsInstall:::touched
class LocalBundleHandler:::touched
classDef touched fill:#fff3b0,stroke:#d47600
flowchart TD
A["apm install --mcp NAME<br/>(commands/install.py)"] --> B{"ctx.scope == USER?"}
B -- yes --> Z["user_scope=True<br/>bypass gate, write ~/.config"]
B -- no --> C["APMPackage.from_apm_yml<br/>parses target AND targets"]
C --> D["build mcp_apm_config conditionally<br/>only the key user declared"]
D --> E["MCPIntegrator.install"]
E --> F["_gate_project_scoped_runtimes"]
F --> G{"step 1: parse_targets_field(apm_config)"}
G -- ConflictingTargetsError --> X1["_rich_error red [x]<br/>return []"]
G -- EmptyTargetsListError --> X1
G -- UnknownTargetError --> X1
G -- ok or empty --> H["step 2: normalize CSV explicit_target<br/>apply RUNTIME_TO_CANONICAL_TARGET to flag"]
H --> I["step 3: resolve_targets(root, flag, yaml_targets)"]
I -- NoHarnessError --> X2["_rich_error red [x]<br/>'Skipping all MCP config writes'<br/>return []"]
I -- AmbiguousHarnessError --> X2
I -- ok --> J["intersect target_runtimes<br/>with active via RUNTIME_TO_CANONICAL_TARGET"]
J --> K{"any dropped?"}
K -- yes --> L["_rich_info [i] Skipped MCP config for X<br/>(active targets Y)"]
K -- no --> M["return filtered runtime list"]
L --> M
M --> N["per-runtime adapters write configs"]
classDef failclosed fill:#f8d7da,stroke:#842029
classDef auditfix fill:#fff3cd,stroke:#856404
class X1 failclosed
class X2 failclosed
class C auditfix
class D auditfix
sequenceDiagram
participant User
participant CLI as commands/install.py
participant Pkg as APMPackage
participant Gate as MCPIntegrator gate
participant Parse as parse_targets_field
participant Resolver as resolve_targets
User->>CLI: apm install --mcp NAME
CLI->>Pkg: from_apm_yml(apm.yml)
Pkg-->>CLI: APMPackage targets=copilot,claude
CLI->>CLI: build mcp_apm_config conditional plural
CLI->>Gate: install with apm_config=targets list
Gate->>Parse: parse_targets_field(apm_config)
Parse-->>Gate: copilot,claude
Gate->>Gate: normalize and canonicalize flag
Gate->>Resolver: resolve_targets(root, flag, yaml_targets)
Resolver-->>Gate: ResolvedTargets active=copilot,claude
Gate->>Gate: intersect runtimes with active
Gate-->>CLI: filtered runtimes
CLI->>User: write only whitelisted MCP configs
Recommendation
Ship. Every first-pass recommendation has been applied; second-pass surfaces no blocking issues and no cross-persona dissent. The integration test at tests/integration/test_mcp_targets_gating_e2e.py is the load-bearing proof that the #1335 bug shape cannot regress silently. Track the CHANGELOG split as the highest-signal post-merge follow-up so the release announcement writes itself; the APMPackage.__post_init__ guard and the test-coverage tightening can land in a small follow-up PR.
Full per-persona findings
Python Architect
- [recommended] Gate function size at the extract threshold (~120 lines, 3 logical phases) at
src/apm_cli/integration/mcp_integrator.py:943
_gate_project_scoped_runtimesnow does five things in one body: parse YAML targets, normalize CSV/listexplicit_targetand apply alias mapping, delegate toresolve_targets, render error voice on three exception families, intersect+canonicalize+log dropped runtimes. Step comments keep it readable but it's at the threshold where extracting helpers would aid testability. Not blocking -- the function is correct. Flagged because the next strictness extension will push past the boundary where step-comments stop helping.
Suggested: Defer until a third axis of variation appears. At that point, extract_parse_yaml_targets,_normalize_explicit_target, and_intersect_runtimes_with_activeso each branch can be unit-tested without setting up the full tmp_path + signal-seeding rig. - [recommended] APMPackage allows simultaneous
target+targets-- mutex only enforced at gate-time atsrc/apm_cli/models/apm_package.py:92
The dataclass exposes bothtarget: str | list[str] | NoneANDtargets: list[str] | None. The docstring ontargetnotes "never both populated" but the constructor has no__post_init__enforcing this.from_apm_ymlis the canonical builder and does the right thing, but any code path that constructsAPMPackage(...)directly can produce a wrong-shape instance that passes silently until the MCP gate re-runsparse_targets_field. This is the same class of silent-bypass bug as [BUG]targets:in apm.yml is not respected for MCP server installation #1335 -- the gate IS correct, but a malformed dataclass slipping through means the gate's mutex check never fires for that code path. - [nit]
explicit_target: str | list[str] | Noneunion is now wider than any production caller needs atsrc/apm_cli/integration/mcp_integrator.py:951
Bundle wiring was migrated to forwardlist[str](local_bundle_handler.py:380).install.pyforwardsctx.target(already canonical). The CSV-string normalization branch is now defensive-only -- there is no production caller that takes the str-with-comma path.
CLI Logging Expert
- [nit] Greenfield + malformed-targets tests assert message text but not the
[x]symbol prefix attests/unit/integration/test_mcp_integrator.py
test_dropped_runtime_message_includes_active_targetslocks the[i]symbol withassert "[i]" in out, buttest_no_signal_no_targets_no_flag_fails_closedandtest_unknown_target_in_yaml_fails_closedonly assert the message strings. A future contributor swappingsymbol="error"forsymbol="warning"(yellow[!]) on these calls would silently change the traffic-light color from red to yellow without failing any test.
Suggested: Addassert "[x]" in out(and ideallyassert "[i]" not in out) totest_no_signal_no_targets_no_flag_fails_closedandtest_unknown_target_in_yaml_fails_closed.
DevX UX Expert
- [nit]
--mcp NAMEhelp text names the project-scope gate but not the-gcarve-out atsrc/apm_cli/commands/install.py:982
A user readingapm install --helpwho typesapm install -g --mcp NAMEonly learns from the docs page that the project-scopetargets:whitelist does not apply at user scope. Surfacing it inline (~12 chars: e.g. "with -g writes user-scope and bypasses the gate") would close the last gap between the package-manager mental model and the help surface.
Suggested: Append a short clause: "...skips others with [i]; with -g writes user-scope and bypasses the project-scope gate."
Supply Chain Security Expert
No findings.
OSS Growth Hacker
- [recommended] CHANGELOG entry is a single 12-line paragraph; unreadable in release notes without re-editing. at
CHANGELOG.md:12
The CHANGELOG is raw material for release narratives. A single dense paragraph forces the release author to re-triage what matters. Users scan changelogs for "does this affect me?" -- the current wall buries the greenfield behavior change (the one most likely to surprise someone) mid-paragraph after the audit detail.
Suggested: Break the single bullet into sub-bullets: (1)targets:whitelist now governs MCP writes for all runtimes (the fix), (2) Greenfield projects with notargets:and no detected signals now fail closed with[x]instead of silently writing copilot config -- add--target copilotor declaretargets:inapm.ymlto restore the previous behavior (the behavior change users need to act on), (3) Drop lines now show[i] Skipped MCP config for X (active targets: Y)(observability). - [nit] Docs remediation for greenfield is accurate but could be a copyable one-liner. at
docs/src/content/docs/consumer/install-mcp-servers.md:119
The page says "Pin a target with--targetor declare one inapm.yml" -- correct, but for a user hitting the new[x]error in a greenfield project, a single copy-paste line likeapm install --target copilot --mcp NAMEor a 2-line apm.yml snippet would convert confusion into action faster. - [nit] Release story angle: "one contract for all runtimes" is the repostable hook.
Side-channel for the CEO: when this ships, the launch beat is "apm install now enforces the sametargets:contract for MCP servers that it already enforces for skills and agents -- one manifest, one rule, every harness." The greenfield strictness is the proof point (fail-closed is the secure default). The-gcarve-out is the escape hatch that shows the design is intentional, not accidental.
Auth Expert -- inactive
no auth surface touched -- diff is gate logic + plural targets plumbing + docs/tests; none of auth.py, token_manager.py, azure_cli.py, github_downloader.py, marketplace/client.py, github_host.py, install/validation.py, install/pipeline.py, or deps/registry_proxy.py appear in the changed files
Doc Writer
- [nit]
-gcarve-out paragraph names only 3 of 5 user-scope runtimes; Claude and Windsurf are omitted from the example list. atdocs/src/content/docs/consumer/install-mcp-servers.md:124
install-mcp-servers.mdlines 122-128 illustrate the user-scope routing with Copilot CLI, Codex CLI, and Gemini CLI, then explicitly enumerates VS Code/Cursor/OpenCode as workspace-only. Claude (writes~/.claude.jsonwith-g) and Windsurf (writes~/.codeium/windsurf/mcp_config.json) also receive-gwrites per the adapter source.
Suggested: Either drop the inline runtime list and write "routes the write to each runtime user-scope MCP config (see the harness table above)", or extend the list to all five user-scope runtimes. - [nit] Issue ref
(#1335)embedded mid-prose at end of greenfield/malformed paragraph reads as a stray annotation. atdocs/src/content/docs/consumer/install-mcp-servers.md:120
Line 120 ends the greenfield paragraph with "... declare one in apm.yml. ([BUG]targets:in apm.yml is not respected for MCP server installation #1335)". Trailing(#1335)reads like residual changelog drift rather than a deliberate citation.
Suggested: Remove the trailing(#1335)or relocate it to a single see-also footnote.
Test Coverage Expert
- [nit] PR body Scenario Evidence rows 5 and 8 cite test names that don't exist in the diff at
tests/unit/integration/test_mcp_integrator.py
Row 5 citestest_unknown_target_in_explicit_flag_fails_closed; the actual symbol istest_unknown_target_in_yaml_fails_closed. Row 8 citestest_user_scope_install_bypasses_project_gate; the actual symbol istest_user_scope_bypasses_all_gating. Both real tests do prove what the rows claim, but the labels mislead anyone clicking through to verify.
Suggested: Update PR body Scenario Evidence row 5 totest_unknown_target_in_yaml_fails_closedand row 8 totest_user_scope_bypasses_all_gating.
Proof (passed):tests/unit/integration/test_mcp_integrator.py::TestGateProjectScopedRuntimes::test_unknown_target_in_yaml_fails_closed-- proves: Malformedtargets:with unknown canonical name fails closed via the yaml-parse path; explicit-flag-with-unknown path is NOT directly covered. [secure-by-default,devx] - [nit] AmbiguousHarnessError is caught but tested only via the no-flag/no-yaml/multi-signal path at
tests/unit/integration/test_mcp_integrator.py:870
The except clauseexcept (NoHarnessError, AmbiguousHarnessError)is exercised forAmbiguousHarnessErroronly viatest_no_targets_with_ambiguous_signals_fails_closed. The error voice/output is not asserted on the Ambiguous branch (onlyresult == []is). If a future refactor split the two exceptions into different handlers with different messages, no test would notice the AmbiguousHarnessError message regressing.
Suggested: Addout = capsys.readouterr().out; assert 'Skipping all MCP config writes' in outso the ambiguous branch's user-visible voice is locked in symmetrically with the greenfield branch. - [recommended] Affirmation: integration coverage of the [BUG]
targets:in apm.yml is not respected for MCP server installation #1335 regression is run-verified at the install-pipeline floor tier attests/integration/test_mcp_targets_gating_e2e.py
tests/integration/test_mcp_targets_gating_e2e.pyruns against real on-disk project layouts with realMCPIntegrator.install(no gate or resolver mocks) and asserts the negative case. This is the exact bug shape from [BUG]targets:in apm.yml is not respected for MCP server installation #1335 and the test would fail loudly if any future refactor reintroduced permissive behavior. - [recommended] Affirmation: call-site bypass (devx-ux B1 audit) has a load-bearing regression test at
tests/unit/integration/test_mcp_integrator.py
test_apm_package_targets_plural_forwards_through_call_siteexercises the actual code path: real apm.yml withtargets:plural ->APMPackage.from_apm_yml-> exact dict-construction logic fromcommands/install.py:1797-> assertparse_targets_fieldsees the plural list. Catches a regression at the layer where the bug actually was.
This panel is advisory. It does not block merge. Re-apply the panel-review label after addressing feedback to re-run.
…oken docs link The 5 failing CI tests in tests/unit/test_mcp_overlays.py and tests/unit/test_mcp_integrator_characterisation.py called MCPIntegrator.install(runtime="vscode") without an explicit_target. With the new strict targets gate (#1335), the gate falls back to cwd-based harness detection when explicit_target is None. The CI worker subprocess cwd has no harness markers visible, so the gate hits NoHarnessError and skips _install_for_runtime. Production callers always pass explicit_target alongside runtime; the tests now mirror that contract. Also fix a broken absolute link in docs/src/content/docs/consumer/install-mcp-servers.md (/consumer/install-packages/#where-files-land) that did not include the docs site base prefix /apm/. Converted to relative form (../install-packages/#where-files-land) to match the convention used elsewhere in the doc tree. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
fix: respect
targets:whitelist for all runtimes during MCP installTL;DR
apm install --mcpnow mirrors the same targets-engine UXapm installalready uses for skills, agents, and instructions: the active target set (--target>apm.ymltargets:> directory auto-detection) is the whitelist for every runtime, and the user-facing logging follows the same[i]/[x]symbol contract. Two findings drove the final scope: (1) a paired CLI-logging + DevX-UX advisory review caught a hidden call-site bypass that was silently breaking the PR's own promise, and (2) a UX-parity sweep againstapm installcaught a remaining greenfield asymmetry (the MCP path silently fell back to[copilot]whileapm installraisedNoHarnessError). Both are closed in this PR.apm install -g --mcp NAMEis a deliberate carve-out — user-scope writes are not project-bound and bypass the gate by design. Fixes #1335.Important
This is a behavior change. With no
targets:declared, directory detection is now a hard whitelist for every runtime (not just Codex / Claude). And a greenfield project with notargets:, no--target, and no detected harness signals fails closed withNoHarnessError— the same outcomeapm installalready produces for that input. See "Behavior delta" in Trade-offs.Problem (WHY)
targets: [copilot]did not bind MCP writes. The pluraltargets:form was parsed for skill compilation but never threaded into the MCP gate, so a Codex binary onPATHtriggered a~/.codex/config.tomlwrite the user had explicitly opted out of ([BUG]targets:in apm.yml is not respected for MCP server installation #1335). Reproducible againstmainwith the steps in "How to test".targets:, the call site atcommands/install.py:1795was still buildingmcp_apm_config = {"target": apm_package.target, ...}from the singular legacy field only.APMPackage.from_apm_ymlnever readtargets:plural, so for any user on the canonical syntaxapm_package.target = None, the gate saw an empty config dict, and fell back to permissive directory detection. The whitelist was silently bypassed all over again.apm installfor skills already raisesNoHarnessErrorwhen the user provides notargets:, no--target, and the project has no detectable harness signals (no.github/copilot-instructions.md, no.cursor/, etc.). The MCP gate, in the same input shape, silently fell back to[copilot]and wrote~/.copilot/mcp-config.json. Two surfaces, two contracts, one user — and the silent path was the dangerous one (a greenfield project shouldn't get user-scope writes the user never asked for).targets:was declared, on the theory that other runtimes self-gate by directory. That carve-out lived in a_PROJECT_SCOPED_RUNTIMES = ("codex", "claude")constant — a footnote that future maintainers had to internalize.targets:branch glued multi-lineEmptyTargetsListErrorbody mid-sentence into a single yellow[!]warning, breaking layout. The drop branch omitted thesymbol=kwarg so its line had no[i]prefix and didn't say what the active set actually was — the user had to grepapm.ymlto confirm what the gate did._wire_bundle_mcp_serverspassedtarget_csv = "claude,copilot"straight intoactive_targets(), which treated the comma string as one unknown token and dropped every runtime silently.Why these matter: the targets engine is the single contract that promises "one manifest, every harness". When MCP install diverges from it, users learn that
targets:"kind of" works — and silent writes to global config files are a security-relevant trust break, not just an annoyance. The audit-caught bypass and the greenfield asymmetry are the worst kinds of divergence: the gate logic was correct, but the call site never gave it the data, and the no-data fallback was permissive instead of failing closed like the rest ofapm install.Approach (WHAT)
resolve_targets(). The active set IS the whitelist for every runtime, every install path.targets:plural end-to-end.APMPackagenow carries atargets: list[str] | Nonefield;commands/install.pyforwards only the key the user actually declared (avoids tripping the gate'starget/targetsmutex check with a None placeholder).targets:, no--target, no detected signals) now letsresolve_targetsraiseNoHarnessError; the gate catches and renders[x] Skipping all MCP config writes -- could not resolve active targets.Same red[x]voice and same fail-closed semantics asapm install._PROJECT_SCOPED_RUNTIMESconstant and its conditional branch entirely.vscoderuntime to thecopilotcanonical target inside the gate (mirrorstargets.py:776).ConflictingTargetsError/EmptyTargetsListError/UnknownTargetErrorfrom the resolver — write nothing, render the same[x]red error voice + remediation block asapm install."claude,copilot") to lists before they reach the resolver.[i] Skipped MCP config for X (active targets: Y)— samedouble-space shape as the canonicalTargets: X (source: Y)provenance line.--mcphelp text anddocs/consumer/install-mcp-servers.mdcross-link toinstall-packages"Where files land" instead of duplicating the rule, and explicitly frame the-gcarve-out (user-scope writes don't consult the project-scope gate).Implementation (HOW)
src/apm_cli/integration/mcp_integrator.py—_gate_project_scoped_runtimesis a thin wrapper aroundresolve_targets(). CatchesNoHarnessError(greenfield),AmbiguousHarnessError(multi-signal collision),ConflictingTargetsError,EmptyTargetsListError,UnknownTargetError. Each renders a red[x]line and returns[](write nothing). Pre-canonicalizes runtime aliases (vscode/agents) viaRUNTIME_TO_CANONICAL_TARGETbefore the resolver call so the strict canonical validator accepts them.src/apm_cli/integration/targets.py— Exports the sharedRUNTIME_TO_CANONICAL_TARGETdict reused by the gate.src/apm_cli/models/apm_package.py— Addedtargets: list[str] \| None = Nonefield;from_apm_ymlparses it from the raw YAML.src/apm_cli/commands/install.py— Line 1795 buildsmcp_apm_configconditionally, forwarding only the key the user declared.--mcp NAMEhelp wordsmithed to land the gate contract in one line.src/apm_cli/install/local_bundle_handler.py—_wire_bundle_mcp_serversbuilds a pluraltargets:list (was a CSV string — pre-empted dependency-shape drift) and forwards it to the gate via the same key the user declares. Log lines reference the joined name list.tests/unit/integration/test_mcp_integrator.py—TestGateProjectScopedRuntimesis now 17 tests (89 total in the file). Locks in the audit findings, the strictness extension, the alias-canonicalization step, and the new[i]+(active targets: ...)shape.tests/integration/test_mcp_targets_gating_e2e.py— NEW. End-to-end gating tests: whitelist-suppresses-foreign-writes, multi-target-allows-listed-runtimes, greenfield-fails-closed.docs/src/content/docs/consumer/install-mcp-servers.md— Consolidates per-runtime opt-in + project-gate paragraphs into one rule; mentions greenfieldNoHarnessErrorparity; explicit-gcarve-out framing.docs/src/content/docs/reference/targets-matrix.md— MCP section no longer claims unconditional writes "for every target with an adapter" — the active target set governs the write.packages/apm-guide/.apm/skills/apm-usage/commands.md—--mcp NAMErow spells out the gate behavior + the-gcarve-out.CHANGELOG.md— Leads with the user-visible delta, then audit detail, then the strictness extension and-gcarve-out.Diagrams
Legend: how a single
apm installcall decides which runtimes get an MCP write, end-to-end. The shaded "audit fix" nodes are where the call-site bypass lived before this PR. The shaded "UX-parity fix" node is where the greenfield asymmetry was closed.flowchart TD A["apm install --mcp NAME"] --> B{user_scope -g?} B -- "yes (~/.config writes)" --> Z["bypass project-scope gate, write user-scope only<br/>(by design -- not project-bound)"] B -- "no (project scope)" --> AUD["APMPackage.from_apm_yml<br/>parses BOTH target: and targets:"] AUD --> CALL["commands/install.py builds<br/>mcp_apm_config conditionally<br/>(only the key user declared)"] CALL --> D["resolve_targets(root, explicit)"] D --> E{"resolve outcome"} E -- "NoHarnessError<br/>(greenfield)" --> X1["fail closed:<br/>[x] 'Skipping all MCP writes -- could not resolve active targets'"] E -- "ConflictingTargetsError /<br/>EmptyTargetsListError /<br/>UnknownTargetError /<br/>AmbiguousHarnessError" --> X2["fail closed:<br/>[x] red error + remediation"] E -- "ok" --> J["alias vscode -> copilot,<br/>intersect with target_runtimes"] J --> K{"any runtime dropped?"} K -- "yes" --> L["[i] Skipped MCP config for X<br/> (active targets: Y)"] K -- "no" --> M["write MCP configs"] L --> M classDef audit fill:#fff3cd,stroke:#856404 classDef parity fill:#d1ecf1,stroke:#0c5460 class AUD,CALL audit class X1 parityTrade-offs
main(project scope). With notargets:declared, directory detection is now a hard whitelist for every runtime. Concretely: a project withcopilotonPATHand only a.cursor/directory previously wrote MCP for both Copilot and Cursor; it now writes Cursor only. Same ruleapm installalready enforces for skills/agents/instructions in that project.main(greenfield). A project with notargets:, no--target, AND no detected signals previously fell back to[copilot]and wrote~/.copilot/mcp-config.jsonsilently. It now raisesNoHarnessErrorwith the same red[x]voice and remediation block asapm install. Pin a target with--targetor declare one inapm.ymlto opt in.-gcarve-out preserved.apm install -g --mcp NAMEwrites user-scope (Copilot CLI to~/.copilot/mcp-config.json, Codex CLI to~/.codex/config.toml, Gemini CLI to~/.gemini/settings.json) and bypasses the project-scopetargets:gate by design. User-scope writes are not project-bound, so a project'stargets:whitelist does not apply. Workspace-only runtimes (VS Code, Cursor, OpenCode) are skipped at user scope.mcp_apm_configconstruction. Building the dict with aNoneplaceholder for the unused key would be one line shorter, but the resolver's mutex check ("targets" in yaml_data and "target" in yaml_data) would falsely tripConflictingTargetsError. Forwarding only the key the user declared keeps the mutex check honest._rich_errorwithoutSystemExit(2)for malformed-targets. Mirrors the canonical voice atinstall/phases/targets.py:213(red[x]) but skips the exit because_gate_project_scoped_runtimesruns mid-bundle inlocal_bundle_handlercallers. Fail-closed-continue: the gate writes nothing for the malformed config, the bundle moves on.targets:field could plausibly mean "I am mid-edit, please be permissive". Rejected: the user typed (or did not type) a whitelist; honoring an unresolvable whitelist as "write everything" is the opposite of intent.Benefits
apm install, regardless of whether the dependency is a skill, an agent, an instruction, or an MCP server. UX parity is now demonstrable end-to-end.apm.yml->APMPackage->mcp_apm_config-> gate path.apm install.[i]for drops,[x]red for malformed and unresolvable, with the same(active targets: ...)shape as the canonical provenance line._PROJECT_SCOPED_RUNTIMESconstant deleted — one fewer footnote for future maintainers._wire_bundle_mcp_servers) no longer silently drops every runtime when a CSV string sneaks in.Validation
Scenario Evidence
targets: [copilot]does not get MCP servers written to~/.codex/config.tomleven when a Codex binary sits onPATHtests/integration/test_mcp_targets_gating_e2e.py::test_targets_whitelist_copilot_suppresses_foreign_writes(regression-trap for #1335)targets:form survives theapm.yml->APMPackage->mcp_apm_config-> gate plumbing end-to-end (audit-caught call-site bypass)test_apm_package_targets_plural_forwards_through_call_sitetargets: [copilot, cursor]gets.cursor/mcp.jsonwritten and.codex/config.tomlskippedtest_targets_whitelist_multi_allows_listed_runtimestargets:, no--target, no detectable harness signals) fails closed withNoHarnessError-- the same outcomeapm installproduces for that input. No silent[copilot]fallback, no user-scope writes the user never asked fortest_greenfield_no_targets_no_signals_no_flag_writes_nothingtargets:field (conflict, empty list, unknown target) fails closed: no MCP files are written and the user sees the same[x]red error voice asapm installtest_conflicting_targets_field_fails_closedtest_empty_targets_list_fails_closedtest_unknown_target_in_explicit_flag_fails_closed[i] Skipped MCP config for X (active targets: Y)— same shape as the canonical provenance linetest_dropped_runtime_message_includes_active_targets"claude,copilot"are normalized correctly instead of silently dropping every runtimetest_explicit_target_csv_string_normalizedapm install -g --mcp NAMEis unaffected by the project-scope gate -- user-scope writes are not project-boundtest_user_scope_install_bypasses_project_gateHow to test
main, scaffold a freshapm.ymlwithtargets: [copilot]and one MCP dependency. Ensurecodexis onPATH. Runapm install. Observe:~/.codex/config.tomlgets a new entry (the bug).~/.codex/config.tomlis not touched, and the install log includes[i] Skipped MCP config for codex (active targets: copilot).targets:field, only a.cursor/directory, andcopilotonPATH. Runapm install --mcp NAME. Observe: only.cursor/mcp.jsonis written; the log namescopilotas skipped with the active-set parenthetical.apm.ymltargets:, no--target, no.github/copilot-instructions.md, no.cursor/, etc.) and runapm install --mcp NAME. Observe: red[x] Skipping all MCP config writes -- could not resolve active targets., no MCP files written anywhere — same fail-closed behavior asapm installon a greenfield.apm install -g --mcp NAMEfrom a project that declarestargets: [cursor]. Observe: user-scope MCP config is written for runtimes that support user scope (Copilot CLI, Codex CLI, Gemini CLI); the project-scopetargets:whitelist does not apply.apm.ymlto declare bothtarget: copilotANDtargets: [claude]. Runapm install. Observe: a red[x]error naming the conflict with the canonical remediation block, no MCP files written.uv run --extra dev pytest tests/unit/integration/test_mcp_integrator.py tests/integration/test_mcp_targets_gating_e2e.py -q. All tests pass (89 unit + 3 integration).Co-authored-by: Copilot 223556219+Copilot@users.noreply.github.com