Skip to content

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

Open
dmartinol wants to merge 17 commits intomicrosoft:mainfrom
dmartinol:feature/claude-code-mcp
Open

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

Conversation

@dmartinol
Copy link
Copy Markdown

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
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
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 and removed CI/CD 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.

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)

4 participants