Skip to content

feat: MCP install target for Claude Code (project + user scope)#655

Closed
dmartinol wants to merge 17 commits into
microsoft:mainfrom
dmartinol:feature/claude-code-mcp
Closed

feat: MCP install target for Claude Code (project + user scope)#655
dmartinol wants to merge 17 commits into
microsoft:mainfrom
dmartinol:feature/claude-code-mcp

Conversation

@dmartinol
Copy link
Copy Markdown
Contributor

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 to InstallScope (apm install vs apm install --global).

Behavior

  • Project: .mcp.json at the repo root (mcpServers), only when .claude/ already exists (opt-in, same idea as Cursor + .cursor/).
  • User: ~/.claude.json top-level mcpServers when scope is user / global install path.
  • Reuses Copilot-style registry → config formatting from CopilotClientAdapter, then normalizes entries for Claude Code (e.g. drop Copilot-only stdio fields such as type: local, default tools, empty id; keep remotes / HTTP shape per docs).
  • Install: MCP installation is no longer blanket-disabled for --global when MCP deps exist, so user-scoped Claude (and other home-dir runtimes) can be configured from the manifest.
  • Integrator: MCPIntegrator passes install_scope (and related wiring) into install/uninstall paths; stale cleanup removes Claude entries from project .mcp.json and user ~/.claude.json where applicable.
  • Conflict detection: MCPConflictDetector treats Claude like other mcpServers-shaped clients.
  • Factory: ClientFactory registers runtime claude / Claude.
  • Docs: IDE integration doc updated to include Claude.
  • Tests: tests/unit/test_claude_mcp.py (adapter, merge/normalize, remove_stale); runtime script detection for claude in test_runtime_detection.py.

Notes

  • Repo-local adapter paths follow the same cwd convention as existing repo-local clients (e.g. Cursor): run apm from the project root for project MCP files.
  • safe_installer accepts optional workspace_root / install_scope for API compatibility with the integrator; mcp_install_scope is 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 -x
  • (CI) full unit suite as in CONTRIBUTING.md

Signed-off-by: Daniele Martinoli <dmartino@redhat.com>
Copilot AI review requested due to automatic review settings April 9, 2026 20:40
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 ClaudeClientAdapter and registers it in ClientFactory; adds runtime detection for claude.
  • Threads install_scope/workspace_root through 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.

Comment on lines 1010 to 1014
if runtime_name == "vscode":
if _is_vscode_available():
ClientFactory.create_client(runtime_name)
installed_runtimes.append(runtime_name)
elif runtime_name == "cursor":
Copy link

Copilot AI Apr 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment thread src/apm_cli/integration/mcp_integrator.py Outdated
Comment thread src/apm_cli/adapters/client/claude.py
@dmartinol
Copy link
Copy Markdown
Contributor Author

@microsoft-github-policy-service agree

dmartinol and others added 3 commits April 9, 2026 22:57
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>
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 14 out of 14 changed files in this pull request and generated 7 comments.

Comment on lines 1021 to 1025
if runtime_name == "vscode":
if _is_vscode_available():
ClientFactory.create_client(runtime_name)
installed_runtimes.append(runtime_name)
elif runtime_name == "cursor":
Copy link

Copilot AI Apr 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines 863 to 867
stored_mcp_configs=old_mcp_configs,
diagnostics=apm_diagnostics,
workspace_root=get_deploy_root(scope),
install_scope=scope,
)
Copy link

Copilot AI Apr 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +469 to 473
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:
Copy link

Copilot AI Apr 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment thread src/apm_cli/integration/mcp_integrator.py Outdated
Comment thread src/apm_cli/integration/mcp_integrator.py
Comment thread src/apm_cli/core/safe_installer.py
Comment thread src/apm_cli/registry/operations.py
Copy link
Copy Markdown
Collaborator

@sergio-sisternes-epam sergio-sisternes-epam left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

srid added a commit to juspay/kolu that referenced this pull request Apr 14, 2026
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.
srid added a commit to juspay/kolu that referenced this pull request Apr 14, 2026
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.
srid added a commit to juspay/kolu that referenced this pull request Apr 14, 2026
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.
dmartinol and others added 7 commits April 17, 2026 08:55
- 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
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
@dmartinol
Copy link
Copy Markdown
Contributor Author

Thanks for the review. Addressed all blocking items:

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.

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 apm install -g --runtime <one of those> now errors with a short message to use a project install. remove_stale under user scope mirrors the same runtime set.

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.

Claude project cleanup: Stale removal for project .mcp.json runs only when .claude/ exists, matching install opt-in.

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.

Messages: Stale MCP lines use _rich_info(..., symbol="check") for STATUS_SYMBOLS.

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)

CHANGELOG [Unreleased] Added line added as requested.

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.

workspace_root: Documented that adapters still use cwd for repo-local paths; filtering fixes the -g case without a large adapter refactor (full plumb of workspace_root can be follow-up).
Tests updated for the above; full unit suite is green.

@danielmeppiel danielmeppiel added CI/CD Deprecated: use area/ci-cd. Kept for issue history; will be removed in milestone 0.10.0. and removed CI/CD Deprecated: use area/ci-cd. Kept for issue history; will be removed in milestone 0.10.0. labels Apr 19, 2026
Copy link
Copy Markdown
Collaborator

@danielmeppiel danielmeppiel left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 checksrc/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 absentsrc/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=Nonesrc/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 commentsrc/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.

srid added a commit to juspay/kolu that referenced this pull request Apr 22, 2026
**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)
@danielmeppiel
Copy link
Copy Markdown
Collaborator

Hi @dmartinol -- friendly check-in: this PR has been in changes-requested state since 2026-04-19, and we are working through the wave-3 merge backlog. If you are still able to address the review feedback in the next ~7 days, we would love to land this; otherwise we will close it as stale and you can re-open later (no hard feelings -- contribution graveyard happens to everyone).

Concretely, the outstanding feedback is:
The Claude Code adapter shape itself is good (mirrors the Cursor / OpenCode pattern); please address the four PR-specific items called out in the latest review before merge -- the broader per-runtime if/elif scaling concerns are explicitly deferred and not blocking (see latest review).

Let us know either way -- a quick "still on it" or "going to close it" reply is enough.

Thanks for the contribution!

@dmartinol
Copy link
Copy Markdown
Contributor Author

Hi @dmartinol -- friendly check-in: this PR has been in changes-requested state since 2026-04-19, and we are working through the wave-3 merge backlog. If you are still able to address the review feedback in the next ~7 days, we would love to land this; otherwise we will close it as stale and you can re-open later (no hard feelings -- contribution graveyard happens to everyone).

Concretely, the outstanding feedback is: The Claude Code adapter shape itself is good (mirrors the Cursor / OpenCode pattern); please address the four PR-specific items called out in the latest review before merge -- the broader per-runtime if/elif scaling concerns are explicitly deferred and not blocking (see latest review).

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.
Unfortunately, I'm not going to complete this PR as we decided -at least for now- to adopt another package manager that already provides the requested features. I'm afraid I won't have any more time to spend on this project in the next days!
Hope you can understand. You can either close the PR or I can do by myself.

Thanks!

@oddrationale
Copy link
Copy Markdown

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.

@danielmeppiel
Copy link
Copy Markdown
Collaborator

We will take over this work and ship on hte next milestone ASAP

@danielmeppiel
Copy link
Copy Markdown
Collaborator

APM Review Panel: needs_rework

Claude Code MCP support (issue #643, 6 thumbs-ups) is the right feature at the right time, but 11-file merge conflicts and 9 confirmed design gaps require a maintainer-takeover rebase before this can ship.

cc @dmartinol -- a fresh advisory pass is ready for your review.

All eight panelists converge: the adapter shape is sound, the feature is high-demand (#643 is the 2nd-most-reacted open issue), and the core merge/normalize logic already has solid unit coverage. The panel also converges -- unanimously -- that the PR cannot merge in its current state. Eleven of sixteen files conflict against main, the gemini runtime would regress, and the four items from the prior maintainer review (id 4136317541) are all still open and independently confirmed by 3-5 panelists each. The strongest new signal is architectural: mcp_install_scope is monkey-patched onto adapter instances via setattr from 4+ call sites with no base-class declaration (flagged by python-architect, devx-ux, supply-chain-security, and auth -- 4 of 8 panelists). This is not cosmetic debt; it is a scope-routing decision that determines whether APM writes to ~/.claude.json or .mcp.json, and a silent None default sends writes to the wrong file. The supply-chain expert and auth expert agree this must become an explicit constructor parameter before the dual-scope pattern is shipped. The second cluster of concern is the remove_stale scope=None path, where both user and project Claude configs are cleaned simultaneously -- confirmed by 4 panelists and backed by a missing-test evidence block from test-coverage. The test-coverage expert delivered 3 recommended findings, all with outcome: missing evidence on critical-promise surfaces (scope safety, auto-detection gating, conflict detection). Per arbitration rules, missing tests on scope-safety surfaces (governed-by-policy, secure-by-default) rank at or near blocking-tier opinion findings. These are cheap to write and should land in the same PR as the rework. The practical path is clear: maintainer takeover with Co-authored-by: dmartinol credit. The author has been inactive since 2026-04-19 (2.5+ months), the 7-day check-in window expired 2026-04-30, and 11-file conflicts make author-side rebase impractical.

Dissent. Auth-expert rates the non-atomic ~/.claude.json write as nit (pre-existing pattern shared with Copilot/Codex adapters); supply-chain-security rates it recommended (higher stakes because Claude Code stores non-MCP keys including potential OAuth state). Orchestrator sides with supply-chain: ~/.claude.json is a shared config file written by another tool, and silent data loss on concurrent write is a real risk that justifies atomic-write + 0o600 permissions in the same rework pass. Separately, test-coverage-expert's 3 recommended findings all carry well-formed evidence blocks with outcome: missing -- these are not opinion; the absence of regression traps on scope-safety and auto-detection gating is a concrete defect on secure-by-default and governed-by-policy surfaces.

Aligned with: Portable By Manifest -- Claude Code becomes the 7th MCP target configured from a single apm.yml manifest -- strongest portability proof point since MCP landed.; Secure By Default -- Gaps: ~/.claude.json created world-readable (0644), non-atomic write, print() may leak credential URLs, and scope=None stale cleanup crosses trust boundaries. All fixable in the rework pass.; Governed By Policy -- mcp_install_scope monkey-patching bypasses type safety on the user-vs-project routing decision. Constructor parameter with explicit default restores the governed contract.; Multi Harness Multi Host -- Dual-scope support (project .mcp.json + user ~/.claude.json) is the correct model. Once the scope-routing bug is fixed, Claude is the cleanest dual-scope adapter in the codebase.; Oss Community Driven -- First external-contributor MCP runtime PR. Maintainer takeover with Co-authored-by credit and CHANGELOG attribution preserves the contributor funnel signal.; Pragmatic As Npm -- Project-scope silent no-op when .claude/ is absent violates 'install reports what it did'. Fix: return skipped + user-visible message, consistent with npm's 0-package install behavior.

Growth signal. 'apm install --runtime claude' is the sharpest one-liner since MCP landed and directly answers the query a Claude Code user would search for. Issue #643's 6 thumbs-ups confirm latent demand. Ship with a standalone release callout, CHANGELOG credit to @dmartinol, and a '7 MCP targets, one manifest' milestone claim in the release note. Every week this sits unshipped leaks demand into manual workarounds.

Panel summary

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

  1. [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.
  2. [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.
  3. [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.
  4. [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.
  5. [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"
Loading
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)"]
Loading
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
Loading

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 -g auto-targets Claude without checking whether claude binary is installed at src/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 runs apm install -g some-package today 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 claude should 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_root in 3+ locations at src/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 -g Claude side-effect. at packages/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 adds claude as a valid --runtime value and changes apm install -g semantics 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 the apm install row, mention --runtime accepts copilot|codex|vscode|cursor|opencode|claude|llm. Add a line below noting apm install -g will configure MCP into ~/.claude.json whenever the claude CLI is on PATH (and --exclude claude opts out).

  • [recommended] User-scope deployment callout in ide-tool-integration.md does not mention ~/.claude.json; reader cannot connect apm install -g to the new Claude user-scope MCP write. at docs/src/content/docs/integrations/ide-tool-integration.md:54
    The existing callout enumerates user-scope destinations as folders (~/.copilot/, ~/.claude/, etc.). With this PR apm install -g also 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 when apm install -g mutates ~/.claude.json on a machine where Claude CLI is on PATH.
    Suggested: Append: 'apm install -g also writes MCP server config into ~/.claude.json when the claude CLI is on PATH; pass --exclude claude to opt out.'

  • [recommended] introduction/what-is-apm.md capability table still implies Claude MCP lives under .claude/; new project file is .mcp.json at repo root. at docs/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). at CHANGELOG.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. at docs/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. at docs/src/content/docs/integrations/ide-tool-integration.md
    PR confirms apm install --runtime claude at 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 runs apm install --runtime claude in 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.

@danielmeppiel
Copy link
Copy Markdown
Collaborator

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.

@danielmeppiel
Copy link
Copy Markdown
Collaborator

danielmeppiel commented May 2, 2026

per above

danielmeppiel added a commit that referenced this pull request May 2, 2026
…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>
sergio-sisternes-epam pushed a commit that referenced this pull request May 19, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[FEATURE] MCP install target for Claude Code (project + user scope)

5 participants