feat: MCP install target for Claude Code (project + user scope)#655
feat: MCP install target for Claude Code (project + user scope)#655dmartinol wants to merge 17 commits into
Conversation
Signed-off-by: Daniele Martinoli <dmartino@redhat.com>
There was a problem hiding this comment.
Pull request overview
Adds Claude Code as a supported MCP client for apm install, including project vs user scope configuration, and wires scope through install/uninstall + conflict detection.
Changes:
- Introduces
ClaudeClientAdapterand registers it inClientFactory; adds runtime detection forclaude. - Threads
install_scope/workspace_rootthrough MCP install/check/cleanup paths and adds Claude-specific stale cleanup. - Updates docs and adds unit tests for Claude MCP config merge/normalize and stale cleanup.
Reviewed changes
Copilot reviewed 14 out of 14 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| tests/unit/test_runtime_detection.py | Adds unit test to detect claude in scripts runtime detection. |
| tests/unit/test_claude_mcp.py | New unit tests for Claude adapter config pathing, merge/normalize behavior, and stale cleanup. |
| src/apm_cli/security/audit_report.py | Refactors markdown escaping to avoid f-string backslash expression issues. |
| src/apm_cli/registry/operations.py | Threads install_scope/workspace_root into installed-server checks; adds claude/opencode parsing branches. |
| src/apm_cli/integration/mcp_integrator.py | Adds Claude runtime detection, scope threading, and Claude project/user stale cleanup logic. |
| src/apm_cli/factory.py | Registers claude runtime to ClaudeClientAdapter. |
| src/apm_cli/core/safe_installer.py | Passes install_scope through to adapters for scope-sensitive MCP config. |
| src/apm_cli/core/operations.py | Extends install_package API to accept workspace_root/install_scope. |
| src/apm_cli/core/conflict_detector.py | Treats Claude configs as mcpServers schema for conflict detection. |
| src/apm_cli/commands/uninstall/engine.py | Passes workspace_root into MCP stale cleanup after uninstall. |
| src/apm_cli/commands/uninstall/cli.py | Wires deploy root into uninstall MCP cleanup call. |
| src/apm_cli/commands/install.py | Enables MCP install for --global and passes scope/root through to MCP integrator. |
| src/apm_cli/adapters/client/claude.py | New Claude adapter implementing project .mcp.json + user ~/.claude.json behavior and normalization. |
| docs/src/content/docs/integrations/ide-tool-integration.md | Documents Claude MCP config locations and runtime targeting. |
| if runtime_name == "vscode": | ||
| if _is_vscode_available(): | ||
| ClientFactory.create_client(runtime_name) | ||
| installed_runtimes.append(runtime_name) | ||
| elif runtime_name == "cursor": |
There was a problem hiding this comment.
When running with user/global scope, this runtime detection can still include "vscode" (via _is_vscode_available()). That can cause apm install -g to create/update a workspace-local .vscode/mcp.json in the current working directory (VSCodeClientAdapter auto-creates .vscode/ even on read), which breaks the intended scope separation. Consider filtering out workspace-scoped runtimes (vscode/cursor/opencode/claude-project) when install_scope is InstallScope.USER, unless the user explicitly targets them, or ensure repo-local adapters are rooted at workspace_root rather than CWD.
|
@microsoft-github-policy-service agree |
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Signed-off-by: Daniele Martinoli <dmartino@redhat.com>
Signed-off-by: Daniele Martinoli <dmartino@redhat.com>
| if runtime_name == "vscode": | ||
| if _is_vscode_available(): | ||
| ClientFactory.create_client(runtime_name) | ||
| installed_runtimes.append(runtime_name) | ||
| elif runtime_name == "cursor": |
There was a problem hiding this comment.
When running with install_scope=InstallScope.USER (e.g. apm install -g), this runtime detection still includes workspace-scoped clients like VS Code. Since VSCodeClientAdapter uses os.getcwd() and auto-creates .vscode/ in get_config_path(), a global install can end up creating/modifying .vscode/mcp.json in whatever directory the user ran the command from. Consider filtering target runtimes by scope (USER: only home-scoped runtimes like copilot/codex/claude) or implementing workspace_root support so repo-local adapters read/write under that root instead of CWD.
| stored_mcp_configs=old_mcp_configs, | ||
| diagnostics=apm_diagnostics, | ||
| workspace_root=get_deploy_root(scope), | ||
| install_scope=scope, | ||
| ) |
There was a problem hiding this comment.
workspace_root is documented as the root for repo-local MCP configs, but get_deploy_root(InstallScope.USER) is Path.home(). Passing the home directory here makes runtime detection/checks look for .cursor/ / .opencode/ / .claude/ under $HOME, and does not prevent repo-local adapters from writing under the process CWD. It would be safer to pass Path.cwd() for workspace_root (repo-local), and rely on install_scope for Claude user-vs-project behavior, or otherwise ensure only user-scoped runtimes run in USER installs.
| wr = workspace_root if workspace_root is not None else Path.cwd() | ||
|
|
||
| # Determine which runtimes to clean, mirroring install-time logic. | ||
| all_runtimes = {"vscode", "copilot", "codex", "cursor", "opencode"} | ||
| all_runtimes = {"vscode", "copilot", "codex", "cursor", "opencode", "claude"} | ||
| if runtime: |
There was a problem hiding this comment.
In USER scope, workspace_root will typically be Path.home(), but remove_stale() still considers workspace-scoped runtimes (vscode/cursor/opencode) in all_runtimes. This can cause stale cleanup during apm install -g / user-scope uninstall to touch unexpected paths (and some adapters create directories on read). Consider deriving all_runtimes from install_scope (USER: home-scoped runtimes only) or requiring an explicit --runtime for workspace-scoped cleanup.
sergio-sisternes-epam
left a comment
There was a problem hiding this comment.
Great work adding Claude Code as a first-class MCP target — the adapter design (reusing CopilotClientAdapter with normalization) and the opt-in project scope (.claude/ gate) are well thought out.
There are a few issues to address before we can merge:
1. Scope leakage on apm install -g (blocking)
workspace_root is threaded through 3 APIs but explicitly ignored (_ = workspace_root in safe_installer.py, registry/operations.py, and one more). During apm install -g, runtime detection still includes workspace-scoped clients (vscode, cursor, opencode), which could write to CWD-relative config files. This PR widens that surface by enabling MCP install for --global.
Fix: filter out workspace-scoped runtimes when install_scope is InstallScope.USER. Only home-scoped runtimes (copilot, codex, claude-user) should be eligible for global installs. Either implement workspace_root properly or remove the parameter to avoid a misleading API — accepted-but-ignored parameters are a bug magnet.
2. Stale cleanup breaks opt-in contract (blocking)
Claude project install is opt-in (only writes .mcp.json when .claude/ exists), but stale cleanup removes entries from .mcp.json whenever it exists, even if .claude/ is absent. Gate project cleanup on .claude/ existing, same as install, to keep behavior consistent.
3. CLI output convention (non-blocking)
Stale cleanup messages use hardcoded "+" instead of the repo's STATUS_SYMBOLS convention ([+] via _rich_info with symbol="check"). Minor, but worth aligning for consistency.
4. Missing CHANGELOG entry
Please add an entry under ## [Unreleased]:
### Added
- Add Claude Code as MCP install target with project (`.mcp.json`) and user (`~/.claude.json`) scope support (#643)
5. Review Copilot's feedback
Copilot flagged the scope leakage across 5 separate threads with concrete suggestions for each affected location. Please review and address those comments — they map directly to the concerns above and provide useful implementation guidance.
Thanks for the thorough contribution — the feature is valuable, just needs the scope boundaries tightened.
The chrome-devtools MCP is wired up via three artifacts at project root (.mcp.json, justfile recipe, shell.nix env var) because APM's current claude-code runtime adapter lacks MCP support. Once microsoft/apm#655 merges and juspay's fork picks it up, we can fold this into apm.yml's `dependencies.mcp` and let `just ai::apm` regenerate .mcp.json.
Encapsulate what can be encapsulated today. The justfile recipe moves from project-root `justfile` into `agents/ai.just` (sibling to the rest of the APM/agent dev tooling), and `shell.nix` reverts its `KOLU_CHROME_EXECUTABLE` addition — the recipe now resolves Chrome-for-Testing inline by globbing `$PLAYWRIGHT_BROWSERS_PATH/chromium-*/chrome-linux64/chrome`. `.mcp.json` stays at project root (that's where Claude Code reads it from) and its command updates to `just ai::mcp-chrome-devtools` via the existing `mod ai 'agents/ai.just'` import. Full encapsulation — declaring the MCP server inside `agents/apm.yml` and letting `just ai::apm` regenerate `.mcp.json` — is blocked on microsoft/apm#655 (Claude Code MCP adapter) landing in juspay's fork. The follow-up TODO is captured in the recipe's doc comment. Trade-off: the hickey-iteration's elegant Nix-eval-time chrome-path resolution (read browsers.json at eval, fail loud on layout change) is dropped in favor of a runtime shell glob. Failure mode shifts from "fails at `nix develop` eval" to "empty --executable-path at MCP startup → chrome-devtools-mcp errors out loud" — different failure point, still loud, and keeps `shell.nix` free of MCP-specific env vars.
Prototype for #518. Wires the official [chrome-devtools-mcp](https://github.com/ChromeDevTools/chrome-devtools-mcp) server so **Claude (and any other MCP client) can drive a real headless Chrome** against Kolu's own dev server — `evaluate_script`, `list_console_messages`, `take_screenshot`, network inspection, performance traces, all 29 tools. Solves the recurring "I'm guessing at CSS cascade / computed styles" round-trip that inspired the issue. The wiring lives in three places. `.mcp.json` at the repo root is the bit Claude Code actually reads — Claude starts _outside_ the nix devshell, so the `nix develop --command just ai::mcp-chrome-devtools` wrapper bridges into a shell where `npx`/`node` and the Playwright-provided Chrome-for-Testing are on `PATH`. The launcher recipe sits in `agents/ai.just` alongside the rest of the APM/agent dev tooling, and resolves Chrome's binary inline by globbing `$PLAYWRIGHT_BROWSERS_PATH/chromium-*/chrome-linux64/chrome` so there's no shell.nix surface area to maintain. _Reuses Playwright's Chrome-for-Testing 143, which chrome-devtools-mcp officially supports — no new browser dependency, just the Node package fetched by `npx`._ End-to-end smoke verified: JSON-RPC `initialize` returns `chrome_devtools` v0.21.0 with `tools.listChanged` capability advertised. Navigated to `http://localhost:7681`, took an a11y snapshot, evaluated `document.querySelectorAll('[data-terminal-id]')` to probe live sidebar state — all 29 tools work as expected. Warm `nix develop` eval stays at ~0.25s. > **Follow-up encapsulation is gated on [microsoft/apm#655](microsoft/apm#655 Once that PR (Claude Code MCP adapter) merges and lands in juspay's fork, the `chrome-devtools` entry folds into `agents/apm.yml`'s `dependencies.mcp` and `.mcp.json` becomes a generated artifact of `just ai::apm`. The TODO is captured in the `mcp-chrome-devtools` recipe's doc comment. > **Telemetry note.** `chrome-devtools-mcp` sends usage statistics to Google by default, and performance traces send URLs to the Chrome CrUX API. Left default-on for this prototype — if unwanted, add `--no-usage-statistics --no-performance-crux` to the recipe before merge. Refs #518.
- Add WORKSPACE_SCOPED_MCP_RUNTIMES / HOME_SCOPED_MCP_RUNTIMES constants - Reject --global with workspace runtimes (vscode/cursor/opencode) - Filter detected runtimes for apm install -g; adjust VS Code fallback - Mirror filtering in remove_stale when install_scope is USER Addresses PR microsoft#655 review (scope leakage on global install). Made-with: Cursor
Match install opt-in: only edit project .mcp.json when .claude/ exists. PR microsoft#655 Made-with: Cursor
Use _rich_info(..., symbol="check") instead of hardcoded '+' prefix. PR microsoft#655 Made-with: Cursor
PR microsoft#655 / microsoft#643 Made-with: Cursor
Document that repo-local paths use process cwd and USER-scope installs filter workspace runtimes upstream. PR microsoft#655 Made-with: Cursor
- Require .claude/ for project stale cleanup tests; add opt-in skip test - Assert USER-scope remove_stale does not touch .vscode/mcp.json - Assert apm install -g with --runtime vscode raises - Normalize newlines in global-no-manifest install test (Rich wrap) PR microsoft#655 Made-with: Cursor
|
Thanks for the review. Addressed all blocking items:
Global MCP scope: User-scope installs only consider home-scoped runtimes (copilot, codex, claude). Workspace/CWD clients (vscode, cursor, opencode) are excluded from auto-detection and
Claude project cleanup: Stale removal for project
Messages: Stale MCP lines use
CHANGELOG [Unreleased] Added line added as requested.
workspace_root: Documented that adapters still use |
danielmeppiel
left a comment
There was a problem hiding this comment.
Comparative architectural pass against the existing 5 MCP targets (vscode/copilot/cursor/opencode/codex): the adapter itself is well-formed and follows the Cursor/OpenCode inheritance pattern exactly, the .claude/ opt-in gate mirrors .cursor/, and _normalize_for_claude_code is the same shape as OpenCode's _to_opencode_format. Format normalization correctly lives in the adapter. Good work overall.
Flagging four PR-specific items that should land before merge. The broader integration-layer scaling concerns (per-runtime if/elif chains in remove_stale, _get_installed_server_ids, conflict detection) are pre-existing patterns shared by all 5 existing targets, so they're being filed as a separate architectural issue rather than blocking this PR.
Blocking
1. apm install -g auto-targets Claude without an availability check — src/apm_cli/integration/mcp_integrator.py:955-959, 983-989
In user scope, claude is appended to the runtime list purely because ClientFactory.create_client("claude") succeeds. There's no shutil.which("claude") (or equivalent) check. Existing apm install -g users who don't use Claude Code will suddenly get ~/.claude.json created or modified.
Suggest gating Claude (and ideally any home-scoped runtime) on real binary availability, mirroring how _detect_runtimes already uses command-line presence for the workspace-scoped path.
2. Project-scope Claude install is a silent "success" no-op when .claude/ is absent — src/apm_cli/adapters/client/claude.py:181-185, 227-229 and src/apm_cli/integration/mcp_integrator.py:1233-1242
If .claude/ does not exist, update_config returns and configure_mcp_server returns True. The integrator counts it as configured and the install summary reports success, but no .mcp.json is written. From a user's perspective, apm install --runtime claude says it worked when it didn't.
Two options: (a) return an explicit skip state that the integrator recognizes and excludes from the success count, or (b) emit an actionable info message such as "Skipping Claude project config: no .claude/ directory found. Create .claude/ to opt in, or use apm install -g for user scope." Cursor and OpenCode have the same silent-skip semantic — worth fixing the pattern here once and aligning the others in a follow-up.
3. Stale cleanup fails open when install_scope=None — src/apm_cli/integration/mcp_integrator.py:710-716, 748-776
When install_scope is missing, Claude cleanup is allowed to touch both project .mcp.json AND user ~/.claude.json. With name overlap between scopes, a project-scope operation can delete user-level entries (or vice versa). Suggest failing closed: if scope is unspecified, default to a single scope rather than both, and log clearly which scope was chosen.
4. Non-ASCII em-dash in new source comment — src/apm_cli/integration/mcp_integrator.py (new code near the top of the file)
# target them — see ``install()`` and ``remove_stale()``. uses U+2014 (—). Repo encoding rule requires printable ASCII (U+0020–U+007E). Replace with --. Trivial fix; the file is otherwise ASCII-clean on main.
Non-blocking suggestion (raised for visibility, not gating)
mcp_install_scope is set on the adapter via attribute assignment from 4+ call sites and read with getattr(self, "mcp_install_scope", None). This is the first adapter to carry runtime-mutable state and the first with dual config paths. It works defensively today, but as more adapters need per-invocation configuration the implicit contract will hurt. A future refactor could either thread scope through ClientFactory.create_client(**kwargs) or pass it as an argument to update_config / configure_mcp_server. No need to block on it here.
Tracked separately
The broader integration-layer coupling (per-runtime branches in mcp_integrator.remove_stale, registry/operations._get_installed_server_ids, and core/conflict_detector.get_existing_server_configs that bypass the adapter abstraction and re-implement file I/O) is being filed as a separate refactor issue. This is the second target after VS Code where it's becoming visibly painful, and APM's broader install-target surface is growing faster than MCP coverage of it.
**Chrome DevTools MCP is now declared in the top-level `apm.yml`, but this branch keeps project-local fallbacks for the runtimes APM does not fully cover yet.** Claude still reads the checked-in root `.mcp.json`, and Codex now gets a checked-in `.codex/config.toml` for trusted projects. That lets the repo use one MCP server definition across the current tool mix without pretending upstream support is further along than it is. **The split is explicit instead of accidental.** `apm.yml` and `apm.lock.yaml` describe the `chrome-devtools` server for the runtimes APM wires up today, OpenCode keeps the generated root `opencode.json`, and `agents/ai.just` now documents the manual fallbacks plus the upstream TODOs: `microsoft/apm#655` for Claude and `microsoft/apm#803` for project-scoped Codex MCP. **`apm-sync` is tighter in this branch than on `master`.** It now verifies the root-level `opencode.json` that APM emits for OpenCode MCP config, using semantic JSON comparison so newline trivia does not create drift. It also deliberately ignores the manual `.codex/config.toml` until `microsoft/apm#803` lands, and ignores local OpenCode runtime noise (`node_modules`, `package.json`, `package-lock.json`, `.gitignore`) so the check stays focused on APM-managed output. Verification on commit `ee374f5abaff9b93931be0ee65e9f505dfc77feb`: - `just fmt` - `just ai::apm-sync` - `just ci::fmt` - `just ci::apm-sync` Upstream references: - `microsoft/apm#655` — Claude project/user MCP support (still unmerged) - `microsoft/apm#803` — Codex project-scoped MCP support (open)
|
Hi @dmartinol -- friendly check-in: this PR has been in Concretely, the outstanding feedback is: Let us know either way -- a quick "still on it" or "going to close it" reply is enough. Thanks for the contribution! |
Hi, thanks for reminding me @danielmeppiel and sending this kind update. I really apologize for the late reply. Thanks! |
|
Hi! I subscribed to this issue as I was tracking when this would close. This is important to my workflow, and if the original PR author is unable to continue, I would like a chance to address the review comments and bring this to a conclusion. Give me a couple of days to familiarize myself with the changes, and I'll try to have an update over the weekend. |
|
We will take over this work and ship on hte next milestone ASAP |
APM Review Panel:
|
| Persona | B | R | N | Takeaway |
|---|---|---|---|---|
| Python Architect | 0 | 5 | 2 | Adapter shape is sound; mcp_install_scope monkey-patching and procedural remove_stale growth are the main architectural debts to address before next MCP targets land. |
| Cli Logging Expert | 0 | 3 | 3 | Four bare print() calls bypass rich* helpers; project-scope silent no-op misleads install summary; em-dash violates encoding rule. |
| Devx Ux Expert | 0 | 6 | 2 | Silent success on missing .claude/ breaks install-reports-what-it-did contract; -g auto-targets Claude without binary check; gemini runtime regressed by stale branch; cli-commands.md not updated. |
| Supply Chain Security Expert | 0 | 5 | 2 | Stale cleanup with install_scope=None cleans BOTH scopes (cross-scope deletion); ~/.claude.json read-modify-write has no atomic-write or permission guard; no path-security helpers used for new file targets. |
| Oss Growth Hacker | 0 | 2 | 2 | Ship #643 via maintainer takeover with Co-authored-by credit; CHANGELOG needs @dmartinol attribution; release note should lead with 'apm install --runtime claude' one-liner. |
| Auth Expert | 0 | 1 | 1 | No auth bypass or credential leak; shallow-merge on ~/.claude.json preserves non-MCP keys. Monkey-patched scope attr is a mis-scope risk. |
| Doc Writer | 0 | 6 | 1 | Doc edit on the touched file is solid; main gap is drift in the apm-usage skill (commands.md), the user-scope deployment callout, and the what-is-apm capability table -- none updated to reflect Claude MCP scope semantics. |
| Test Coverage Expert | 0 | 3 | 2 | Claude adapter has good unit coverage for merge/normalize; missing regression traps for scope=None stale cleanup, Claude availability gating, and conflict-detector Claude branch. |
B = blocking-severity findings, R = recommended, N = nits.
Counts are signal strength, not gates. The maintainer ships.
Top 5 follow-ups
- [Supply Chain Security Expert] Promote mcp_install_scope from monkey-patched setattr to explicit constructor parameter on ClaudeClientAdapter (or base adapter) -- 4 of 8 panelists flagged this independently. Scope-routing determines whether APM writes to ~/.claude.json or .mcp.json; a silent None default sends writes to the wrong file. Single highest-leverage fix.
- [Test Coverage Expert] Add regression-trap tests for scope=None stale cleanup, Claude auto-detection gating, and conflict-detector Claude branch (3 missing tests with evidence blocks) -- All three carry outcome=missing evidence on secure-by-default / governed-by-policy surfaces. Per arbitration rules, missing tests on these surfaces rank at blocking-tier. Cheap to write.
- [Devx Ux Expert] Gate Claude auto-detection behind shutil.which('claude') or ~/.claude/ probe; explicit --runtime claude bypasses the gate -- Without this, apm install -g creates ~/.claude.json for users who never installed Claude Code -- a surprise home-directory mutation. Prior review item Why do we need a GitHub token? #1, confirmed by 3 panelists.
- [Supply Chain Security Expert] Use atomic write (write-to-temp + os.replace) and 0o600 permissions for ~/.claude.json -- Shared config file written by Claude Code itself. Non-atomic write risks silent data loss on concurrent access; default 0644 permissions expose potential OAuth state on multi-user systems.
- [Devx Ux Expert] Fix project-scope silent no-op: return skipped (not True) when .claude/ absent, emit user-visible skip message -- Install summary currently lies ('configured 1 runtime' when 0 files were written). Prior review item Integrate copilot runtime #2, confirmed by 5 panelists. One-line fix with outsized UX impact.
Architecture
classDiagram
direction TB
class MCPClientAdapter {
<<Abstract>>
+project_root Path
+supports_user_scope bool
+get_config_path()* str
+update_config(config_updates)* bool
+get_current_config()* dict
+configure_mcp_server(server_url, ...)* bool
}
class CopilotClientAdapter {
+registry_client SimpleRegistryClient
+_format_server_config(server_info, env, rt_vars) dict
+get_config_path() str
+update_config(config_updates) bool
+get_current_config() dict
}
class CursorClientAdapter {
+get_config_path() str
+update_config(config_updates)
+get_current_config() dict
}
class OpenCodeClientAdapter {
+get_config_path() str
+update_config(config_updates, enabled)
+get_current_config() dict
}
class ClaudeClientAdapter {
+_normalize_mcp_entry_for_claude_code(entry) dict
+_merge_mcp_server_dicts(existing, updates)
+_is_user_scope() bool
+get_config_path() str
+update_config(config_updates, enabled) bool
+get_current_config() dict
+configure_mcp_server(...) bool
}
class ClientFactory {
<<Factory>>
+create_client(client_type, project_root, user_scope) MCPClientAdapter
}
class SafeMCPInstaller {
+adapter MCPClientAdapter
+conflict_detector MCPConflictDetector
+install_server(server_ref, ...) dict
}
class MCPConflictDetector {
+adapter MCPClientAdapter
+get_existing_server_configs() dict
}
MCPClientAdapter <|-- CopilotClientAdapter
CopilotClientAdapter <|-- CursorClientAdapter
CopilotClientAdapter <|-- OpenCodeClientAdapter
CopilotClientAdapter <|-- ClaudeClientAdapter
ClientFactory ..> MCPClientAdapter : creates
SafeMCPInstaller *-- MCPClientAdapter : adapter
SafeMCPInstaller *-- MCPConflictDetector : detector
MCPConflictDetector *-- MCPClientAdapter : adapter
class ClaudeClientAdapter:::touched
class ClientFactory:::touched
class SafeMCPInstaller:::touched
class MCPConflictDetector:::touched
classDef touched fill:#fff3b0,stroke:#d47600
note for ClaudeClientAdapter "Dual scope: .mcp.json (project)\n~/.claude.json (user)\nmcp_install_scope set post-construction"
note for MCPConflictDetector "if/elif on adapter class name\nto extract server section"
flowchart TD
A["apm install --runtime claude"] --> B["commands/install.py\nResolve InstallScope"]
B --> C{"InstallScope?"}
C -->|USER| D["get_deploy_root(USER)\n= ~/.apm"]
C -->|PROJECT| E["get_deploy_root(PROJECT)\n= cwd"]
D --> F["MCPIntegrator.install()\nworkspace_root, install_scope"]
E --> F
F --> G{"Runtime detection"}
G -->|"--runtime claude"| H["Single runtime mode"]
G -->|"auto-detect"| I{"Scope filter"}
I -->|USER| J["Filter out WORKSPACE_SCOPED_MCP_RUNTIMES\n(vscode, cursor, opencode)"]
I -->|PROJECT| K["All detected runtimes"]
J --> L["_install_for_runtime()"]
K --> L
H --> L
L --> M["SafeMCPInstaller(runtime)\nadapter.mcp_install_scope = scope"]
M --> N["ClientFactory.create_client('claude')"]
N --> O["ClaudeClientAdapter"]
O --> P{"_is_user_scope?"}
P -->|Yes| Q["[FS] Write ~/.claude.json\n_merge_user_mcp()"]
P -->|No| R{"[FS] .claude/ exists?"}
R -->|Yes| S["[FS] Write .mcp.json\nmerge + normalize"]
R -->|No| T["Silent no-op\n(returns True)"]
sequenceDiagram
participant User
participant CLI as commands/install.py
participant MCP as MCPIntegrator.install()
participant SF as SafeMCPInstaller
participant CF as ClientFactory
participant CA as ClaudeClientAdapter
participant FS as ~/.claude.json
User->>CLI: apm install -g --runtime claude
CLI->>CLI: scope = InstallScope.USER
CLI->>MCP: install(mcp_deps, runtime='claude', install_scope=USER)
MCP->>MCP: Guard: reject WORKSPACE_SCOPED_MCP_RUNTIMES
MCP->>MCP: target_runtimes = ['claude']
loop each dep
MCP->>SF: SafeMCPInstaller('claude', install_scope=USER)
SF->>CF: create_client('claude')
CF-->>SF: ClaudeClientAdapter instance
SF->>CA: adapter.mcp_install_scope = USER
SF->>CA: install_server(dep)
CA->>CA: _is_user_scope() -> True
CA->>FS: read ~/.claude.json
CA->>CA: _merge_and_normalize_updates()
CA->>FS: write ~/.claude.json
CA-->>SF: True
end
MCP-->>CLI: configured_count
Recommendation
The PR cannot merge as-is: 11 of 16 files conflict against main, gemini runtime would regress, and 4 prior review items + the monkey-patched scope-routing + 3 missing regression-trap tests on security-sensitive surfaces are substantive gaps confirmed by broad panelist convergence. The recommended path is maintainer takeover: open a fresh branch from main, cherry-pick dmartinol's commits, resolve conflicts, fix the top-9 items from this panel (scope constructor, auto-detection gate, atomic write, silent no-op, print() sanitization, docs, tests, em-dash, CHANGELOG credit), and merge with Co-authored-by: dmartinol <dmartinol@users.noreply.github.com>. Close #655 with a 'shipped via #NNN' comment. The _remove_stale_json_keys extraction and conflict-detector polymorphism can follow as fast-follow PRs. Unblocks the highest-demand open issue.
Full per-persona findings
Python Architect
-
[recommended] mcp_install_scope set via post-construction attribute assignment from 4+ call sites -- no contract in the base class at
src/apm_cli/core/safe_installer.py:518
MCPClientAdapter.init declares project_root and user_scope as constructor params, but install_scope is injected via bare attribute assignment (self.adapter.mcp_install_scope = install_scope) in SafeMCPInstaller.init, _check_self_defined_servers_needing_installation, _get_installed_server_ids, and at least one more site. The attribute is read by ClaudeClientAdapter._is_user_scope via getattr with a None fallback. No type annotation, no init slot, no ABC declaration. Every future scope-aware adapter must rediscover this protocol by reading SafeMCPInstaller internals. This is a composition-over-inheritance smell: the scope should flow through the factory or constructor, not be monkey-patched. Pattern: the base class already has user_scope: bool -- extending it to accept the richer InstallScope enum (or a scope parameter in the factory) would formalize the contract in one place.
Suggested: Add install_scope to MCPClientAdapter.init (defaulting to None) and pass it through ClientFactory.create_client. ClaudeClientAdapter._is_user_scope then reads self.install_scope instead of getattr(self, 'mcp_install_scope', None). Remove all post-construction assignments. This is a bounded change (~15 lines across base.py, factory.py, safe_installer.py) and prevents every future adapter from needing the same monkey-patch. -
[recommended] remove_stale grows another ~70 lines of inline JSON-read/delete/write for Claude -- now 6 runtime-specific blocks with identical shape at
src/apm_cli/integration/mcp_integrator.py
Each MCP runtime in remove_stale (vscode, copilot, codex, cursor, claude-project, claude-user, opencode) duplicates the same pattern: read JSON/TOML -> find stale keys -> delete -> write back -> log. Claude adds two blocks (project + user), pushing the method past 200 lines. The next runtime added will require another copy. This is the textbook Open-Closed Principle violation that the prior review flagged as 'tracked separately'. While a full Strategy refactor is out of scope for this PR, the Claude addition makes the debt acute enough that a MINIMAL bounded refactor IS practical: extract a _remove_stale_json_keys(path, wrapper_key, stale_names, logger, label) helper that all JSON-based runtimes (vscode, cursor, claude-project, claude-user, copilot) can call. That would cut ~100 lines from this PR and make the next addition a one-liner.
Suggested: Extract: def _remove_stale_json_keys(path: Path, wrapper_key: str, stale: set, logger, label: str) -> None that encapsulates read-JSON / delete-keys / write-JSON / log. The vscode, cursor, copilot, claude-project, claude-user blocks all become one-line calls. Codex (TOML) and opencode (nested mcp key) stay inline or get their own small helper. -
[recommended] ClaudeClientAdapter._workspace_root() duplicates base MCPClientAdapter.project_root property at
src/apm_cli/adapters/client/claude.py:136
The base class MCPClientAdapter already exposes self.project_root (returns explicit root or cwd). ClaudeClientAdapter adds _workspace_root() -> Path(os.getcwd()), which is equivalent when no project_root was supplied. But it ignores any explicit project_root that was passed to the constructor, creating a divergence from cursor.py and opencode.py which both use self.project_root. If a future caller passes project_root to ClientFactory.create_client('claude'), the Claude adapter will silently ignore it. This violates the adapter contract.
Suggested: Replace _workspace_root() with self.project_root throughout claude.py. Delete the _workspace_root method. -
[recommended] workspace_root threaded through 8+ signatures but discarded at every leaf with _ = workspace_root at
src/apm_cli/integration/mcp_integrator.py
The PR adds workspace_root to install(), remove_stale(), _install_for_runtime(), _check_self_defined_servers_needing_installation(), check_servers_needing_installation(), get_installed_server_ids(), install_package(), SafeMCPInstaller.init. At every adapter-touching leaf, it is immediately discarded: ' = workspace_root'. This is dead parameter threading -- it inflates the API surface for no runtime effect. The adapters already resolve paths from cwd (or soon from self.project_root). If workspace_root is needed for the non-adapter code in remove_stale (the inline JSON munging for vscode/cursor paths), pass it only there. Do not thread a parameter through 8 layers only to discard it at each one.
Suggested: Keep workspace_root only on install() and remove_stale() (the two entry points that use it for direct path resolution of .vscode/mcp.json and .cursor/mcp.json). Remove it from _install_for_runtime, install_package, SafeMCPInstaller, _check_self_defined_servers_needing_installation, check_servers_needing_installation, _get_installed_server_ids. These intermediate layers do not use it. -
[recommended] conflict_detector.get_existing_server_configs uses class-name substring matching -- fragile and compounds per runtime at
src/apm_cli/core/conflict_detector.py:117
get_existing_server_configs inspects adapter_class_name via string containment ('claude' in adapter_class_name, 'cursor' in adapter_class_name, etc.) to determine the config wrapper key. This is the same OCP violation as remove_stale but in a different module. The adapter itself knows its own config structure -- the extraction logic should live on the adapter, not in the conflict detector. A get_server_section(config: dict) -> dict method on MCPClientAdapter would move the knowledge to the adapter and eliminate the if/elif chain entirely.
Suggested: Add an abstract or default method on MCPClientAdapter: def extract_servers(self, config: dict) -> dict. Each adapter overrides it (CopilotClientAdapter returns config.get('mcpServers', {}), VSCodeClientAdapter returns config.get('servers', {}), etc.). conflict_detector calls self.adapter.extract_servers(existing_config). One-line per adapter, zero if/elif in the detector. -
[nit] Prior review item Why do we need a GitHub token? #1 confirmed: apm install -g auto-targets Claude without shutil.which availability check
In the ImportError fallback path (mcp_integrator.py lines ~983-989 in the diff), USER scope unconditionally calls ClientFactory.create_client('claude') and appends to installed_runtimes. Copilot and Codex use shutil.which(runtime_name); Claude skips it. Users who run apm install -g but do not have Claude Code installed will get ~/.claude.json created. Already flagged in prior review; confirming it remains unaddressed. -
[nit] Prior review item Will there be MCP coverage? #3 confirmed: remove_stale with install_scope=None cleans BOTH project and user Claude configs
In remove_stale (diff lines ~710-716), when install_scope is neither USER nor PROJECT (i.e. None), the code sets clean_claude_user = clean_claude_project = True. A project-scope caller that neglects to pass install_scope will also remove user-level entries. The default should fail closed (skip Claude cleanup when scope is unknown) rather than fail open. Already flagged in prior review; confirming it remains.
Cli Logging Expert
-
[recommended] claude.py configure_mcp_server uses bare print() instead of _rich_success/_rich_error/_rich_warning at
src/apm_cli/adapters/client/claude.py:224
Lines 224, 237, 252, 256 of the new claude.py use bare print() for error, success, and failure messages. CLI architecture mandates _rich_success/_rich_error/_rich_warning from console.py so output respects color, symbol, and quiet/verbose modes. While inherited from CopilotClientAdapter (pre-existing tech debt), a new file should not propagate it. The four calls bypass STATUS_SYMBOLS, color theming, and any future structured-logging sink.
Suggested: Replace print('Error: server_url cannot be empty') with _rich_error('server_url cannot be empty'). Repeat for lines 237, 252, 256. Import the helpers from apm_cli.utils.console. -
[recommended] Project-scope install reports success when .claude/ is absent and no .mcp.json is written (silent no-op) at
src/apm_cli/adapters/client/claude.py:183
update_config (line 183-184) and configure_mcp_server (line 227-228) both return True when _should_write_project() is False. The caller treats True as 'configured' and increments configured_count. Install summary then prints '[+] Configured N MCP servers' including the phantom Claude entry. Anti-pattern: 'Installation complete when nothing was installed'. Minimum-viable fix: when _should_write_project() returns False, emit a [!] skip line naming the server and reason, and return a distinguishable result (False or sentinel) so the counter is not incremented.
Suggested: Replace 'return True' on the skip path with: _rich_warning(f'Skipped Claude Code project MCP -- .claude/ not found'); return False. Or return a 'skipped' sentinel and adjust the install summary to show skip count separately. -
[recommended] Stale cleanup cleans BOTH Claude scopes when install_scope is None -- no diagnostic output at
src/apm_cli/integration/mcp_integrator.py
In remove_stale around the new Claude cleanup block, when install_scope is None the code sets clean_claude_user = clean_claude_project = True. An unscoped caller can delete user-level entries when only project entries were intended. From CLI-output perspective: no info/warning telling user which scope(s) are being cleaned. User sees 'Removed stale MCP server X from ~/.claude.json' without knowing cleanup was unscoped by default.
Suggested: Add before the Claude cleanup block: if install_scope is None: _rich_info('Cleaning Claude MCP servers in both project and user scope (no explicit scope)') -
[nit] Em-dash (U+2014) in new source comment violates encoding rule at
src/apm_cli/integration/mcp_integrator.py
Line ~566 of the diff (mcp_integrator.py source comment near WORKSPACE_SCOPED_MCP_RUNTIMES) contains an em-dash. encoding.instructions.md requires printable ASCII only (U+0020-U+007E). Prior review item Add ARM64 Linux support to CI/CD pipeline #4 already flagged this; confirming it is still present.
Suggested: Replace the em-dash with ' -- ' (space-hyphen-hyphen-space). -
[nit] Stale-removal messages use _rich_info with symbol='check' -- semantic mismatch at
src/apm_cli/integration/mcp_integrator.py
Stale-removal messages use _rich_info (blue) with symbol='check' ([+]). Traffic-light rule: [+] green = success, [i] blue = informational. A removal is action confirmation, not info. Should be _rich_success with symbol='check'.
Suggested: Change _rich_info(..., symbol='check') to _rich_success(..., symbol='check') for all stale-removal confirmation lines. -
[nit] No scope label in Claude stale-removal messages -- user cannot tell project vs user at
src/apm_cli/integration/mcp_integrator.py
When both project and user Claude cleanup run, messages say 'from .mcp.json' and 'from ~/.claude.json' which is mechanically correct but does not name the scope concept. For dual-scope ops the messages should include the scope to help debugging.
Suggested: Append ' (project scope)' and ' (user scope)' to the respective removal messages.
Devx Ux Expert
-
[recommended] Project-scope Claude install silently succeeds when .claude/ is absent -- install summary lies at
src/apm_cli/adapters/client/claude.py:183
claude.py configure_mcp_server returns True and update_config returns True (silently) when .claude/ does not exist. The integrator counts this as 'configured'; the install summary reports success. But no .mcp.json was written. This violates 'install adds, never silently mutates'. npm install does not say 'installed 3 packages' when 0 were written. The lockfile records MCP servers as configured that are not on disk; subsequent uninstall finds nothing to remove; idempotent re-install sees 'already configured' for a server never configured. Cursor and OpenCode have the same pattern -- fixing it here sets the precedent.
Suggested: Return False (or a sentinel) from update_config/configure_mcp_server when .claude/ is absent, and have the integrator tally it as 'skipped (no .claude/ directory)' with a user-visible info message naming the next action: 'Create .claude/ or run claude to initialize Claude Code'. -
[recommended]
apm install -gauto-targets Claude without checking whether claude binary is installed atsrc/apm_cli/integration/mcp_integrator.py:955
At the auto-detection step (diff lines 955-959 RuntimeManager path, 983-989 ImportError fallback), Claude is added to installed_runtimes whenever ClientFactory.create_client('claude') succeeds -- which always succeeds because it just instantiates the adapter class. Copilot/Codex use is_runtime_available() or shutil.which() as a gate. A user who runsapm install -g some-packagetoday and does NOT have Claude Code installed will suddenly get ~/.claude.json created or modified. That is a surprise side-effect on the user's home directory for a tool they never opted into.
Suggested: Gate Claude auto-detection behind shutil.which('claude') or a~/.claude/home-dir probe, consistent with how Cursor uses .cursor/ and VS Code uses code on PATH. Explicit--runtime claudeshould bypass the gate. -
[recommended] PR branch drops gemini runtime -- regression vs current main at
src/apm_cli/integration/mcp_integrator.py
Current main already includes 'gemini' in the runtime detection loop, factory imports, and _detect_runtimes(). The PR's diff shows runtime lists that enumerate copilot/codex/vscode/cursor/opencode/claude but omit gemini. The --runtime help text also omits it. With 11 conflicting files this is a merge-base staleness issue; the PR as-diffed would regress gemini support. The conflict resolution must preserve gemini.
Suggested: Rebase onto current main and include 'gemini' in all runtime enumeration lists, detection loops, help text, and docs. -
[recommended] Stale cleanup with install_scope=None defaults to cleaning BOTH user and project Claude configs at
src/apm_cli/integration/mcp_integrator.py
In remove_stale (diff lines 710-716), when install_scope is None, the code sets clean_claude_user=True and clean_claude_project=True. An ambiguous-scope operation will delete MCP entries from both ~/.claude.json AND .mcp.json. Safe default is 'touch nothing you are not sure about' (default-closed), not 'touch everything' (default-wide). A project uninstall should never reach into the user's home-dir config and vice versa.
Suggested: When install_scope is None, default to PROJECT scope only, with a debug log, or raise ValueError to force callers to be explicit. -
[recommended] cli-commands.md not updated with Claude runtime -- doc-sync rule violation at
docs/src/content/docs/reference/cli-commands.md:91
doc-sync rule 4 requires every CLI change to commands/flags is reflected in docs/src/content/docs/reference/cli-commands.md and packages/apm-guide/.apm/skills/apm-usage/commands.md in the same PR. The PR adds 'claude' to --runtime values and changes -g semantics but does not update either file.
Suggested: Add claude (and cursor, opencode if missing) to the --runtime option doc. Update --global flag description to mention Claude user scope. Update commands.md in the apm-guide skill. -
[recommended] mcp_install_scope is monkey-patched onto adapter instances -- fragile coupling at
src/apm_cli/core/safe_installer.py:75
safe_installer.py sets self.adapter.mcp_install_scope = install_scope as runtime attribute injection. ClaudeClientAdapter reads via getattr(self, 'mcp_install_scope', None). No type-safety, no base-class declaration, silently defaults to None (project scope) if any code path forgets to set it. Cursor declares supports_user_scope: bool = False as class attribute; Claude should declare supports_user_scope: bool = True and mcp_install_scope should be a proper constructor parameter or base-class attribute, not a monkey-patch.
Suggested: Add mcp_install_scope: InstallScope | None = None as constructor parameter (or class attribute) on the base CopilotClientAdapter or on ClaudeClientAdapter. Pass through SafeMCPInstaller's constructor cleanly. -
[nit] workspace_root accepted then immediately discarded with
_ = workspace_rootin 3+ locations atsrc/apm_cli/core/safe_installer.py:72
Pattern of accepting a parameter and immediately discarding it (_ = workspace_root) in safe_installer.py, _check_self_defined_servers, and registry/operations.py creates confusing API surface. A developer reading the signature thinks workspace_root does something. Either use it (pass to the adapter constructor) or remove it. Looks like an unfinished refactor. -
[nit] First-run guidance for global Claude install could be more discoverable
When no home-scoped runtimes are found for global install, the fallback message says 'Install one with: apm runtime setup copilot'. Does not mention Claude Code as an option even though the PR adds it. A user who came here specifically for Claude gets no guidance.
Suggested: Extend the hint to: 'Install one with: apm runtime setup copilot -- or install Claude Code (https://claude.ai/code) to use apm install -g --runtime claude'.
Supply Chain Security Expert
-
[recommended] remove_stale with install_scope=None defaults to cleaning BOTH user and project Claude scopes -- cross-scope deletion risk at
src/apm_cli/integration/mcp_integrator.py
In mcp_integrator.py remove_stale(), when install_scope is neither USER nor PROJECT (the else branch at ~line 715 of the diff), the code sets clean_claude_user = clean_claude_project = True. A project-scope operation that neglects to pass install_scope will also delete entries from ~/.claude.json, and vice-versa. Confirms and extends prior-review item Will there be MCP coverage? #3: name overlap between scopes can DELETE entries the user did not intend to remove. Fail-closed principle requires an explicit scope; defaulting to 'both' is fail-open.
Suggested: Change the else branch to either raise an error ('install_scope is required for Claude cleanup') or default to PROJECT only (the narrower scope). Log a warning when install_scope is None so callers are alerted to the ambiguity. -
[recommended] ~/.claude.json read-modify-write is not atomic -- concurrent writes (Claude Code, APM, user) can lose data at
src/apm_cli/adapters/client/claude.py
Both _merge_user_mcp() and the remove_stale() user-scope path do a read-json / mutate / write_text cycle on ~/.claude.json with no file-locking or atomic-rename pattern. ~/.claude.json is a shared config file written by Claude Code itself. If Claude Code or the user writes concurrently, one side's changes are silently dropped. For a file that may contain user settings, API keys, or OAuth tokens managed by Claude Code, silent data loss is supply-chain-adjacent (credential exfiltration via config clobbering could force re-auth through a poisoned flow).
Suggested: Use atomic write-to-tempfile + os.replace() pattern (already used elsewhere in APM, e.g., lockfile writes). Consider adding fcntl/msvcrt advisory file lock for the read-modify-write window. -
[recommended] New file paths (.mcp.json, ~/.claude.json) bypass path_security helpers entirely at
src/apm_cli/adapters/client/claude.py
The PR constructs file paths via _project_mcp_path() = cwd / '.mcp.json' and _user_claude_json_path() = Path.home() / '.claude.json'. Neither path flows through validate_path_segments or ensure_path_within. While filenames are hardcoded today, the persona scope contract says: 'any new file deletion or path resolution MUST flow through these chokepoints'. The remove_stale() Claude block constructs 'wr / .mcp.json' from workspace_root which IS caller-controlled. Containment check would defend against future regression.
Suggested: Add ensure_path_within(self._project_mcp_path(), self._workspace_root()) in update_config() and _should_write_project(). In remove_stale(), add ensure_path_within(claude_proj, wr) before read/write. -
[recommended] mcp_install_scope set via monkey-patching (setattr on adapter) -- no type contract, silently None if caller forgets at
src/apm_cli/core/safe_installer.py:518
SafeMCPInstaller.init does self.adapter.mcp_install_scope = install_scope, and the Claude adapter reads it via getattr(self, 'mcp_install_scope', None). Duck-typed side-channel with no base-class declaration, no type annotation, no validation. If any code path creates a ClaudeClientAdapter without setting the attribute, _is_user_scope() returns False (project scope), which silently writes to .mcp.json instead of ~/.claude.json. For a security-relevant routing decision, this should be an explicit constructor parameter with a default, not a monkey-patched attribute.
Suggested: Add mcp_install_scope as init parameter on ClaudeClientAdapter (or base adapter class) with default of InstallScope.PROJECT. Remove the setattr pattern. -
[recommended] Freshly created ~/.claude.json and .mcp.json use default file permissions (umask-dependent, typically 0644) at
src/apm_cli/adapters/client/claude.py
Path.write_text() creates files with the process umask, typically world-readable (0644). For ~/.claude.json, which is shared with Claude Code and may contain user configuration or OAuth tokens written by other tools, a world-readable file in the home directory is a credential exposure risk on multi-user systems.
Suggested: For user-scope writes to ~/.claude.json, use os.open(path, os.O_WRONLY|os.O_CREAT|os.O_TRUNC, 0o600) + os.fdopen() when creating the file, or os.chmod(path, 0o600) after write. Project-scope .mcp.json can keep default permissions (it is repo-committed). -
[nit] Exception handler in configure_mcp_server uses bare print() and leaks the full exception string which may include registry URLs with embedded tokens at
src/apm_cli/adapters/client/claude.py:256
Line 256 of claude.py: print(f'Error configuring MCP server: {e}'). If the registry client raises an exception whose message includes a URL with embedded credentials, the token is printed to stdout. Other adapters use _rich_error() which goes through the console helper pipeline.
Suggested: Replace print() with _rich_error() and sanitize the exception message through the existing URL-credential redaction helper before printing. -
[nit] get_current_config for user scope discards non-mcpServers fields but _merge_user_mcp preserves them -- asymmetric contract at
src/apm_cli/adapters/client/claude.py
get_current_config() at line 166 returns only {'mcpServers': ...} even for user scope, dropping keys like 'projects', 'permissions', etc. _merge_user_mcp() correctly reads the full file, merges only mcpServers, and writes back the full dict. Asymmetry means any caller that reads via get_current_config() then writes the result back via update_config() would clobber the non-MCP keys. Latent data-loss vector.
Suggested: Document the asymmetry in a code comment, or have get_current_config() return the full dict so round-trip callers do not lose data.
Oss Growth Hacker
-
[recommended] Contributor funnel: take over from dmartinol, ship with Co-authored-by credit at
CHANGELOG.md
dmartinol last pushed 2026-04-19 -- inactive >10 days past the 7-day check-in window (2026-04-30 comment expired). 11 of 16 files now conflict with main, making author-side rebase impractical. Issue [FEATURE] MCP install target for Claude Code (project + user scope) #643 is the 2nd-most-reacted open issue (6 thumbs-ups) -- every week it sits unshipped leaks demand into manual workarounds. Recommended path: maintainer team rebases onto main, addresses the 4 review items, ships with 'Co-authored-by: dmartinol' trailer and inline CHANGELOG credit ('-- by @dmartinol'). This preserves dmartinol's first-PR experience while unblocking the highest-demand feature. Leaving the PR stale signals 'we accept community PRs but never ship them'.
Suggested: Maintainer opens a new branch from main, cherry-picks dmartinol's commits, resolves conflicts, fixes the 4 review items, merges with Co-authored-by trailer. Close feat: MCP install target for Claude Code (project + user scope) #655 with a 'shipped via ' comment crediting dmartinol. -
[recommended] CHANGELOG entry missing external contributor credit per repo convention at
CHANGELOG.md
changelog.instructions.md requires 'Credit external contributors inline: -- by @username (#PR_NUMBER)'. The current entry omits '-- by @dmartinol'. Named credit in CHANGELOG is the most visible 'thank you' an OSS project gives, and it shows up in release notes that reach every user.
Suggested: Change to: '- Add Claude Code as MCP install target with project (.mcp.json) and user (~/.claude.json) scope support -- by @dmartinol ([FEATURE] MCP install target for Claude Code (project + user scope) #643)' -
[nit] Release note should lead with the one-liner hook, not the implementation detail at
CHANGELOG.md
For the release announcement the hook is the command: 'apm install --runtime claude'. This matches the README hero line which already lists Claude Code, making the claim fully backed by shipped code for the first time.
Suggested: For release notes, lead with 'Claude Code is now a first-class MCP target: apm install --runtime claude' and follow with the scope details. -
[nit] Docs MCP table addition misses target-count milestone at
docs/src/content/docs/integrations/ide-tool-integration.md
After this PR APM configures MCP across 7 targets. Worth a sentence: 'APM configures MCP servers across 7 targets automatically' -- the kind of concrete claim that lands in tweets and READMEs and compounds with each new runtime.
Auth Expert
-
[recommended] mcp_install_scope is monkey-patched onto adapter instances, not a constructor parameter at
src/apm_cli/core/safe_installer.py:518
safe_installer.py sets self.adapter.mcp_install_scope = install_scope after construction. ClaudeClientAdapter._is_user_scope() reads it via getattr with None fallback. Any code path that creates a ClaudeClientAdapter directly (e.g. registry/operations.py _get_installed_server_ids, mcp_integrator.py _check_self_defined_servers_needing_installation) must remember to patch this attribute or the adapter silently defaults to project scope. A wrong-scope default means get_current_config reads .mcp.json instead of ~/.claude.json (or vice versa), causing install-state queries to return stale data and stale-removal to target the wrong file. For a scope-aware adapter writing to a shared credentials file, scope should be an explicit constructor param with no silent default.
Suggested: Add install_scope as an init parameter on ClaudeClientAdapter (or on a base class), with a required-or-explicit-None contract. Thread install_scope through the constructor at all call sites that create a client and need scope-aware paths. -
[nit] No advisory lock on ~/.claude.json read-modify-write; concurrent Claude Code process could lose non-MCP keys at
src/apm_cli/adapters/client/claude.py:199
The _merge_user_mcp pattern (read JSON, modify mcpServers, write full dict) is a TOCTOU race. If Claude Code writes to ~/.claude.json between APM's read and write, Claude's changes are silently lost. Same pattern as ~/.copilot/mcp-config.json and ~/.codex/config.toml so not a regression, but ~/.claude.json is higher-stakes because Claude Code stores non-MCP top-level keys (projects, settings, potentially OAuth state) that APM's write-back could clobber. Flagging for future hardening awareness only.
Doc Writer
-
[recommended] commands.md (canonical CLI reference in apm-usage skill) is not updated for the new claude runtime /
apm install -gClaude side-effect. atpackages/apm-guide/.apm/skills/apm-usage/commands.md
doc-sync rule 4 requires that 'When changing CLI commands, flags, dependency formats, ... also update the corresponding resource files in packages/apm-guide/.apm/skills/apm-usage/ ... commands.md for CLI changes.' This PR addsclaudeas a valid--runtimevalue and changesapm install -gsemantics so APM auto-targets Claude and writes/modifies~/.claude.json. Neither is reflected in commands.md. Skill consumers (Copilot/Claude agents that load apm-usage) will give wrong answers about scope behaviour.
Suggested: In theapm installrow, mention--runtimeaccepts copilot|codex|vscode|cursor|opencode|claude|llm. Add a line below notingapm install -gwill configure MCP into ~/.claude.json whenever theclaudeCLI is on PATH (and--exclude claudeopts out). -
[recommended] User-scope deployment callout in ide-tool-integration.md does not mention
~/.claude.json; reader cannot connectapm install -gto the new Claude user-scope MCP write. atdocs/src/content/docs/integrations/ide-tool-integration.md:54
The existing callout enumerates user-scope destinations as folders (~/.copilot/, ~/.claude/, etc.). With this PRapm install -galso writes a top-level mcpServers block into ~/.claude.json (a single user-home file, not under ~/.claude/). A reader who skims the top callout will miss the new file and be surprised whenapm install -gmutates ~/.claude.json on a machine where Claude CLI is on PATH.
Suggested: Append: 'apm install -galso writes MCP server config into~/.claude.jsonwhen theclaudeCLI is on PATH; pass--exclude claudeto opt out.' -
[recommended] introduction/what-is-apm.md capability table still implies Claude MCP lives under
.claude/; new project file is.mcp.jsonat repo root. atdocs/src/content/docs/introduction/what-is-apm.md:150
Line 150 of what-is-apm.md lists Claude as '.claude/commands, skills, MCP'. With this PR the project-scope MCP target for Claude is .mcp.json at the repository root (not under .claude/), and user-scope is ~/.claude.json. Stale capability table violates doc-sync and 'State once, reference elsewhere'.
Suggested: Change the Claude row's MCP cell to reference.mcp.json(project) and~/.claude.json(user), or drop 'MCP' from the.claude/cell and add a footnote pointing at the MCP table in integrations/ide-tool-integration.md. -
[recommended] CHANGELOG entry omits external-contributor credit (
-- by @dmartinol). atCHANGELOG.md
changelog.instructions.md mandates: 'Credit external contributors inline:-- by @username (#PR_NUMBER).' dmartinol is external. The added line carries no credit. Section placement (Added) and PR-number link are correct; only the byline is missing.
Suggested: Replace with:- Add Claude Code as MCP install target with project (.mcp.json) and user (~/.claude.json) scope -- by @dmartinol (#643). -
[recommended] New MCP table row inlines a CLI flag (
apm install -g) into the Client column; inconsistent with Cursor / OpenCode rows and obscures the scope concept. atdocs/src/content/docs/integrations/ide-tool-integration.md
Cursor and OpenCode rows say only 'Cursor' / 'OpenCode' in the client column and put the trigger condition in the Format column. The new Claude rows split the same idea differently: one says 'Claude Code (project)', the other 'Claude Code (user,apm install -g)' with the flag in the client cell. Voice consistency argues for uniform shape.
Suggested: Rename the two new rows to 'Claude Code (project scope)' and 'Claude Code (user scope)'. Move the apm install -g trigger into the Note callout below the table. -
[recommended] Docs do not surface that project-scope Claude install is a silent no-op when
.claude/is absent. atdocs/src/content/docs/integrations/ide-tool-integration.md
PR confirmsapm install --runtime claudeat project scope writes nothing and reports success when .claude/ is missing. The new MCP table only annotates the destination cell with '(when .claude/ exists)'. An opt-in gate that silently succeeds is exactly the kind of surprise docs must call out. A reader who runsapm install --runtime claudein a fresh project will see 'configured 1 runtime' and assume .mcp.json was written.
Suggested: In the Note paragraph below the MCP table, add: 'Project-scope Claude is opt-in: APM only writes .mcp.json when a .claude/ directory exists at the project root. Without it, the install reports success and skips Claude.' -
[nit] New Note paragraph leans on bold and parenthetical Copilot-noise enumeration; reads dense for a callout. at
docs/src/content/docs/integrations/ide-tool-integration.md
Voice rule: 'Prose supports the examples, not the other way around.' The Note packs behaviour explanation, dropped fields list, and preserved fields list into one sentence. A two-sentence split would be easier to skim. Pure polish, no correctness issue.
Suggested: Split into two sentences: (1) merge contract; (2) normalization rules.
Test Coverage Expert
-
[recommended] No test for stale cleanup when install_scope=None -- both user and project entries are deleted at
src/apm_cli/integration/mcp_integrator.py:714
mcp_integrator.py remove_stale lines 710-715: when install_scope is None, clean_claude_user and clean_claude_project are both True. A caller that forgets to pass scope cleans BOTH user and project entries. Existing tests (test_remove_stale_claude_project_mcp_json, test_remove_stale_claude_user_claude_json) always pass an explicit InstallScope. Prior review item Will there be MCP coverage? #3 flagged this scope-safety concern. Grep'd tests/ -- no match.
Suggested: Add test in tests/unit/test_claude_mcp.py::TestMCPIntegratorClaudeStaleCleanup that calls remove_stale with install_scope=None, runtime='claude', asserts the safe behavior (default to PROJECT and not cross-touch user entries).
Proof (missing at):tests/unit/test_claude_mcp.py::TestMCPIntegratorClaudeStaleCleanup::test_remove_stale_scope_none_defaults_safely-- proves: When install_scope is None, stale cleanup does not silently cross-touch the wrong scope
assert 'stale' in user_data['mcpServers'] # user entries untouched when scope unspecified -
[recommended] No test that Claude is skipped from auto-detection when the claude binary is absent at
src/apm_cli/integration/mcp_integrator.py:955
Prior item Why do we need a GitHub token? #1: apm install -g auto-targets Claude without shutil.which('claude') availability check. PR code at lines 955-959 unconditionally appends 'claude' when InstallScope.USER or .claude/ exists -- no binary check. Users without Claude Code installed get ~/.claude.json created. PR's test_install_global_rejects_vscode_runtime only tests workspace-runtime rejection. Grep'd tests/ for 'shutil.which.*claude' -- no match.
Suggested: Add a test that mocks shutil.which('claude') to return None and asserts 'claude' is NOT in installed_runtimes during auto-detect.
Proof (missing at):tests/unit/test_claude_mcp.py::TestMCPIntegratorUserScopeInstall::test_claude_not_auto_targeted_when_binary_absent-- proves: Users without Claude Code installed do not get ~/.claude.json created by apm install -g
assert 'claude' not in installed_runtimes -
[recommended] MCPConflictDetector.get_existing_server_configs Claude branch has no test at
src/apm_cli/core/conflict_detector.py:124
conflict_detector.py adds elif 'claude' in adapter_class_name: return existing_config.get('mcpServers', {}). Existing tests cover copilot and codex but not claude. Grep'd tests/ -- no match. Without this test a regression in the Claude branch is silent.
Suggested: Add test_get_existing_server_configs_claude in tests/unit/test_conflict_detection.py that creates a ClaudeClientAdapter, seeds mcpServers, and asserts get_existing_server_configs returns the correct dict.
Proof (missing at):tests/unit/test_conflict_detection.py::TestMCPConflictDetection::test_get_existing_server_configs_claude-- proves: Conflict detector correctly reads mcpServers from Claude adapter config
assert configs == {'srv': {'command': 'x'}} -
[nit] normalize_mcp_entry_for_claude_code lacks SSE and streamable-http type coverage at
src/apm_cli/adapters/client/claude.py:88
normalize function has is_remote branch firing for type in ('http', 'sse', 'streamable-http') or bool(url). PR tests cover type='http' and stdio (type='local'). SSE and streamable-http not exercised. Valid Claude Code MCP transport types per docs.
Suggested: Add parametrized test cases for type='sse' and type='streamable-http' asserting type/url are preserved and empty id/default tools are stripped.
Proof (missing at):tests/unit/test_claude_mcp.py::TestClaudeClientAdapterProject::test_normalize_sse_and_streamable_http-- proves: All Claude Code remote transport types are normalized correctly -
[nit] No idempotency test for double update_config on .mcp.json or ~/.claude.json at
tests/unit/test_claude_mcp.py
Install idempotency is a critical user promise. Merge logic ({**old, **new}) should produce identical output on re-run, but no test calls update_config twice with the same payload and asserts identical file content. Low risk because the merge is deterministic, but a cheap parametrize guards against future drift.
Suggested: Add test that calls adapter.update_config(payload) twice and asserts json.loads(path.read_text()) is identical after both calls.
Proof (missing at):tests/unit/test_claude_mcp.py::TestClaudeClientAdapterProject::test_update_config_idempotent-- proves: Running apm install --runtime claude twice produces identical .mcp.json
This panel is advisory. It does not block merge. Re-apply the panel-review label after addressing feedback to re-run.
|
Hi @dmartinol -- thanks again for the design and the 11-commit groundwork on this. The PR went past the 7-day check-in window with 11 file-level merge conflicts against main, so I've taken it over as a maintainer rework: #1104. That PR carries your design intent end-to-end, supersedes this one, and gives you full Co-authored-by credit on the commit. It also folds in the panel-recommended fixes from the advisory review (#655 (comment)) -- monkey-patch -> ctor param, shutil.which gate, fail-closed cleanup, atomic + 0o600 user-scope writes, ASCII sweep, gemini preserved, regression tests. I'll close this PR once #1104 merges. Massive thank-you for getting #643 unblocked. |
|
per above |
…655) (#1104) * feat: add Claude Code as MCP install target (closes #643) Adds Claude Code as a first-class MCP install target with both project-scope (.mcp.json) and user-scope (~/.claude.json) writes, mirroring the model used for Cursor and OpenCode. Builds on the design intent of #655 by @dmartinol, with the panel- recommended fixes integrated inline so the takeover ships green: Architecture - Promote MCP install scope from a runtime monkey-patch (setattr) to an explicit ClaudeClientAdapter(user_scope=...) constructor flag, threaded through ClientFactory. - Polymorphic conflict-detection branch for Claude in conflict_detector.py and registry/operations.py (mcpServers root). - Extract _gate_project_scoped_runtimes helper in mcp_integrator.py; also de-duplicates the prior Codex gate. Safety - shutil.which('claude') gates auto-detection during 'apm install -g'; explicit --runtime claude bypasses the gate. - Project-scope write returns False (not silent True) and emits a rich warning when .claude/ is absent in the project root. - remove_stale with install_scope=None defaults to project-scope cleanup only and warns; user-scope ~/.claude.json is never touched unless install_scope=USER is passed explicitly (fail-closed). - ~/.claude.json writes are atomic (tmp + os.replace) and new files are created with mode 0o600 so OAuth tokens cannot leak via group or world read. - Replace bare print() calls in the adapter with _rich_* helpers; exception messages are not interpolated into user-facing strings (avoids credential URL leakage in error output). UX / docs - Preserve gemini in runtime enumerations (factory + integrator). - Update --runtime help text and the integrations table for Claude. - Update docs/integrations/ide-tool-integration.md and reference/cli-commands.md. - CHANGELOG entry credits @dmartinol and references #643. Tests - New tests/unit/test_claude_mcp.py covers: factory wiring, project + user scope behavior, normalize for sse and streamable-http, idempotency, atomicity + 0o600 perms, remove_stale safety with install_scope=None, and the binary-absent auto-detection gate. Tier 2 follow-ups (small, defensible to fast-follow): - _remove_stale_json_keys helper to fold five remove_stale branches into one-liners. - Polymorphic extract_servers(config) on the adapter base to collapse the conflict-detector if/elif on adapter type. Co-authored-by: Daniele Martinoli <86618610+dmartinol@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: address review feedback on PR #1104 Eight findings from copilot-pull-request-reviewer: - Replace --user references with -g/--global in remove_stale warning and ide-tool-integration docs (the actual InstallScope.USER flag). - CHANGELOG: switch to repo-standard "(#1104, closes #643) -- by @user" credit format. - claude.py update_config + _merge_user_mcp: catch json.JSONDecodeError so a corrupted config file does not crash apm install. - claude.py configure_mcp_server: surface update_config() failures instead of always returning True. - claude.py _atomic_write_user_json: replaced by canonical helper; the Windows AttributeError on os.fchmod is now handled inside atomic_io. - test_claude_mcp.py: skip POSIX-perm-bit assertion on non-posix where mode bits are not observable. - atomic_io.atomic_write_text: extended with optional new_file_mode kwarg (POSIX-only, hasattr-guarded for Windows). Claude adapter now routes through this canonical helper instead of re-implementing the mkstemp+fchmod+replace dance. - mcp_integrator.py Claude stale-cleanup: guard isinstance(servers, dict) for both project .mcp.json and user ~/.claude.json so a malformed mcpServers value does not skip cleanup entirely. Co-authored-by: Daniele Martinoli <86618610+dmartinol@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * test(claude-mcp): schema-fidelity integration tests + canonical stdio type Adds tests/integration/test_claude_mcp_schema_fidelity.py with 12 tests that assert ClaudeClientAdapter writes byte-equivalent JSON to what the upstream 'claude' CLI emits. Golden fixtures under tests/integration/fixtures/claude_mcp_golden/ were captured live from Claude Code 2.1.126 (probe commands documented in fixtures/README.md). Schema-fidelity coverage: PROJECT scope: HTTP, SSE, stdio, HTTP+headers, full-file round-trip USER scope: HTTP, stdio, full-file round-trip, top-level vs projects.<path> guard, unrelated-keys preservation LOCAL scope: guard tests for the intentional non-implementation Polish (a) per review: * _normalize_mcp_entry_for_claude_code now emits explicit type: 'stdio' for stdio entries (matches 'claude mcp add' canonical output; unit test updated + new test for the type-missing case) * Adapter docstring + ide-tool-integration.md document why APM targets PROJECT + USER scopes and intentionally omits LOCAL * CHANGELOG entry expanded with schema-fidelity + LOCAL-scope notes * scripts/test-integration.sh runs the new schema-fidelity suite in CI Validation: ruff check src/ tests/ -> silent ruff format --check src/ tests/ -> silent pytest tests/unit/test_claude_mcp.py tests/integration/test_claude_mcp_schema_fidelity.py -> 37 passed (25 unit + 12 integration) pytest tests/unit -> 7111 passed No claude CLI dependency in CI -- fixtures freeze the contract. Co-authored-by: Daniele Martinoli <86618610+dmartinol@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <copilot-rework@github.com> Co-authored-by: Daniele Martinoli <86618610+dmartinol@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…655) (#1104) * feat: add Claude Code as MCP install target (closes #643) Adds Claude Code as a first-class MCP install target with both project-scope (.mcp.json) and user-scope (~/.claude.json) writes, mirroring the model used for Cursor and OpenCode. Builds on the design intent of #655 by @dmartinol, with the panel- recommended fixes integrated inline so the takeover ships green: Architecture - Promote MCP install scope from a runtime monkey-patch (setattr) to an explicit ClaudeClientAdapter(user_scope=...) constructor flag, threaded through ClientFactory. - Polymorphic conflict-detection branch for Claude in conflict_detector.py and registry/operations.py (mcpServers root). - Extract _gate_project_scoped_runtimes helper in mcp_integrator.py; also de-duplicates the prior Codex gate. Safety - shutil.which('claude') gates auto-detection during 'apm install -g'; explicit --runtime claude bypasses the gate. - Project-scope write returns False (not silent True) and emits a rich warning when .claude/ is absent in the project root. - remove_stale with install_scope=None defaults to project-scope cleanup only and warns; user-scope ~/.claude.json is never touched unless install_scope=USER is passed explicitly (fail-closed). - ~/.claude.json writes are atomic (tmp + os.replace) and new files are created with mode 0o600 so OAuth tokens cannot leak via group or world read. - Replace bare print() calls in the adapter with _rich_* helpers; exception messages are not interpolated into user-facing strings (avoids credential URL leakage in error output). UX / docs - Preserve gemini in runtime enumerations (factory + integrator). - Update --runtime help text and the integrations table for Claude. - Update docs/integrations/ide-tool-integration.md and reference/cli-commands.md. - CHANGELOG entry credits @dmartinol and references #643. Tests - New tests/unit/test_claude_mcp.py covers: factory wiring, project + user scope behavior, normalize for sse and streamable-http, idempotency, atomicity + 0o600 perms, remove_stale safety with install_scope=None, and the binary-absent auto-detection gate. Tier 2 follow-ups (small, defensible to fast-follow): - _remove_stale_json_keys helper to fold five remove_stale branches into one-liners. - Polymorphic extract_servers(config) on the adapter base to collapse the conflict-detector if/elif on adapter type. Co-authored-by: Daniele Martinoli <86618610+dmartinol@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: address review feedback on PR #1104 Eight findings from copilot-pull-request-reviewer: - Replace --user references with -g/--global in remove_stale warning and ide-tool-integration docs (the actual InstallScope.USER flag). - CHANGELOG: switch to repo-standard "(#1104, closes #643) -- by @user" credit format. - claude.py update_config + _merge_user_mcp: catch json.JSONDecodeError so a corrupted config file does not crash apm install. - claude.py configure_mcp_server: surface update_config() failures instead of always returning True. - claude.py _atomic_write_user_json: replaced by canonical helper; the Windows AttributeError on os.fchmod is now handled inside atomic_io. - test_claude_mcp.py: skip POSIX-perm-bit assertion on non-posix where mode bits are not observable. - atomic_io.atomic_write_text: extended with optional new_file_mode kwarg (POSIX-only, hasattr-guarded for Windows). Claude adapter now routes through this canonical helper instead of re-implementing the mkstemp+fchmod+replace dance. - mcp_integrator.py Claude stale-cleanup: guard isinstance(servers, dict) for both project .mcp.json and user ~/.claude.json so a malformed mcpServers value does not skip cleanup entirely. Co-authored-by: Daniele Martinoli <86618610+dmartinol@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * test(claude-mcp): schema-fidelity integration tests + canonical stdio type Adds tests/integration/test_claude_mcp_schema_fidelity.py with 12 tests that assert ClaudeClientAdapter writes byte-equivalent JSON to what the upstream 'claude' CLI emits. Golden fixtures under tests/integration/fixtures/claude_mcp_golden/ were captured live from Claude Code 2.1.126 (probe commands documented in fixtures/README.md). Schema-fidelity coverage: PROJECT scope: HTTP, SSE, stdio, HTTP+headers, full-file round-trip USER scope: HTTP, stdio, full-file round-trip, top-level vs projects.<path> guard, unrelated-keys preservation LOCAL scope: guard tests for the intentional non-implementation Polish (a) per review: * _normalize_mcp_entry_for_claude_code now emits explicit type: 'stdio' for stdio entries (matches 'claude mcp add' canonical output; unit test updated + new test for the type-missing case) * Adapter docstring + ide-tool-integration.md document why APM targets PROJECT + USER scopes and intentionally omits LOCAL * CHANGELOG entry expanded with schema-fidelity + LOCAL-scope notes * scripts/test-integration.sh runs the new schema-fidelity suite in CI Validation: ruff check src/ tests/ -> silent ruff format --check src/ tests/ -> silent pytest tests/unit/test_claude_mcp.py tests/integration/test_claude_mcp_schema_fidelity.py -> 37 passed (25 unit + 12 integration) pytest tests/unit -> 7111 passed No claude CLI dependency in CI -- fixtures freeze the contract. Co-authored-by: Daniele Martinoli <86618610+dmartinol@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <copilot-rework@github.com> Co-authored-by: Daniele Martinoli <86618610+dmartinol@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Closes #643
Summary
Adds Claude Code as a first-class MCP client for
apm install/ lockfile-driven MCP lifecycle: registry-backed server entries are merged into Claude’s documented config locations, with project vs user scope aligned toInstallScope(apm installvsapm install --global).Behavior
.mcp.jsonat the repo root (mcpServers), only when.claude/already exists (opt-in, same idea as Cursor +.cursor/).~/.claude.jsontop-levelmcpServerswhen scope is user / global install path.CopilotClientAdapter, then normalizes entries for Claude Code (e.g. drop Copilot-only stdio fields such astype: local, defaulttools, emptyid; keep remotes / HTTP shape per docs).--globalwhen MCP deps exist, so user-scoped Claude (and other home-dir runtimes) can be configured from the manifest.MCPIntegratorpassesinstall_scope(and related wiring) into install/uninstall paths; stale cleanup removes Claude entries from project.mcp.jsonand user~/.claude.jsonwhere applicable.MCPConflictDetectortreats Claude like othermcpServers-shaped clients.ClientFactoryregisters runtimeclaude/Claude.tests/unit/test_claude_mcp.py(adapter, merge/normalize,remove_stale); runtime script detection forclaudeintest_runtime_detection.py.Notes
apmfrom the project root for project MCP files.safe_installeraccepts optionalworkspace_root/install_scopefor API compatibility with the integrator;mcp_install_scopeis set on the adapter for Claude user vs project behavior.audit_report.py: extract pipe-escaping for Markdown tables into a variable to satisfy Python 3.11+ f-string rules (no behavior change).Minor style-only diffs in Python may appear from Black/isort on edited files; repo-wide formatting and CI alignment are out of scope here — see #645.
Testing
uv run pytest tests/unit/test_claude_mcp.py tests/unit/test_runtime_detection.py -xCONTRIBUTING.md