feat: MCP install target for Claude Code (project + user scope)#655
feat: MCP install target for Claude Code (project + user scope)#655dmartinol wants to merge 4 commits intomicrosoft:mainfrom
Conversation
Signed-off-by: Daniele Martinoli <dmartino@redhat.com>
There was a problem hiding this comment.
Pull request overview
Adds Claude Code as a supported MCP client for apm install, including project vs user scope configuration, and wires scope through install/uninstall + conflict detection.
Changes:
- Introduces
ClaudeClientAdapterand registers it inClientFactory; adds runtime detection forclaude. - Threads
install_scope/workspace_rootthrough MCP install/check/cleanup paths and adds Claude-specific stale cleanup. - Updates docs and adds unit tests for Claude MCP config merge/normalize and stale cleanup.
Reviewed changes
Copilot reviewed 14 out of 14 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| tests/unit/test_runtime_detection.py | Adds unit test to detect claude in scripts runtime detection. |
| tests/unit/test_claude_mcp.py | New unit tests for Claude adapter config pathing, merge/normalize behavior, and stale cleanup. |
| src/apm_cli/security/audit_report.py | Refactors markdown escaping to avoid f-string backslash expression issues. |
| src/apm_cli/registry/operations.py | Threads install_scope/workspace_root into installed-server checks; adds claude/opencode parsing branches. |
| src/apm_cli/integration/mcp_integrator.py | Adds Claude runtime detection, scope threading, and Claude project/user stale cleanup logic. |
| src/apm_cli/factory.py | Registers claude runtime to ClaudeClientAdapter. |
| src/apm_cli/core/safe_installer.py | Passes install_scope through to adapters for scope-sensitive MCP config. |
| src/apm_cli/core/operations.py | Extends install_package API to accept workspace_root/install_scope. |
| src/apm_cli/core/conflict_detector.py | Treats Claude configs as mcpServers schema for conflict detection. |
| src/apm_cli/commands/uninstall/engine.py | Passes workspace_root into MCP stale cleanup after uninstall. |
| src/apm_cli/commands/uninstall/cli.py | Wires deploy root into uninstall MCP cleanup call. |
| src/apm_cli/commands/install.py | Enables MCP install for --global and passes scope/root through to MCP integrator. |
| src/apm_cli/adapters/client/claude.py | New Claude adapter implementing project .mcp.json + user ~/.claude.json behavior and normalization. |
| docs/src/content/docs/integrations/ide-tool-integration.md | Documents Claude MCP config locations and runtime targeting. |
| if runtime_name == "vscode": | ||
| if _is_vscode_available(): | ||
| ClientFactory.create_client(runtime_name) | ||
| installed_runtimes.append(runtime_name) | ||
| elif runtime_name == "cursor": |
There was a problem hiding this comment.
When running with user/global scope, this runtime detection can still include "vscode" (via _is_vscode_available()). That can cause apm install -g to create/update a workspace-local .vscode/mcp.json in the current working directory (VSCodeClientAdapter auto-creates .vscode/ even on read), which breaks the intended scope separation. Consider filtering out workspace-scoped runtimes (vscode/cursor/opencode/claude-project) when install_scope is InstallScope.USER, unless the user explicitly targets them, or ensure repo-local adapters are rooted at workspace_root rather than CWD.
|
@microsoft-github-policy-service agree |
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Signed-off-by: Daniele Martinoli <dmartino@redhat.com>
Signed-off-by: Daniele Martinoli <dmartino@redhat.com>
| if runtime_name == "vscode": | ||
| if _is_vscode_available(): | ||
| ClientFactory.create_client(runtime_name) | ||
| installed_runtimes.append(runtime_name) | ||
| elif runtime_name == "cursor": |
There was a problem hiding this comment.
When running with install_scope=InstallScope.USER (e.g. apm install -g), this runtime detection still includes workspace-scoped clients like VS Code. Since VSCodeClientAdapter uses os.getcwd() and auto-creates .vscode/ in get_config_path(), a global install can end up creating/modifying .vscode/mcp.json in whatever directory the user ran the command from. Consider filtering target runtimes by scope (USER: only home-scoped runtimes like copilot/codex/claude) or implementing workspace_root support so repo-local adapters read/write under that root instead of CWD.
| stored_mcp_configs=old_mcp_configs, | ||
| diagnostics=apm_diagnostics, | ||
| workspace_root=get_deploy_root(scope), | ||
| install_scope=scope, | ||
| ) |
There was a problem hiding this comment.
workspace_root is documented as the root for repo-local MCP configs, but get_deploy_root(InstallScope.USER) is Path.home(). Passing the home directory here makes runtime detection/checks look for .cursor/ / .opencode/ / .claude/ under $HOME, and does not prevent repo-local adapters from writing under the process CWD. It would be safer to pass Path.cwd() for workspace_root (repo-local), and rely on install_scope for Claude user-vs-project behavior, or otherwise ensure only user-scoped runtimes run in USER installs.
| wr = workspace_root if workspace_root is not None else Path.cwd() | ||
|
|
||
| # Determine which runtimes to clean, mirroring install-time logic. | ||
| all_runtimes = {"vscode", "copilot", "codex", "cursor", "opencode"} | ||
| all_runtimes = {"vscode", "copilot", "codex", "cursor", "opencode", "claude"} | ||
| if runtime: |
There was a problem hiding this comment.
In USER scope, workspace_root will typically be Path.home(), but remove_stale() still considers workspace-scoped runtimes (vscode/cursor/opencode) in all_runtimes. This can cause stale cleanup during apm install -g / user-scope uninstall to touch unexpected paths (and some adapters create directories on read). Consider deriving all_runtimes from install_scope (USER: home-scoped runtimes only) or requiring an explicit --runtime for workspace-scoped cleanup.
| else: | ||
| clean_claude_user = clean_claude_project = True | ||
|
|
||
| if clean_claude_project: |
There was a problem hiding this comment.
Claude project behavior is opt-in on install (only write .mcp.json when .claude/ exists), but stale cleanup removes entries from .mcp.json whenever it exists, even if .claude/ is absent. To keep behavior consistent and avoid modifying .mcp.json in repos that are not opted-in, gate project cleanup on (wr / '.claude').is_dir() (or track whether APM previously managed the file).
| if clean_claude_project: | |
| claude_project_enabled = (wr / ".claude").is_dir() | |
| if clean_claude_project and claude_project_enabled: |
| _rich_info( | ||
| f"+ Removed stale MCP server '{name}' from .mcp.json" | ||
| ) |
There was a problem hiding this comment.
These new CLI messages hardcode a leading "+" instead of using the repo's status-symbol convention (STATUS_SYMBOLS in src/apm_cli/utils/console.py). Consider emitting "Removed stale MCP server ..." and passing symbol="check" (or prefixing with [+]) so output is consistent across commands.
| use the process working directory (same as Cursor/VS Code paths). | ||
| install_scope: ``InstallScope`` for user vs project MCP paths (Claude). | ||
| """ | ||
| _ = workspace_root |
There was a problem hiding this comment.
workspace_root is now threaded through the install APIs, but it is explicitly ignored here (_ = workspace_root). This makes it easy for callers to think repo-local adapters will honor the provided root when they actually use os.getcwd(), which is especially risky for apm install -g (may write to the wrong directory). Either implement workspace_root handling (e.g., run repo-local adapter operations under that directory) or remove the parameter to avoid a misleading API.
| _ = workspace_root | ||
|
|
There was a problem hiding this comment.
workspace_root is accepted but currently unused (_ = workspace_root), so check_servers_needing_installation() still reads runtime configs relative to the process CWD (and some adapters create directories as a side effect of reads). If this parameter is intended for user-scope/global installs, it should be applied (e.g., temporarily chdir to workspace_root for repo-local runtimes) or removed to prevent incorrect assumptions by callers.
Closes #643
Summary
Adds Claude Code as a first-class MCP client for
apm install/ lockfile-driven MCP lifecycle: registry-backed server entries are merged into Claude’s documented config locations, with project vs user scope aligned toInstallScope(apm installvsapm install --global).Behavior
.mcp.jsonat the repo root (mcpServers), only when.claude/already exists (opt-in, same idea as Cursor +.cursor/).~/.claude.jsontop-levelmcpServerswhen scope is user / global install path.CopilotClientAdapter, then normalizes entries for Claude Code (e.g. drop Copilot-only stdio fields such astype: local, defaulttools, emptyid; keep remotes / HTTP shape per docs).--globalwhen MCP deps exist, so user-scoped Claude (and other home-dir runtimes) can be configured from the manifest.MCPIntegratorpassesinstall_scope(and related wiring) into install/uninstall paths; stale cleanup removes Claude entries from project.mcp.jsonand user~/.claude.jsonwhere applicable.MCPConflictDetectortreats Claude like othermcpServers-shaped clients.ClientFactoryregisters runtimeclaude/Claude.tests/unit/test_claude_mcp.py(adapter, merge/normalize,remove_stale); runtime script detection forclaudeintest_runtime_detection.py.Notes
apmfrom the project root for project MCP files.safe_installeraccepts optionalworkspace_root/install_scopefor API compatibility with the integrator;mcp_install_scopeis set on the adapter for Claude user vs project behavior.audit_report.py: extract pipe-escaping for Markdown tables into a variable to satisfy Python 3.11+ f-string rules (no behavior change).Minor style-only diffs in Python may appear from Black/isort on edited files; repo-wide formatting and CI alignment are out of scope here — see #645.
Testing
uv run pytest tests/unit/test_claude_mcp.py tests/unit/test_runtime_detection.py -xCONTRIBUTING.md