fix(update): reconcile MCP/LSP servers after apm update#2085
Conversation
|
@microsoft-github-policy-service agree |
There was a problem hiding this comment.
Pull request overview
This PR fixes a behavioral gap where apm update bypassed the post-install reconciliation steps that apm install performs, causing MCP servers to linger as orphans in apm.lock.yaml (and runtime configs) and preventing LSP servers from being recorded via update. It extracts MCP reconciliation into a shared helper and invokes both MCP and LSP integration from the update flow after the consent gate, aligning apm update with the install pipeline’s integration semantics.
Changes:
- Extract MCP reconciliation logic into
run_mcp_integration()and wire it intoapm installandapm update. - Add
apm updateintegration glue to snapshot prior lockfile MCP/LSP state, re-parse the manifest after pin application, and reconcile servers/configs. - Add unit + integration tests for extraction parity and for update pruning/recording behavior; update lockfile spec docs to reflect “last install or update”.
Reviewed changes
Copilot reviewed 7 out of 7 changed files in this pull request and generated no comments.
Show a summary per file
| File | Description |
|---|---|
| tests/unit/install/test_mcp_integration_extraction.py | Verifies branch-by-branch parity for the extracted run_mcp_integration() behavior, including policy-block propagation. |
| tests/integration/test_update_mcp_lsp_prune.py | Regression coverage ensuring apm update --yes prunes orphaned MCP entries, preserves state on --dry-run, and records declared LSP servers. |
| src/apm_cli/install/mcp/integration.py | New shared MCP reconciliation helper used by both install and update flows. |
| src/apm_cli/install/mcp/init.py | Exposes run_mcp_integration from the MCP install subpackage. |
| src/apm_cli/commands/update.py | Adds MCP+LSP reconciliation to apm update after consent, using a snapshot of pre-update lockfile state. |
| src/apm_cli/commands/install.py | Replaces the inline MCP block with a call to run_mcp_integration() while preserving existing policy-failure behavior. |
| docs/src/content/docs/reference/lockfile-spec.md | Updates lockfile field descriptions to reflect install/update parity for MCP/LSP server recording. |
APM Review Panel:
|
| Persona | B | R | N | Takeaway |
|---|---|---|---|---|
| Python Architect | 0 | 1 | 2 | Clean extraction following the existing LSP integration pattern. Outer except Exception and untyped params are minor. |
| CLI Logging Expert | 0 | 2 | 1 | PolicyBlockError in update.py uses _rich_error instead of logger.error, breaking logging parity with install.py. |
| DevX UX Expert | 0 | 2 | 2 | MCP/LSP reconciliation in update is a correct fix; policy-block error lacks recovery guidance and flag-parity gap should be documented. |
| Supply Chain Security Expert | 0 | 0 | 2 | MCP/LSP reconciliation on update path correctly enforces policy, allowExecutables gate, and consent; no security regressions found. |
| OSS Growth Hacker | 0 | 1 | 2 | Community-contributed bug fix closes a silent config-rot gap; needs CHANGELOG entry and is great release-narrative material. |
| Doc Writer | 0 | 3 | 1 | Lockfile field descriptions are accurate; three gaps: lifecycle table, update.md Behavior section, flag parity gap undocumented. |
| Test Coverage Expert | 0 | 2 | 1 | Core regression for #2077 is well-covered at integration tier (3 CliRunner tests, all passing); two recommended gaps on error/failure paths. |
B = blocking-severity findings, R = recommended, N = nits.
Counts are signal strength, not gates. The maintainer ships.
Top 5 follow-ups
- [OSS Growth Hacker] Add CHANGELOG entry under [Unreleased] crediting @cffnpwr for the fix. -- User-facing behavior change requires a CHANGELOG line per project convention; community credit reinforces contributor flywheel.
- [Test Coverage Expert] Add integration test exercising the except Exception handler around _run_mcp_lsp_integration in update.py. -- Evidence=missing on an error path that the python-architect independently flagged as fragile to future refactors. Failed-test-equivalent gap on a user-facing error surface.
- [Doc Writer] Add apm update to lockfile-spec.md lifecycle table and document MCP/LSP reconciliation in update.md Behavior section. -- Lockfile spec now has a new writer (update) but the lifecycle table doesn't reflect it; update.md Behavior section is the canonical place users look for what update does.
- [DevX UX Expert] File issue for flag parity: --no-policy, --trust-transitive-mcp, --runtime on apm update. -- Users who hit PolicyBlockError during update have no escape hatch. Pre-existing gap but now more visible since update exercises MCP policy checks.
- [CLI Logging Expert] Harmonize PolicyBlockError handler in update.py to use logger.error instead of _rich_error, matching install.py. -- Logging parity between install and update for identical error paths; affects structured log consumers and --quiet mode behavior.
Architecture
classDiagram
direction LR
class MCPIntegrator {
<<Facade>>
+collect_transitive() list
+deduplicate(deps) list
+install(deps, ...) int
+remove_stale(servers, ...)
+update_lockfile(servers, path)
+get_server_names(deps) set
+get_server_configs(deps) dict
}
class LSPIntegrator {
<<Facade>>
+collect_transitive() list
+deduplicate(deps) list
+install(deps, ...) int
+remove_stale(servers, ...)
+update_lockfile(servers, path)
+resolve_target_runtimes() list
}
class run_mcp_integration {
<<Function -- install/mcp/integration.py>>
+run_mcp_integration(**kwargs) tuple
}
class run_lsp_integration {
<<Function -- install/lsp/integration.py>>
+run_lsp_integration(**kwargs) int
}
class install_command {
<<Caller -- commands/install.py>>
+_install_apm_packages(ctx, outcome)
}
class update_command {
<<Caller -- commands/update.py>>
+_run_dep_update()
+_run_mcp_lsp_integration()
}
class PolicyBlockError {
<<Exception>>
}
class LockFile {
<<ValueObject>>
+mcp_servers set
+mcp_configs dict
+lsp_servers set
+lsp_configs dict
+read(path) LockFile
}
class APMPackage {
<<ValueObject>>
+get_all_mcp_dependencies() list
+get_lsp_dependencies() list
+from_apm_yml(path) APMPackage
}
run_mcp_integration ..> MCPIntegrator : delegates
run_mcp_integration ..> PolicyBlockError : raises
run_lsp_integration ..> LSPIntegrator : delegates
install_command ..> run_mcp_integration : calls
install_command ..> run_lsp_integration : calls
update_command ..> run_mcp_integration : calls
update_command ..> run_lsp_integration : calls
update_command ..> LockFile : snapshots old state
update_command ..> APMPackage : re-parses after pin
flowchart TD
A["apm update CLI"] --> B["_run_dep_update()"]
B --> C["LockFile.read() -- snapshot old MCP/LSP state"]
C --> D["_install_apm_dependencies() -- resolve + materialize"]
D --> E{"plan accepted?"}
E -->|No| F["return early"]
E -->|Yes| G["_annotate_lockfile_revision_tags()"]
G --> H["_run_mcp_lsp_integration()"]
H --> I["clear_apm_yml_cache()"]
I --> J["APMPackage.from_apm_yml -- re-parse after pins"]
J --> K["run_mcp_integration() -- install/mcp/integration.py"]
K --> L["MCPIntegrator.collect_transitive"]
L --> M["filter_mcp_by_allow_executables"]
M --> N{"mcp_deps?"}
N -->|Yes| O["run_policy_preflight -- raises PolicyBlockError"]
O --> P["MCPIntegrator.install"]
P --> Q["MCPIntegrator.remove_stale (old - new)"]
Q --> R["MCPIntegrator.update_lockfile"]
N -->|No + old servers| S["remove_stale(all old) + update_lockfile(empty)"]
R --> T["run_lsp_integration()"]
S --> T
T --> U["LSPIntegrator install/prune/lockfile"]
U --> V["render summary + exit"]
Recommendation
Ship this PR. The core fix is correct, well-tested at the integration tier, security-clean, and architecturally sound. The five follow-ups (CHANGELOG entry, error-path test, two doc gaps, flag-parity issue) are all post-merge work that does not affect shipped correctness. The CHANGELOG entry should ideally land before or with the next release cut, not necessarily before this PR merges. This is a community contribution -- optimize for merge velocity and contributor experience.
Full per-persona findings
Python Architect
-
[recommended] Outer
except Exceptionin _run_dep_update masks future refactors atsrc/apm_cli/commands/update.py:694
NOT a current bug (SystemExit inherits BaseException not Exception). But if _run_mcp_lsp_integration ever changes to raise instead of sys.exit, the outer handler would mask it with a generic message. The dual-exit pattern is fragile.
Suggested: Let PolicyBlockError propagate out of _run_mcp_lsp_integration and add a dedicated except PolicyBlockError clause at the call site in _run_dep_update, before the generic except Exception. -
[nit] run_mcp_integration has 19 keyword parameters -- consider a config dataclass at
src/apm_cli/install/mcp/integration.py:15
The noqa: PLR0913 is honest about it. A frozen MCPIntegrationConfig dataclass would make the two call sites more uniform. -
[nit] logger/diagnostics params untyped at
src/apm_cli/install/mcp/integration.py:26
Adding TYPE_CHECKING-guarded annotations would improve IDE support.
CLI Logging Expert
-
[recommended] PolicyBlockError handler in _run_mcp_lsp_integration uses _rich_error instead of logger.error at
src/apm_cli/commands/update.py
install.py catches the identical PolicyBlockError with logger.error + logger.render_summary(). update.py uses _rich_error for the same message, bypassing CommandLogger lifecycle.
Suggested: Replace _rich_error(...) with logger.error(...) to match install.py line 1882. -
[recommended] Outer MCP/LSP catch-all also uses _rich_error/_rich_info instead of logger at
src/apm_cli/commands/update.py
Compare with _resolve_and_stage_revision_pin_updates (same file) which correctly uses logger.error and logger.info for structurally identical error handling.
Suggested: Use logger.error(f"Error reconciling MCP/LSP servers: {e}") and logger.info(...). -
[nit] Verbose detail uses '(ies)' pluralization instead of count-aware noun at
src/apm_cli/install/mcp/integration.py
Suggested: noun = "dependency" if len(transitive_mcp) == 1 else "dependencies"
DevX UX Expert
-
[recommended] PolicyBlockError in update gives no recovery action
When apm install hits a policy block, users can pass --no-policy. In apm update, the same block fires with no escape hatch and no hint of what to do.
Suggested: Append hint: 'To skip policy checks, use apm install --no-policy after updating.' or add --no-policy to apm update. -
[recommended] Flag asymmetry between install and update is invisible to the user
apm install exposes --runtime, --exclude, --trust-transitive-mcp, --no-policy. apm update silently uses hardcoded defaults for all of these.
Suggested: Add a note in apm update --help or CLI reference: 'MCP policy and runtime flags are not yet supported; use apm install for full control.' -
[nit] Success message grammar issue when installed=0
'No dependency changes were applied.' reads like failure when user explicitly accepted. -
[nit] Consumer guide (update-and-refresh.mdx) doesn't mention MCP reconciliation
A user reading the consumer guide won't know update cleans up orphaned MCP configs.
Supply Chain Security Expert
-
[nit] Lifecycle script failures silently swallowed in _fire_update_scripts (pre-existing)
contextlib.suppress(Exception) swallows all errors from pre-update scripts. If a lifecycle script enforces a security constraint, the update proceeds regardless. -
[nit] Pre-update lifecycle scripts fire before user consent (pre-existing, consistent with install)
OSS Growth Hacker
-
[recommended] Missing CHANGELOG entry under [Unreleased] for this user-facing bug fix
Suggested: Add to [Unreleased] ### Fixed: apm update now reconciles MCP and LSP servers against apm.yml, removing orphaned MCP server entries from runtime configs and writing LSP servers to the lockfile -- matching apm install behavior. (by @cffnpwr, [BUG]apm updatedoes not prune orphaned MCP servers from apm.lock.yaml #2077) -
[nit] Lockfile spec doc change is accurate but misses story-shaped opportunity
-
[nit] Community contributor pattern worth amplifying (thank-you comment, release note credit)
Auth Expert -- inactive
No auth/token/credential surfaces touched; PR changes MCP/LSP reconciliation in apm update.
Doc Writer
-
[recommended] apm update missing from lockfile-spec.md lifecycle table at
docs/src/content/docs/reference/lockfile-spec.md
apm update now writes mcp_servers/mcp_configs and lsp_servers/lsp_configs but the lifecycle table doesn't list it as a writer.
Suggested: Add row: apm update | existing lockfile (MCP/LSP baseline) | rewrites mcp_servers, mcp_configs, lsp_servers, lsp_configs after applying plan -
[recommended] update.md Behavior section does not mention MCP/LSP reconciliation at
docs/src/content/docs/reference/cli/update.md
Users running apm update with an MCP server removed from apm.yml will see reconciliation output with no explanation in the reference docs.
Suggested: Add a bullet: MCP/LSP reconciliation: after applying the dependency plan, apm update reconciles MCP and LSP servers against the updated apm.yml, mirroring apm install behavior. -
[recommended] Flag parity gap (--trust-transitive-mcp, --no-policy not on update) undocumented at
docs/src/content/docs/reference/cli/update.md
Users migrating workflows from install to update will hit this silently.
Suggested: Add a Limitations note: apm update does not yet support --trust-transitive-mcp or --no-policy. -
[nit] Two-word change is accurate and consistent with existing voice
Test Coverage Expert
-
[recommended] Integration tests for update MCP/LSP prune pass -- regression [BUG]
apm updatedoes not prune orphaned MCP servers from apm.lock.yaml #2077 is well-defended attests/integration/test_update_mcp_lsp_prune.py
Proof (passed): tests/integration/test_update_mcp_lsp_prune.py::TestUpdatePrunesOrphanedMCPServers::test_apm_update_prunes_orphaned_mcp_server
assert updated_lock.mcp_servers == [], f"Expected orphaned MCP server to be pruned, got: {updated_lock.mcp_servers}" -
[recommended] No test exercises the except Exception error handler around _run_mcp_lsp_integration in update.py at
src/apm_cli/commands/update.py
Proof (missing): tests/integration/test_update_mcp_lsp_prune.py::test_apm_update_exits_nonzero_on_mcp_reconciliation_failure -
[nit] No integration-tier test for PolicyBlockError exit path in apm update (unit tier covers propagation)
Proof (missing): tests/integration/test_update_mcp_lsp_prune.py::test_apm_update_policy_block_exits_nonzero
Performance Expert -- inactive
No hot-path surface touched. Overhead is bounded by dep count (single-digit typically), justified for correctness.
This panel is advisory. It does not block merge. Re-apply the panel-review label after addressing feedback to re-run.
Spec conformance gate -- how to fixThe This PR is a refactor + bug fix, not a new normative behaviour -- the extracted The fix is a single line. Add this to the PR description body: That line must start at column 1 (no leading spaces) in the PR body. The detector reads it, echoes the rationale to the CI log, and exits 0. The waiver is reviewer-auditable -- maintainers can see exactly what was claimed and verify it matches the diff. How to add it:
No code changes needed. |
Head branch was pushed to by a user without write access
a74269f to
cab6733
Compare
|
@sergio-sisternes-epam Thanks for the pointer. The waiver line is now in the PR body:
One note: the body edit alone did not re-run the gate — the |
Description
apm updatenever ran the MCP/LSP reconciliation thatapm installperforms: it calls_install_apm_dependenciesdirectly, whileMCPIntegrator/run_lsp_integrationwere only invoked from_install_apm_packages. Because the lockfile phase intentionally carriesmcp_servers/mcp_configsforward unreconciled (install/phases/lockfile.py::_preserve_existing_mcp_state), MCP servers that dependencies no longer declare survivedapm updateindefinitely -- inapm.lock.yamland in runtime configs (.mcp.jsonetc.). LSP had the same gap in the opposite direction:lsp_serversare not carried forward, so LSP servers never reached the lockfile viaapm updateat all.Changes:
commands/install.py::_install_apm_packagesintoinstall/mcp/integration.py::run_mcp_integration(), mirroring the existinginstall/lsp/integration.py::run_lsp_integration(). The transitive-policy preflight now raisesPolicyBlockErrorinstead of callingsys.exit(1); both callers catch it, report, and exit non-zero (no behaviour change forapm install).apm updatenow runs MCP and LSP integration after the consent gate -- only when the user accepted the plan (--yesor interactive confirm). It snapshots the old lockfile state before the pipeline regenerates it, re-parses the on-disk manifest after revision pins are applied, then reconciles (install -> remove stale -> update lockfile).docs/reference/lockfile-spec.md:mcp_servers/lsp_serversare now described as reflecting the manifest "as of the last install or update".Security notes:
apm updatenow writes and prunes runtime MCP/LSP configs the same wayapm installdoes;remove_staleruns as a trust-hygiene step.apm updatehas no--no-policy/--trust-transitive-mcpflags, so both default to the stricter side, andallowExecutablesgating now applies on update too.Known limitation: reconciliation only runs when a plan is applied. An
apm updatewhere nothing changed early-returns and leaves a pre-existing orphan in place until the next applied change (or anapm install); pruning without consent would contradict the command's "no changes applied" promise.Out of scope: flag parity for
--runtime/--exclude/--trust-transitive-mcp/--no-policyonapm update;apm lock(by design it resolves packages only).Fixes #2077
Type of change
Testing
uv run pytest tests/unit tests/test_console.py: 18153 passed. New tests:tests/integration/test_update_mcp_lsp_prune.py(update prunes an orphaned MCP server from the lockfile; dry-run leaves state untouched; a declared LSP server is recorded via update) andtests/unit/install/test_mcp_integration_extraction.py(extraction parity for every branch, includingPolicyBlockErrorpropagation).ruff checkandruff format --checkpass.Spec conformance (OpenAPM v0.1)
If this PR changes behaviour that an OpenAPM v0.1
req-XXXcovers,confirm the three-step ritual (see CONTRIBUTING.md "Adding or
changing a normative requirement"):
docs/src/content/docs/specs/openapm-v0.1.mdupdated(new/changed
<a id="req-XXX"></a>anchor + prose + Appendix Crow).
docs/src/content/docs/specs/manifests/openapm-v0.1.requirements.ymlupdated.
@pytest.mark.req("req-XXX")test undertests/spec_conformance/added or extended.CONFORMANCE.{md,json}regenerated viauv run --extra dev python -m tests.spec_conformance.gen_statementand committed.
apm-spec-waiver: install/mcp/integration.py is a behaviour-preserving extraction of the MCP integration block from commands/install.py (apm install semantics unchanged); the update-side fix alters no req-covered behaviour (req-rs-011/012 untouched)