From 35a143e504b0fdcda97d28938e486316a6c5e56a Mon Sep 17 00:00:00 2001 From: Daniele Martinoli Date: Thu, 9 Apr 2026 22:04:18 +0200 Subject: [PATCH 01/10] MCP management for claude code Signed-off-by: Daniele Martinoli --- .../docs/integrations/ide-tool-integration.md | 11 +- src/apm_cli/adapters/client/claude.py | 209 +++++++++++++++ src/apm_cli/commands/install.py | 44 +++- src/apm_cli/commands/uninstall/cli.py | 10 +- src/apm_cli/commands/uninstall/engine.py | 12 +- src/apm_cli/core/conflict_detector.py | 9 +- src/apm_cli/core/operations.py | 23 +- src/apm_cli/core/safe_installer.py | 16 +- src/apm_cli/factory.py | 2 + src/apm_cli/integration/mcp_integrator.py | 241 +++++++++++++----- src/apm_cli/registry/operations.py | 46 +++- src/apm_cli/security/audit_report.py | 3 +- tests/unit/test_claude_mcp.py | 238 +++++++++++++++++ tests/unit/test_runtime_detection.py | 6 + 14 files changed, 774 insertions(+), 96 deletions(-) create mode 100644 src/apm_cli/adapters/client/claude.py create mode 100644 tests/unit/test_claude_mcp.py diff --git a/docs/src/content/docs/integrations/ide-tool-integration.md b/docs/src/content/docs/integrations/ide-tool-integration.md index 665511db5..4e6fafb50 100644 --- a/docs/src/content/docs/integrations/ide-tool-integration.md +++ b/docs/src/content/docs/integrations/ide-tool-integration.md @@ -469,8 +469,14 @@ APM configures MCP servers in the native config format for each supported client | VS Code | `.vscode/mcp.json` | JSON `servers` object | | GitHub Copilot CLI | `~/.copilot/mcp-config.json` | JSON `mcpServers` object | | Codex CLI | `~/.codex/config.toml` | TOML `mcp_servers` section | +| Cursor | `.cursor/mcp.json` | JSON `mcpServers` (when `.cursor/` exists) | +| OpenCode | `opencode.json` | JSON `mcp` object (when `.opencode/` exists) | +| Claude Code (project) | `.mcp.json` (project root) | JSON `mcpServers` (when `.claude/` exists) | +| Claude Code (user, `apm install -g`) | `~/.claude.json` | Top-level JSON `mcpServers` | -**Runtime targeting**: APM detects which runtimes are installed and configures MCP servers for all of them. Use `--runtime ` or `--exclude ` to control which clients receive configuration. +**Runtime targeting**: APM detects which runtimes are installed and configures MCP servers for all of them. Use `--runtime ` or `--exclude ` to control which clients receive configuration. Supported MCP runtime names include `copilot`, `codex`, `vscode`, `cursor`, `opencode`, and `claude`. + +> **Claude Code**: See [Claude Code MCP / `mcpServers`](https://code.claude.com/docs/en/mcp) for the upstream shape. APM **shallow-merges** each server name with what is already on disk so extra keys (for example OAuth) stay when APM refreshes `command` / `args`. It then **normalizes** stdio entries for Claude: Copilot-only noise such as `type: "local"`, `tools: ["*"]`, and empty `id` are dropped; non-empty registry `id` values and remote (`http` / `url`) entries are preserved as expected. > **VS Code detection**: APM considers VS Code available when either the `code` CLI command is on PATH **or** a `.vscode/` directory exists in the current working directory. This means VS Code MCP configuration works even when `code` is not on PATH — common on macOS and Linux when "Install 'code' command in PATH" has not been run from the VS Code command palette, or when VS Code was installed via a method that doesn't register the CLI (e.g. `.tar.gz`, Flatpak, or a non-standard macOS install location). @@ -484,6 +490,9 @@ apm install --runtime vscode # Skip Codex configuration apm install --exclude codex +# Target only Claude Code +apm install --runtime claude + # Install only MCP dependencies (skip APM packages) apm install --only mcp diff --git a/src/apm_cli/adapters/client/claude.py b/src/apm_cli/adapters/client/claude.py new file mode 100644 index 000000000..0ea7ac977 --- /dev/null +++ b/src/apm_cli/adapters/client/claude.py @@ -0,0 +1,209 @@ +"""Claude Code MCP client adapter. + +Project scope: ``.mcp.json`` at the workspace root with top-level ``mcpServers`` +(``--scope project`` in Claude Code). Writes are opt-in when ``.claude/`` exists, +matching Cursor-style directory detection. + +User scope: top-level ``mcpServers`` in ``~/.claude.json`` (``--scope user``). + +See https://code.claude.com/docs/en/mcp +""" + +import json +import os +from pathlib import Path + +from ...core.scope import InstallScope +from .copilot import CopilotClientAdapter + + +class ClaudeClientAdapter(CopilotClientAdapter): + """MCP configuration for Claude Code (``mcpServers`` schema). + + Registry formatting reuses :class:`CopilotClientAdapter`, then entries are + normalized for Claude Code’s on-disk shape (stdio servers omit Copilot-only + keys like ``type: "local"``, default ``tools``, and empty ``id``). + """ + + @staticmethod + def _normalize_mcp_entry_for_claude_code(entry: dict) -> dict: + """Drop Copilot-CLI-only fields that Claude Code does not use for stdio. + + Remote servers keep ``type``/``url`` (and related keys) per Claude Code + docs. See https://code.claude.com/docs/en/mcp + """ + if not isinstance(entry, dict): + return entry + out = dict(entry) + url = out.get("url") + t = out.get("type") + is_remote = bool(url) or t in ("http", "sse", "streamable-http") + + if is_remote: + if out.get("id") in ("", None): + out.pop("id", None) + if out.get("tools") == ["*"]: + out.pop("tools", None) + return out + + if out.get("type") == "local": + out.pop("type", None) + if out.get("tools") == ["*"]: + out.pop("tools", None) + if out.get("id") in ("", None): + out.pop("id", None) + return out + + @staticmethod + def _merge_mcp_server_dicts(existing_servers: dict, config_updates: dict) -> None: + """Merge *config_updates* into *existing_servers* in place. + + Per-server entries are shallow-merged: ``{**old, **new}`` so keys present + only on plugin- or hand-authored configs (e.g. ``type``, OAuth blocks) + survive when an update omits them. Keys in *new* overwrite *old* on + conflict so APM/registry installs still refresh ``command``/``args``/etc. + """ + for name, new_cfg in config_updates.items(): + if not isinstance(new_cfg, dict): + existing_servers[name] = new_cfg + continue + prev = existing_servers.get(name) + if isinstance(prev, dict): + merged = {**prev, **new_cfg} + existing_servers[name] = merged + else: + existing_servers[name] = dict(new_cfg) + + def _merge_and_normalize_updates(self, data: dict, config_updates: dict) -> None: + if "mcpServers" not in data: + data["mcpServers"] = {} + self._merge_mcp_server_dicts(data["mcpServers"], config_updates) + for name in config_updates: + ent = data["mcpServers"].get(name) + if isinstance(ent, dict): + data["mcpServers"][name] = self._normalize_mcp_entry_for_claude_code( + ent + ) + + def _workspace_root(self) -> Path: + """Project paths follow the same cwd convention as other repo-local adapters.""" + return Path(os.getcwd()) + + def _is_user_scope(self) -> bool: + return getattr(self, "mcp_install_scope", None) is InstallScope.USER + + def _project_mcp_path(self) -> Path: + return self._workspace_root() / ".mcp.json" + + def _user_claude_json_path(self) -> Path: + return Path.home() / ".claude.json" + + def _should_write_project(self) -> bool: + return (self._workspace_root() / ".claude").is_dir() + + def get_config_path(self): + if self._is_user_scope(): + return str(self._user_claude_json_path()) + return str(self._project_mcp_path()) + + def get_current_config(self): + if self._is_user_scope(): + path = self._user_claude_json_path() + if not path.is_file(): + return {"mcpServers": {}} + try: + data = json.loads(path.read_text(encoding="utf-8")) + if not isinstance(data, dict): + return {"mcpServers": {}} + return {"mcpServers": dict(data.get("mcpServers") or {})} + except (json.JSONDecodeError, OSError): + return {"mcpServers": {}} + path = self._project_mcp_path() + if not path.is_file(): + return {"mcpServers": {}} + try: + data = json.loads(path.read_text(encoding="utf-8")) + if not isinstance(data, dict): + return {"mcpServers": {}} + return {"mcpServers": dict(data.get("mcpServers") or {})} + except (json.JSONDecodeError, OSError): + return {"mcpServers": {}} + + def update_config(self, config_updates, enabled=True): + if self._is_user_scope(): + return self._merge_user_mcp(config_updates) + if not self._should_write_project(): + return True + path = self._project_mcp_path() + try: + if path.is_file(): + data = json.loads(path.read_text(encoding="utf-8")) + if not isinstance(data, dict): + data = {} + else: + data = {} + self._merge_and_normalize_updates(data, config_updates) + path.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8") + return True + except OSError: + return False + + def _merge_user_mcp(self, config_updates) -> bool: + path = self._user_claude_json_path() + try: + if path.is_file(): + data = json.loads(path.read_text(encoding="utf-8")) + if not isinstance(data, dict): + data = {} + else: + data = {} + self._merge_and_normalize_updates(data, config_updates) + path.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8") + return True + except OSError: + return False + + def configure_mcp_server( + self, + server_url, + server_name=None, + enabled=True, + env_overrides=None, + server_info_cache=None, + runtime_vars=None, + ): + if not server_url: + print("Error: server_url cannot be empty") + return False + + if not self._is_user_scope() and not self._should_write_project(): + return True + + try: + if server_info_cache and server_url in server_info_cache: + server_info = server_info_cache[server_url] + else: + server_info = self.registry_client.find_server_by_reference(server_url) + + if not server_info: + print(f"Error: MCP server '{server_url}' not found in registry") + return False + + if server_name: + config_key = server_name + elif "/" in server_url: + config_key = server_url.split("/")[-1] + else: + config_key = server_url + + server_config = self._format_server_config( + server_info, env_overrides, runtime_vars + ) + self.update_config({config_key: server_config}) + + print(f"Successfully configured MCP server '{config_key}' for Claude Code") + return True + + except Exception as e: + print(f"Error configuring MCP server: {e}") + return False diff --git a/src/apm_cli/commands/install.py b/src/apm_cli/commands/install.py index 09f57c6eb..23548ef6b 100644 --- a/src/apm_cli/commands/install.py +++ b/src/apm_cli/commands/install.py @@ -572,7 +572,11 @@ def _check_repo_fallback(token, git_env): help="Install APM and MCP dependencies (auto-creates apm.yml when installing packages)" ) @click.argument("packages", nargs=-1) -@click.option("--runtime", help="Target specific runtime only (copilot, codex, vscode)") +@click.option( + "--runtime", + help="Target specific MCP runtime only " + "(copilot, codex, vscode, cursor, opencode, claude)", +) @click.option("--exclude", help="Exclude specific runtime from installation") @click.option( "--only", @@ -652,7 +656,15 @@ def install(ctx, packages, runtime, exclude, only, update, dry_run, force, verbo logger = InstallLogger(verbose=verbose, dry_run=dry_run, partial=is_partial) # Resolve scope - from ..core.scope import InstallScope, get_apm_dir, get_manifest_path, get_modules_dir, ensure_user_dirs, warn_unsupported_user_scope + from ..core.scope import ( + InstallScope, + ensure_user_dirs, + get_apm_dir, + get_deploy_root, + get_manifest_path, + get_modules_dir, + warn_unsupported_user_scope, + ) scope = InstallScope.USER if global_ else InstallScope.PROJECT if scope is InstallScope.USER: @@ -740,13 +752,6 @@ def install(ctx, packages, runtime, exclude, only, update, dry_run, force, verbo # Determine what to install based on install mode should_install_apm = install_mode != InstallMode.MCP should_install_mcp = install_mode != InstallMode.APM - # MCP servers are workspace-scoped (.vscode/mcp.json); skip at user scope - if scope is InstallScope.USER: - should_install_mcp = False - if logger: - logger.verbose_detail( - "MCP servers skipped at user scope (workspace-scoped concept)" - ) # Show what will be installed if dry run if dry_run: @@ -851,9 +856,14 @@ def install(ctx, packages, runtime, exclude, only, update, dry_run, force, verbo new_mcp_servers: builtins.set = builtins.set() if should_install_mcp and mcp_deps: mcp_count = MCPIntegrator.install( - mcp_deps, runtime, exclude, verbose, + mcp_deps, + runtime, + exclude, + verbose, stored_mcp_configs=old_mcp_configs, diagnostics=apm_diagnostics, + workspace_root=get_deploy_root(scope), + install_scope=scope, ) new_mcp_servers = MCPIntegrator.get_server_names(mcp_deps) new_mcp_configs = MCPIntegrator.get_server_configs(mcp_deps) @@ -861,14 +871,24 @@ def install(ctx, packages, runtime, exclude, only, update, dry_run, force, verbo # Remove stale MCP servers that are no longer needed stale_servers = old_mcp_servers - new_mcp_servers if stale_servers: - MCPIntegrator.remove_stale(stale_servers, runtime, exclude) + MCPIntegrator.remove_stale( + stale_servers, + runtime, + exclude, + workspace_root=get_deploy_root(scope), + ) # Persist the new MCP server set and configs in the lockfile MCPIntegrator.update_lockfile(new_mcp_servers, mcp_configs=new_mcp_configs) elif should_install_mcp and not mcp_deps: # No MCP deps at all — remove any old APM-managed servers if old_mcp_servers: - MCPIntegrator.remove_stale(old_mcp_servers, runtime, exclude) + MCPIntegrator.remove_stale( + old_mcp_servers, + runtime, + exclude, + workspace_root=get_deploy_root(scope), + ) MCPIntegrator.update_lockfile(builtins.set(), mcp_configs={}) logger.verbose_detail("No MCP dependencies found in apm.yml") elif not should_install_mcp and old_mcp_servers: diff --git a/src/apm_cli/commands/uninstall/cli.py b/src/apm_cli/commands/uninstall/cli.py index 8bd453c44..dba206f70 100644 --- a/src/apm_cli/commands/uninstall/cli.py +++ b/src/apm_cli/commands/uninstall/cli.py @@ -194,8 +194,14 @@ def uninstall(ctx, packages, dry_run, verbose, global_): # Step 10: MCP cleanup try: apm_package = APMPackage.from_apm_yml(manifest_path) - _cleanup_stale_mcp(apm_package, lockfile, lockfile_path, _pre_uninstall_mcp_servers, - modules_dir=get_modules_dir(scope)) + _cleanup_stale_mcp( + apm_package, + lockfile, + lockfile_path, + _pre_uninstall_mcp_servers, + modules_dir=get_modules_dir(scope), + workspace_root=deploy_root, + ) except Exception: logger.warning("MCP cleanup during uninstall failed") diff --git a/src/apm_cli/commands/uninstall/engine.py b/src/apm_cli/commands/uninstall/engine.py index c2e7165f7..7e8b9666e 100644 --- a/src/apm_cli/commands/uninstall/engine.py +++ b/src/apm_cli/commands/uninstall/engine.py @@ -354,7 +354,14 @@ def _sync_integrations_after_uninstall(apm_package, project_root, all_deployed_f return counts -def _cleanup_stale_mcp(apm_package, lockfile, lockfile_path, old_mcp_servers, modules_dir=None): +def _cleanup_stale_mcp( + apm_package, + lockfile, + lockfile_path, + old_mcp_servers, + modules_dir=None, + workspace_root=None, +): """Remove MCP servers that are no longer needed after uninstall.""" if not old_mcp_servers: return @@ -367,6 +374,7 @@ def _cleanup_stale_mcp(apm_package, lockfile, lockfile_path, old_mcp_servers, mo all_remaining_mcp = MCPIntegrator.deduplicate(remaining_root_mcp + remaining_mcp) new_mcp_servers = MCPIntegrator.get_server_names(all_remaining_mcp) stale_servers = old_mcp_servers - new_mcp_servers + wr = workspace_root if workspace_root is not None else Path.cwd() if stale_servers: - MCPIntegrator.remove_stale(stale_servers) + MCPIntegrator.remove_stale(stale_servers, workspace_root=wr) MCPIntegrator.update_lockfile(new_mcp_servers, lockfile_path) diff --git a/src/apm_cli/core/conflict_detector.py b/src/apm_cli/core/conflict_detector.py index d0865fd5d..9ea5e6617 100644 --- a/src/apm_cli/core/conflict_detector.py +++ b/src/apm_cli/core/conflict_detector.py @@ -121,7 +121,14 @@ def get_existing_server_configs(self) -> Dict[str, Any]: return servers elif "vscode" in adapter_class_name: return existing_config.get("servers", {}) - + elif "claude" in adapter_class_name: + return existing_config.get("mcpServers", {}) + elif "cursor" in adapter_class_name: + return existing_config.get("mcpServers", {}) + elif "opencode" in adapter_class_name: + mcp = existing_config.get("mcp") or {} + return mcp if isinstance(mcp, dict) else {} + return {} def get_conflict_summary(self, server_reference: str) -> Dict[str, Any]: diff --git a/src/apm_cli/core/operations.py b/src/apm_cli/core/operations.py index 0b432482d..7258850bd 100644 --- a/src/apm_cli/core/operations.py +++ b/src/apm_cli/core/operations.py @@ -23,9 +23,18 @@ def configure_client(client_type, config_updates): return False -def install_package(client_type, package_name, version=None, shared_env_vars=None, server_info_cache=None, shared_runtime_vars=None): +def install_package( + client_type, + package_name, + version=None, + shared_env_vars=None, + server_info_cache=None, + shared_runtime_vars=None, + workspace_root=None, + install_scope=None, +): """Install an MCP package for a specific client type. - + Args: client_type (str): Type of client to configure. package_name (str): Name of the package to install. @@ -33,13 +42,19 @@ def install_package(client_type, package_name, version=None, shared_env_vars=Non shared_env_vars (dict, optional): Pre-collected environment variables to use. server_info_cache (dict, optional): Pre-fetched server info to avoid duplicate registry calls. shared_runtime_vars (dict, optional): Pre-collected runtime variables to use. - + workspace_root (Path, optional): Root for repo-local MCP configs. + install_scope: ``InstallScope`` for user vs project MCP (Claude). + Returns: dict: Result with 'success' (bool), 'installed' (bool), 'skipped' (bool) keys. """ try: # Use safe installer with conflict detection - safe_installer = SafeMCPInstaller(client_type) + safe_installer = SafeMCPInstaller( + client_type, + workspace_root=workspace_root, + install_scope=install_scope, + ) # Pass shared environment and runtime variables and server info cache if available if shared_env_vars is not None or server_info_cache is not None or shared_runtime_vars is not None: diff --git a/src/apm_cli/core/safe_installer.py b/src/apm_cli/core/safe_installer.py index 6ce93af40..624e26d9b 100644 --- a/src/apm_cli/core/safe_installer.py +++ b/src/apm_cli/core/safe_installer.py @@ -2,6 +2,7 @@ from typing import List, Dict, Any, Optional from dataclasses import dataclass +from pathlib import Path from ..factory import ClientFactory from .conflict_detector import MCPConflictDetector from ..utils.console import _rich_warning, _rich_success, _rich_error @@ -10,7 +11,7 @@ @dataclass class InstallationSummary: """Summary of MCP server installation results.""" - + def __init__(self): self.installed = [] self.skipped = [] @@ -58,15 +59,20 @@ def log_summary(self, logger=None): class SafeMCPInstaller: """Safe MCP server installation with conflict detection.""" - def __init__(self, runtime: str, logger=None): + def __init__(self, runtime: str, logger=None, workspace_root: Optional[Path] = None, install_scope=None): """Initialize the safe installer. - + Args: - runtime: Target runtime (copilot, codex, vscode). + runtime: Target runtime (copilot, codex, vscode, claude, ...). logger: Optional CommandLogger for structured output. + workspace_root: Reserved for API compatibility; repo-local adapters + use the process working directory (same as Cursor/VS Code paths). + install_scope: ``InstallScope`` for user vs project MCP paths (Claude). """ + _ = workspace_root self.runtime = runtime self.adapter = ClientFactory.create_client(runtime) + self.adapter.mcp_install_scope = install_scope self.conflict_detector = MCPConflictDetector(self.adapter) self.logger = logger @@ -156,4 +162,4 @@ def check_conflicts_only(self, server_references: List[str]) -> Dict[str, Any]: for server_ref in server_references: conflicts[server_ref] = self.conflict_detector.get_conflict_summary(server_ref) - return conflicts \ No newline at end of file + return conflicts diff --git a/src/apm_cli/factory.py b/src/apm_cli/factory.py index 4f6295a86..91a805459 100644 --- a/src/apm_cli/factory.py +++ b/src/apm_cli/factory.py @@ -1,5 +1,6 @@ """Factory classes for creating adapters.""" +from .adapters.client.claude import ClaudeClientAdapter from .adapters.client.vscode import VSCodeClientAdapter from .adapters.client.codex import CodexClientAdapter from .adapters.client.copilot import CopilotClientAdapter @@ -30,6 +31,7 @@ def create_client(client_type): "codex": CodexClientAdapter, "cursor": CursorClientAdapter, "opencode": OpenCodeClientAdapter, + "claude": ClaudeClientAdapter, # Add more clients as needed } diff --git a/src/apm_cli/integration/mcp_integrator.py b/src/apm_cli/integration/mcp_integrator.py index 80c3e6047..1a8f86427 100644 --- a/src/apm_cli/integration/mcp_integrator.py +++ b/src/apm_cli/integration/mcp_integrator.py @@ -19,6 +19,7 @@ import click +from apm_cli.core.scope import InstallScope from apm_cli.deps.lockfile import LockFile, get_lockfile_path from apm_cli.utils.console import ( _get_console, @@ -90,7 +91,10 @@ def collect_transitive( for dep in lockfile.get_all_dependencies(): if dep.repo_url: yml = ( - apm_modules_dir / dep.repo_url / dep.virtual_path / "apm.yml" + apm_modules_dir + / dep.repo_url + / dep.virtual_path + / "apm.yml" if dep.virtual_path else apm_modules_dir / dep.repo_url / "apm.yml" ) @@ -399,6 +403,8 @@ def _detect_mcp_config_drift( def _check_self_defined_servers_needing_installation( dep_names: list, target_runtimes: list, + workspace_root: Optional[Path] = None, + install_scope=None, ) -> list: """Return self-defined MCP servers missing from at least one runtime. @@ -406,6 +412,7 @@ def _check_self_defined_servers_needing_installation( the runtime config keys directly. Runtime config reads are cached per runtime to avoid repeating the same client setup for every dependency. """ + _ = workspace_root try: from apm_cli.core.conflict_detector import MCPConflictDetector from apm_cli.factory import ClientFactory @@ -417,6 +424,7 @@ def _check_self_defined_servers_needing_installation( for runtime in target_runtimes: try: client = ClientFactory.create_client(runtime) + client.mcp_install_scope = install_scope detector = MCPConflictDetector(client) runtime_existing[runtime] = detector.get_existing_server_configs() except Exception: @@ -444,6 +452,7 @@ def remove_stale( runtime: str = None, exclude: str = None, logger=None, + workspace_root: Optional[Path] = None, ) -> None: """Remove MCP server entries that are no longer required by any dependency. @@ -456,8 +465,10 @@ def remove_stale( if not stale_names: return + 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: target_runtimes = {runtime} else: @@ -475,7 +486,7 @@ def remove_stale( # Clean .vscode/mcp.json if "vscode" in target_runtimes: - vscode_mcp = Path.cwd() / ".vscode" / "mcp.json" + vscode_mcp = wr / ".vscode" / "mcp.json" if vscode_mcp.exists(): try: import json as _json @@ -556,7 +567,7 @@ def remove_stale( # Clean .cursor/mcp.json (only if .cursor/ directory exists) if "cursor" in target_runtimes: - cursor_mcp = Path.cwd() / ".cursor" / "mcp.json" + cursor_mcp = wr / ".cursor" / "mcp.json" if cursor_mcp.exists(): try: import json as _json @@ -580,10 +591,69 @@ def remove_stale( exc_info=True, ) + # Clean Claude Code project .mcp.json (mcpServers) + if "claude" in target_runtimes: + claude_proj = wr / ".mcp.json" + if claude_proj.exists(): + try: + import json as _json + + config = _json.loads(claude_proj.read_text(encoding="utf-8")) + servers = config.get("mcpServers", {}) + removed = [n for n in expanded_stale if n in servers] + for name in removed: + del servers[name] + if removed: + claude_proj.write_text( + _json.dumps(config, indent=2), encoding="utf-8" + ) + for name in removed: + if logger: + logger.progress( + f"Removed stale MCP server '{name}' from .mcp.json" + ) + else: + _rich_info( + f"+ Removed stale MCP server '{name}' from .mcp.json" + ) + except Exception: + _log.debug( + "Failed to clean stale MCP servers from .mcp.json", + exc_info=True, + ) + claude_user = Path.home() / ".claude.json" + if claude_user.exists(): + try: + import json as _json + + config = _json.loads(claude_user.read_text(encoding="utf-8")) + servers = config.get("mcpServers", {}) + removed = [n for n in expanded_stale if n in servers] + for name in removed: + del servers[name] + if removed: + claude_user.write_text( + _json.dumps(config, indent=2), encoding="utf-8" + ) + for name in removed: + if logger: + logger.progress( + f"Removed stale MCP server '{name}' from ~/.claude.json" + ) + else: + _rich_info( + f"+ Removed stale MCP server '{name}' from ~/.claude.json" + ) + except Exception: + _log.debug( + "Failed to clean stale MCP servers from ~/.claude.json", + exc_info=True, + ) + # Clean opencode.json (only if .opencode/ directory exists) if "opencode" in target_runtimes: - opencode_cfg = Path.cwd() / "opencode.json" - if opencode_cfg.exists() and (Path.cwd() / ".opencode").is_dir(): + opencode_cfg = wr / "opencode.json" + if opencode_cfg.exists() and (wr / ".opencode").is_dir(): try: import json as _json @@ -669,6 +739,8 @@ def _detect_runtimes(scripts: dict) -> List[str]: detected.add("codex") if re.search(r"\bllm\b", command): detected.add("llm") + if re.search(r"\bclaude\b", command): + detected.add("claude") return builtins.list(detected) @@ -702,7 +774,9 @@ def _filter_runtimes(detected_runtimes: List[str]) -> List[str]: except ImportError: mcp_compatible = [ - rt for rt in detected_runtimes if rt in ["vscode", "copilot", "cursor", "opencode"] + rt + for rt in detected_runtimes + if rt in ["vscode", "copilot", "cursor", "opencode", "claude"] ] return [rt for rt in mcp_compatible if shutil.which(rt)] @@ -718,6 +792,8 @@ def _install_for_runtime( server_info_cache: dict = None, shared_runtime_vars: dict = None, logger=None, + workspace_root: Optional[Path] = None, + install_scope=None, ) -> bool: """Install MCP dependencies for a specific runtime. @@ -725,10 +801,6 @@ def _install_for_runtime( """ try: from apm_cli.core.operations import install_package - from apm_cli.factory import ClientFactory - - # Get the appropriate client for the runtime - client = ClientFactory.create_client(runtime) all_ok = True for dep in mcp_deps: @@ -743,6 +815,8 @@ def _install_for_runtime( shared_env_vars=shared_env_vars, server_info_cache=server_info_cache, shared_runtime_vars=shared_runtime_vars, + workspace_root=workspace_root, + install_scope=install_scope, ) if result["failed"]: if logger: @@ -766,19 +840,27 @@ def _install_for_runtime( except ImportError as e: if logger: - logger.warning(f"Core operations not available for runtime {runtime}: {e}") + logger.warning( + f"Core operations not available for runtime {runtime}: {e}" + ) logger.progress(f"Dependencies for {runtime}: {', '.join(mcp_deps)}") else: - _rich_warning(f"Core operations not available for runtime {runtime}: {e}") + _rich_warning( + f"Core operations not available for runtime {runtime}: {e}" + ) _rich_info(f"Dependencies for {runtime}: {', '.join(mcp_deps)}") return False except ValueError as e: if logger: logger.warning(f"Runtime {runtime} not supported: {e}") - logger.progress("Supported runtimes: vscode, copilot, codex, cursor, opencode, llm") + logger.progress( + "Supported runtimes: vscode, copilot, codex, cursor, opencode, claude, llm" + ) else: _rich_warning(f"Runtime {runtime} not supported: {e}") - _rich_info("Supported runtimes: vscode, copilot, codex, cursor, opencode, llm") + _rich_info( + "Supported runtimes: vscode, copilot, codex, cursor, opencode, claude, llm" + ) return False except Exception as e: _log.debug( @@ -804,6 +886,8 @@ def install( stored_mcp_configs: dict = None, logger=None, diagnostics=None, + workspace_root: Optional[Path] = None, + install_scope=None, ) -> int: """Install MCP dependencies. @@ -818,6 +902,11 @@ def install( stored_mcp_configs: Previously stored MCP configs from lockfile for diff-aware installation. When provided, servers whose manifest config has changed are re-applied automatically. + workspace_root: Root for repo-local MCP configs (VS Code, Cursor, + OpenCode, Claude project ``.mcp.json``). Defaults to cwd. + install_scope: ``InstallScope.USER`` or ``InstallScope.PROJECT``; + controls Claude Code user ``~/.claude.json`` vs project + ``.mcp.json``. Returns: Number of MCP servers newly configured or updated. @@ -879,6 +968,8 @@ def install( else: _rich_info(f"Installing MCP dependencies ({len(mcp_deps)})...") + wr = workspace_root if workspace_root is not None else Path.cwd() + # Runtime detection and multi-runtime installation if runtime: # Single runtime mode @@ -894,6 +985,7 @@ def install( apm_yml = Path("apm.yml") if apm_yml.exists(): from apm_cli.utils.yaml_io import load_yaml + apm_config = load_yaml(apm_yml) except Exception: apm_config = None @@ -906,20 +998,32 @@ def install( manager = RuntimeManager() installed_runtimes = [] - for runtime_name in ["copilot", "codex", "vscode", "cursor", "opencode"]: + for runtime_name in [ + "copilot", + "codex", + "vscode", + "cursor", + "opencode", + "claude", + ]: try: if runtime_name == "vscode": if _is_vscode_available(): ClientFactory.create_client(runtime_name) installed_runtimes.append(runtime_name) elif runtime_name == "cursor": - # Cursor is opt-in: only target when .cursor/ exists - if (Path.cwd() / ".cursor").is_dir(): + if (wr / ".cursor").is_dir(): ClientFactory.create_client(runtime_name) installed_runtimes.append(runtime_name) elif runtime_name == "opencode": - # OpenCode is opt-in: only target when .opencode/ exists - if (Path.cwd() / ".opencode").is_dir(): + if (wr / ".opencode").is_dir(): + ClientFactory.create_client(runtime_name) + installed_runtimes.append(runtime_name) + elif runtime_name == "claude": + if install_scope is InstallScope.USER: + ClientFactory.create_client(runtime_name) + installed_runtimes.append(runtime_name) + elif (wr / ".claude").is_dir(): ClientFactory.create_client(runtime_name) installed_runtimes.append(runtime_name) else: @@ -930,19 +1034,31 @@ def install( continue except ImportError: installed_runtimes = [ - rt - for rt in ["copilot", "codex"] - if shutil.which(rt) is not None + rt for rt in ["copilot", "codex"] if shutil.which(rt) is not None ] # VS Code: check binary on PATH or .vscode/ directory presence if _is_vscode_available(): installed_runtimes.append("vscode") - # Cursor is directory-presence based, not binary-based - if (Path.cwd() / ".cursor").is_dir(): + if (wr / ".cursor").is_dir(): installed_runtimes.append("cursor") - # OpenCode is directory-presence based - if (Path.cwd() / ".opencode").is_dir(): + if (wr / ".opencode").is_dir(): installed_runtimes.append("opencode") + if install_scope is InstallScope.USER: + try: + from apm_cli.factory import ClientFactory as _CF + + _CF.create_client("claude") + installed_runtimes.append("claude") + except (ValueError, ImportError): + pass + elif (wr / ".claude").is_dir(): + try: + from apm_cli.factory import ClientFactory as _CF + + _CF.create_client("claude") + installed_runtimes.append("claude") + except (ValueError, ImportError): + pass # Step 2: Get runtimes referenced in apm.yml scripts script_runtimes = MCPIntegrator._detect_runtimes( @@ -985,13 +1101,9 @@ def install( _rich_info( f"Installed runtimes: {', '.join(installed_runtimes)}" ) - _rich_info( - f"Script runtimes: {', '.join(script_runtimes)}" - ) + _rich_info(f"Script runtimes: {', '.join(script_runtimes)}") if target_runtimes: - _rich_info( - f"Target runtimes: {', '.join(target_runtimes)}" - ) + _rich_info(f"Target runtimes: {', '.join(target_runtimes)}") if not target_runtimes: if logger: @@ -1025,7 +1137,9 @@ def install( else: if logger: logger.warning("No MCP-compatible runtimes installed") - logger.progress("Install a runtime with: apm runtime setup copilot") + logger.progress( + "Install a runtime with: apm runtime setup copilot" + ) else: _rich_warning("No MCP-compatible runtimes installed") _rich_info("Install a runtime with: apm runtime setup copilot") @@ -1100,15 +1214,14 @@ def install( ) if valid_servers: - servers_to_install = ( - operations.check_servers_needing_installation( - target_runtimes, valid_servers - ) + servers_to_install = operations.check_servers_needing_installation( + target_runtimes, + valid_servers, + workspace_root=wr, + install_scope=install_scope, ) already_configured_candidates = [ - dep - for dep in valid_servers - if dep not in servers_to_install + dep for dep in valid_servers if dep not in servers_to_install ] # Detect config drift for "already configured" servers @@ -1119,11 +1232,14 @@ def install( if n in registry_dep_map ] drifted = MCPIntegrator._detect_mcp_config_drift( - drifted_reg_deps, stored_mcp_configs, + drifted_reg_deps, + stored_mcp_configs, ) if drifted: servers_to_update.update(drifted) - MCPIntegrator._append_drifted_to_install_list(servers_to_install, drifted) + MCPIntegrator._append_drifted_to_install_list( + servers_to_install, drifted + ) already_configured_servers = [ dep for dep in already_configured_candidates @@ -1142,9 +1258,7 @@ def install( "All registry MCP servers already configured" ) else: - _rich_success( - "All registry MCP servers already configured" - ) + _rich_success("All registry MCP servers already configured") else: if already_configured_servers: if console: @@ -1182,24 +1296,18 @@ def install( for server_name in servers_to_install: dep = registry_dep_map.get(server_name) if dep: - MCPIntegrator._apply_overlay( - server_info_cache, dep - ) + MCPIntegrator._apply_overlay(server_info_cache, dep) # Collect env and runtime variables - shared_env_vars = ( - operations.collect_environment_variables( - servers_to_install, server_info_cache - ) + shared_env_vars = operations.collect_environment_variables( + servers_to_install, server_info_cache ) for server_name in servers_to_install: dep = registry_dep_map.get(server_name) if dep and dep.env: shared_env_vars.update(dep.env) - shared_runtime_vars = ( - operations.collect_runtime_variables( - servers_to_install, server_info_cache - ) + shared_runtime_vars = operations.collect_runtime_variables( + servers_to_install, server_info_cache ) # Install for each target runtime @@ -1227,6 +1335,8 @@ def install( server_info_cache, shared_runtime_vars, logger=logger, + workspace_root=wr, + install_scope=install_scope, ): any_ok = True @@ -1269,6 +1379,8 @@ def install( MCPIntegrator._check_self_defined_servers_needing_installation( self_defined_names, target_runtimes, + workspace_root=wr, + install_scope=install_scope, ) ) already_configured_candidates_sd = [ @@ -1280,15 +1392,19 @@ def install( # Detect config drift for "already configured" self-defined servers if stored_mcp_configs and already_configured_candidates_sd: drifted_sd_deps = [ - dep for dep in self_defined_deps + dep + for dep in self_defined_deps if dep.name in already_configured_candidates_sd ] drifted_sd = MCPIntegrator._detect_mcp_config_drift( - drifted_sd_deps, stored_mcp_configs, + drifted_sd_deps, + stored_mcp_configs, ) if drifted_sd: servers_to_update.update(drifted_sd) - MCPIntegrator._append_drifted_to_install_list(self_defined_to_install, drifted_sd) + MCPIntegrator._append_drifted_to_install_list( + self_defined_to_install, drifted_sd + ) already_configured_self_defined = [ name for name in already_configured_candidates_sd @@ -1349,6 +1465,8 @@ def install( self_defined_env, self_defined_cache, logger=logger, + workspace_root=wr, + install_scope=install_scope, ): any_ok = True @@ -1365,8 +1483,7 @@ def install( successful_updates.add(dep.name) elif console: console.print( - f"| [red]x[/red] {dep.name} -- " - f"failed for all runtimes" + f"| [red]x[/red] {dep.name} -- " f"failed for all runtimes" ) # Close the panel diff --git a/src/apm_cli/registry/operations.py b/src/apm_cli/registry/operations.py index 0ced867c2..45231f610 100644 --- a/src/apm_cli/registry/operations.py +++ b/src/apm_cli/registry/operations.py @@ -24,7 +24,13 @@ def __init__(self, registry_url: Optional[str] = None): """ self.registry_client = SimpleRegistryClient(registry_url) - def check_servers_needing_installation(self, target_runtimes: List[str], server_references: List[str]) -> List[str]: + def check_servers_needing_installation( + self, + target_runtimes: List[str], + server_references: List[str], + workspace_root: Optional[Path] = None, + install_scope=None, + ) -> List[str]: """Check which MCP servers actually need installation across target runtimes. This method checks the actual MCP configuration files to see which servers @@ -60,7 +66,11 @@ def check_servers_needing_installation(self, target_runtimes: List[str], server_ # Check if this server needs installation in ANY of the target runtimes needs_installation = False for runtime in target_runtimes: - runtime_installed_ids = self._get_installed_server_ids([runtime]) + runtime_installed_ids = self._get_installed_server_ids( + [runtime], + workspace_root=workspace_root, + install_scope=install_scope, + ) if server_id not in runtime_installed_ids: needs_installation = True break @@ -74,7 +84,12 @@ def check_servers_needing_installation(self, target_runtimes: List[str], server_ return list(servers_needing_installation) - def _get_installed_server_ids(self, target_runtimes: List[str]) -> Set[str]: + def _get_installed_server_ids( + self, + target_runtimes: List[str], + workspace_root: Optional[Path] = None, + install_scope=None, + ) -> Set[str]: """Get all installed server IDs across target runtimes. Args: @@ -84,16 +99,19 @@ def _get_installed_server_ids(self, target_runtimes: List[str]) -> Set[str]: Set of server IDs that are currently installed """ installed_ids = set() - + # Import here to avoid circular imports try: from ..factory import ClientFactory except ImportError: return installed_ids - + + _ = workspace_root + for runtime in target_runtimes: try: client = ClientFactory.create_client(runtime) + client.mcp_install_scope = install_scope config = client.get_current_config() if isinstance(config, dict): @@ -128,7 +146,23 @@ def _get_installed_server_ids(self, target_runtimes: List[str]) -> Set[str]: ) if server_id: installed_ids.add(server_id) - + + elif runtime in ("cursor", "claude"): + mcp_servers = config.get("mcpServers", {}) + for server_name, server_config in mcp_servers.items(): + if isinstance(server_config, dict): + server_id = server_config.get("id") + if server_id: + installed_ids.add(server_id) + elif runtime == 'opencode': + mcp_block = config.get("mcp") or {} + if isinstance(mcp_block, dict): + for server_name, server_config in mcp_block.items(): + if isinstance(server_config, dict): + server_id = server_config.get("id") + if server_id: + installed_ids.add(server_id) + except Exception: # If we can't read a runtime's config, skip it continue diff --git a/src/apm_cli/security/audit_report.py b/src/apm_cli/security/audit_report.py index c85b729bb..03c30c7c4 100644 --- a/src/apm_cli/security/audit_report.py +++ b/src/apm_cli/security/audit_report.py @@ -225,9 +225,10 @@ def findings_to_markdown( ] for f in sorted_findings: sev = f.severity.upper() + desc_escaped = f.description.replace("|", "\\|") lines.append( f"| {sev} | `{relative_path_for_report(f.file)}` | {f.line}:{f.column}" - f" | `{f.codepoint}` | {f.description.replace('|', '\\|')} |" + f" | `{f.codepoint}` | {desc_escaped} |" ) lines.append("") lines.append("Run `apm audit --strip` to remove flagged characters.\n") diff --git a/tests/unit/test_claude_mcp.py b/tests/unit/test_claude_mcp.py new file mode 100644 index 000000000..f01a5c281 --- /dev/null +++ b/tests/unit/test_claude_mcp.py @@ -0,0 +1,238 @@ +"""Unit tests for ClaudeClientAdapter and Claude MCP integrator wiring.""" + +import json +import os +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + +from apm_cli.adapters.client.claude import ClaudeClientAdapter +from apm_cli.core.scope import InstallScope +from apm_cli.factory import ClientFactory + + +class TestClaudeClientFactory(unittest.TestCase): + def test_create_claude_client(self): + """ClientFactory returns ClaudeClientAdapter for runtime 'claude'.""" + client = ClientFactory.create_client("claude") + self.assertIsInstance(client, ClaudeClientAdapter) + + def test_create_claude_client_case_insensitive(self): + """Runtime name 'Claude' is accepted and maps to ClaudeClientAdapter.""" + client = ClientFactory.create_client("Claude") + self.assertIsInstance(client, ClaudeClientAdapter) + + +class TestClaudeClientAdapterProject(unittest.TestCase): + """Project scope: .mcp.json when .claude/ exists.""" + + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.root = Path(self.tmp.name) + (self.root / ".claude").mkdir() + self.mcp_json = self.root / ".mcp.json" + self._prev_cwd = os.getcwd() + os.chdir(self.root) + self.adapter = ClaudeClientAdapter() + self.adapter.mcp_install_scope = InstallScope.PROJECT + + def tearDown(self): + os.chdir(self._prev_cwd) + self.tmp.cleanup() + + def test_get_config_path_project(self): + """Project scope config path resolves to .mcp.json under cwd.""" + self.assertEqual( + Path(self.adapter.get_config_path()).resolve(), self.mcp_json.resolve() + ) + + def test_get_current_config_missing(self): + """Missing .mcp.json yields empty mcpServers.""" + self.assertEqual(self.adapter.get_current_config(), {"mcpServers": {}}) + + def test_update_config_merges(self): + """update_config merges new servers into existing mcpServers.""" + self.mcp_json.write_text( + json.dumps({"mcpServers": {"a": {"command": "x"}}}), + encoding="utf-8", + ) + self.adapter.update_config({"b": {"command": "y"}}) + data = json.loads(self.mcp_json.read_text(encoding="utf-8")) + self.assertIn("a", data["mcpServers"]) + self.assertIn("b", data["mcpServers"]) + + def test_update_config_preserves_plugin_only_keys_per_server(self): + """Merge + Claude normalize: Copilot-only keys dropped; plugin extras kept.""" + self.mcp_json.write_text( + json.dumps( + { + "mcpServers": { + "lightspeed-mcp": { + "type": "local", + "tools": ["*"], + "id": "", + "command": "podman", + "args": ["run", "--rm"], + "oauth": {"callbackPort": 8080}, + } + } + } + ), + encoding="utf-8", + ) + self.adapter.update_config( + { + "lightspeed-mcp": { + "command": "podman", + "args": ["run", "--rm", "-i"], + "tools": ["*"], + } + } + ) + data = json.loads(self.mcp_json.read_text(encoding="utf-8")) + srv = data["mcpServers"]["lightspeed-mcp"] + self.assertNotIn("type", srv) + self.assertNotIn("tools", srv) + self.assertNotIn("id", srv) + self.assertIn("oauth", srv) + self.assertEqual(srv["args"], ["run", "--rm", "-i"]) + + def test_update_config_noop_without_claude_dir(self): + """Without .claude/, project update_config does not create .mcp.json.""" + (self.root / ".claude").rmdir() + self.adapter.update_config({"s": {"command": "x"}}) + self.assertFalse(self.mcp_json.exists()) + + @patch("apm_cli.registry.client.SimpleRegistryClient.find_server_by_reference") + def test_configure_mcp_server(self, mock_find): + """configure_mcp_server writes normalized stdio entry from registry mock.""" + mock_find.return_value = { + "id": "", + "name": "test-server", + "packages": [{"registry_name": "npm", "name": "test-pkg", "arguments": []}], + "environment_variables": [], + } + ok = self.adapter.configure_mcp_server("test-server", "srv") + self.assertTrue(ok) + data = json.loads(self.mcp_json.read_text(encoding="utf-8")) + self.assertIn("srv", data["mcpServers"]) + srv = data["mcpServers"]["srv"] + self.assertNotIn("type", srv) + self.assertNotIn("tools", srv) + self.assertNotIn("id", srv) + self.assertEqual(srv["command"], "npx") + + def test_normalize_keeps_http_remote_shape(self): + """HTTP remote entries keep type/url/headers; strip empty id and default tools.""" + raw = { + "type": "http", + "url": "https://example.com/mcp", + "tools": ["*"], + "id": "", + "headers": {"X": "y"}, + } + norm = ClaudeClientAdapter._normalize_mcp_entry_for_claude_code(raw) + self.assertEqual(norm["type"], "http") + self.assertEqual(norm["url"], "https://example.com/mcp") + self.assertIn("headers", norm) + self.assertNotIn("id", norm) + self.assertNotIn("tools", norm) + + def test_normalize_stdio_keeps_nonempty_registry_id(self): + """Stdio normalize drops type/tools but keeps non-empty server id.""" + raw = { + "type": "local", + "tools": ["*"], + "id": "550e8400-e29b-41d4-a716-446655440000", + "command": "npx", + "args": ["-y", "pkg"], + } + norm = ClaudeClientAdapter._normalize_mcp_entry_for_claude_code(raw) + self.assertNotIn("type", norm) + self.assertNotIn("tools", norm) + self.assertEqual(norm["id"], "550e8400-e29b-41d4-a716-446655440000") + + +class TestClaudeClientAdapterUser(unittest.TestCase): + """User scope: ~/.claude.json top-level mcpServers.""" + + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.home = Path(self.tmp.name) + self.claude_json = self.home / ".claude.json" + self.adapter = ClaudeClientAdapter() + self.adapter.mcp_install_scope = InstallScope.USER + self._home = patch.object(Path, "home", return_value=self.home) + self._home.start() + + def tearDown(self): + self._home.stop() + self.tmp.cleanup() + + def test_merge_user_claude_json(self): + """User scope update_config merges mcpServers and preserves other top-level keys.""" + self.claude_json.write_text( + json.dumps({"projects": {}, "mcpServers": {"x": {"command": "a"}}}), + encoding="utf-8", + ) + self.adapter.update_config({"y": {"command": "b"}}) + data = json.loads(self.claude_json.read_text(encoding="utf-8")) + self.assertIn("projects", data) + self.assertIn("x", data["mcpServers"]) + self.assertIn("y", data["mcpServers"]) + + +class TestMCPIntegratorClaudeStaleCleanup(unittest.TestCase): + """MCPIntegrator.remove_stale for Claude project .mcp.json and user .claude.json.""" + + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.root = Path(self.tmp.name) + + def tearDown(self): + self.tmp.cleanup() + + def test_remove_stale_claude_project_mcp_json(self): + """remove_stale drops named servers from project .mcp.json at workspace_root.""" + from apm_cli.integration.mcp_integrator import MCPIntegrator + + mcp = self.root / ".mcp.json" + mcp.write_text( + json.dumps( + {"mcpServers": {"keep": {"command": "k"}, "stale": {"command": "s"}}} + ), + encoding="utf-8", + ) + MCPIntegrator.remove_stale( + {"stale"}, runtime="claude", workspace_root=self.root + ) + data = json.loads(mcp.read_text(encoding="utf-8")) + self.assertIn("keep", data["mcpServers"]) + self.assertNotIn("stale", data["mcpServers"]) + + def test_remove_stale_claude_user_claude_json(self): + """remove_stale drops named servers from ~/.claude.json mcpServers.""" + from apm_cli.integration.mcp_integrator import MCPIntegrator + + with patch.object(Path, "home", return_value=self.root): + cfg = self.root / ".claude.json" + cfg.write_text( + json.dumps( + { + "mcpServers": { + "keep": {"command": "k"}, + "stale": {"command": "s"}, + } + } + ), + encoding="utf-8", + ) + MCPIntegrator.remove_stale({"stale"}, runtime="claude") + data = json.loads(cfg.read_text(encoding="utf-8")) + self.assertIn("keep", data["mcpServers"]) + self.assertNotIn("stale", data["mcpServers"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_runtime_detection.py b/tests/unit/test_runtime_detection.py index 386becbe8..4dd8f46bc 100644 --- a/tests/unit/test_runtime_detection.py +++ b/tests/unit/test_runtime_detection.py @@ -80,6 +80,12 @@ def test_detect_runtime_word_boundaries(self): detected = MCPIntegrator._detect_runtimes(scripts) self.assertEqual(detected, ["copilot"]) + def test_detect_claude_in_scripts(self): + """Detect claude runtime when apm.yml scripts mention the claude CLI.""" + scripts = {"cc": "claude --verbose"} + detected = MCPIntegrator._detect_runtimes(scripts) + self.assertEqual(detected, ["claude"]) + class TestRuntimeFiltering(unittest.TestCase): """Test cases for filtering available runtimes.""" From 424526d2c5c364fa5cb64ad9cc6f48affa5d831e Mon Sep 17 00:00:00 2001 From: Daniele Martinoli <86618610+dmartinol@users.noreply.github.com> Date: Thu, 9 Apr 2026 22:57:25 +0200 Subject: [PATCH 02/10] Update src/apm_cli/integration/mcp_integrator.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/apm_cli/integration/mcp_integrator.py | 110 ++++++++++++---------- 1 file changed, 58 insertions(+), 52 deletions(-) diff --git a/src/apm_cli/integration/mcp_integrator.py b/src/apm_cli/integration/mcp_integrator.py index 1a8f86427..af1fbcb36 100644 --- a/src/apm_cli/integration/mcp_integrator.py +++ b/src/apm_cli/integration/mcp_integrator.py @@ -593,62 +593,68 @@ def remove_stale( # Clean Claude Code project .mcp.json (mcpServers) if "claude" in target_runtimes: - claude_proj = wr / ".mcp.json" - if claude_proj.exists(): - try: - import json as _json + clean_claude_user = install_scope is InstallScope.USER + clean_claude_project = install_scope is not InstallScope.USER - config = _json.loads(claude_proj.read_text(encoding="utf-8")) - servers = config.get("mcpServers", {}) - removed = [n for n in expanded_stale if n in servers] - for name in removed: - del servers[name] - if removed: - claude_proj.write_text( - _json.dumps(config, indent=2), encoding="utf-8" - ) - for name in removed: - if logger: - logger.progress( - f"Removed stale MCP server '{name}' from .mcp.json" - ) - else: - _rich_info( - f"+ Removed stale MCP server '{name}' from .mcp.json" - ) - except Exception: - _log.debug( - "Failed to clean stale MCP servers from .mcp.json", - exc_info=True, - ) - claude_user = Path.home() / ".claude.json" - if claude_user.exists(): - try: - import json as _json + if clean_claude_project: + claude_proj = wr / ".mcp.json" + if claude_proj.exists(): + try: + import json as _json - config = _json.loads(claude_user.read_text(encoding="utf-8")) - servers = config.get("mcpServers", {}) - removed = [n for n in expanded_stale if n in servers] - for name in removed: - del servers[name] - if removed: - claude_user.write_text( - _json.dumps(config, indent=2), encoding="utf-8" + config = _json.loads(claude_proj.read_text(encoding="utf-8")) + servers = config.get("mcpServers", {}) + removed = [n for n in expanded_stale if n in servers] + for name in removed: + del servers[name] + if removed: + claude_proj.write_text( + _json.dumps(config, indent=2), encoding="utf-8" + ) + for name in removed: + if logger: + logger.progress( + f"Removed stale MCP server '{name}' from .mcp.json" + ) + else: + _rich_info( + f"+ Removed stale MCP server '{name}' from .mcp.json" + ) + except Exception: + _log.debug( + "Failed to clean stale MCP servers from .mcp.json", + exc_info=True, ) + + if clean_claude_user: + claude_user = Path.home() / ".claude.json" + if claude_user.exists(): + try: + import json as _json + + config = _json.loads(claude_user.read_text(encoding="utf-8")) + servers = config.get("mcpServers", {}) + removed = [n for n in expanded_stale if n in servers] for name in removed: - if logger: - logger.progress( - f"Removed stale MCP server '{name}' from ~/.claude.json" - ) - else: - _rich_info( - f"+ Removed stale MCP server '{name}' from ~/.claude.json" - ) - except Exception: - _log.debug( - "Failed to clean stale MCP servers from ~/.claude.json", - exc_info=True, - ) + del servers[name] + if removed: + claude_user.write_text( + _json.dumps(config, indent=2), encoding="utf-8" + ) + for name in removed: + if logger: + logger.progress( + f"Removed stale MCP server '{name}' from ~/.claude.json" + ) + else: + _rich_info( + f"+ Removed stale MCP server '{name}' from ~/.claude.json" + ) + except Exception: + _log.debug( + "Failed to clean stale MCP servers from ~/.claude.json", + exc_info=True, + ) # Clean opencode.json (only if .opencode/ directory exists) if "opencode" in target_runtimes: From 6209a9ee9a7ea96ab2dc6ec178b150f2cfd7d72f Mon Sep 17 00:00:00 2001 From: Daniele Martinoli Date: Thu, 9 Apr 2026 22:58:24 +0200 Subject: [PATCH 03/10] fix: correct typo in docstring for ClaudeClientAdapter Signed-off-by: Daniele Martinoli --- src/apm_cli/adapters/client/claude.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/apm_cli/adapters/client/claude.py b/src/apm_cli/adapters/client/claude.py index 0ea7ac977..76e57d830 100644 --- a/src/apm_cli/adapters/client/claude.py +++ b/src/apm_cli/adapters/client/claude.py @@ -21,7 +21,7 @@ class ClaudeClientAdapter(CopilotClientAdapter): """MCP configuration for Claude Code (``mcpServers`` schema). Registry formatting reuses :class:`CopilotClientAdapter`, then entries are - normalized for Claude Code’s on-disk shape (stdio servers omit Copilot-only + normalized for Claude Code's on-disk shape (stdio servers omit Copilot-only keys like ``type: "local"``, default ``tools``, and empty ``id``). """ From 6a3f8a70c402171464a4e175f04a2dba7fb5a551 Mon Sep 17 00:00:00 2001 From: Daniele Martinoli Date: Fri, 10 Apr 2026 08:57:16 +0200 Subject: [PATCH 04/10] fix test error Signed-off-by: Daniele Martinoli --- src/apm_cli/commands/install.py | 2 ++ src/apm_cli/commands/uninstall/cli.py | 1 + src/apm_cli/commands/uninstall/engine.py | 7 ++++++- src/apm_cli/integration/mcp_integrator.py | 11 ++++++++--- tests/unit/test_claude_mcp.py | 9 +++++++-- 5 files changed, 24 insertions(+), 6 deletions(-) diff --git a/src/apm_cli/commands/install.py b/src/apm_cli/commands/install.py index 23548ef6b..358f84755 100644 --- a/src/apm_cli/commands/install.py +++ b/src/apm_cli/commands/install.py @@ -876,6 +876,7 @@ def install(ctx, packages, runtime, exclude, only, update, dry_run, force, verbo runtime, exclude, workspace_root=get_deploy_root(scope), + install_scope=scope, ) # Persist the new MCP server set and configs in the lockfile @@ -888,6 +889,7 @@ def install(ctx, packages, runtime, exclude, only, update, dry_run, force, verbo runtime, exclude, workspace_root=get_deploy_root(scope), + install_scope=scope, ) MCPIntegrator.update_lockfile(builtins.set(), mcp_configs={}) logger.verbose_detail("No MCP dependencies found in apm.yml") diff --git a/src/apm_cli/commands/uninstall/cli.py b/src/apm_cli/commands/uninstall/cli.py index dba206f70..d290b1233 100644 --- a/src/apm_cli/commands/uninstall/cli.py +++ b/src/apm_cli/commands/uninstall/cli.py @@ -201,6 +201,7 @@ def uninstall(ctx, packages, dry_run, verbose, global_): _pre_uninstall_mcp_servers, modules_dir=get_modules_dir(scope), workspace_root=deploy_root, + install_scope=scope, ) except Exception: logger.warning("MCP cleanup during uninstall failed") diff --git a/src/apm_cli/commands/uninstall/engine.py b/src/apm_cli/commands/uninstall/engine.py index 7e8b9666e..6c17ee3e7 100644 --- a/src/apm_cli/commands/uninstall/engine.py +++ b/src/apm_cli/commands/uninstall/engine.py @@ -361,6 +361,7 @@ def _cleanup_stale_mcp( old_mcp_servers, modules_dir=None, workspace_root=None, + install_scope=None, ): """Remove MCP servers that are no longer needed after uninstall.""" if not old_mcp_servers: @@ -376,5 +377,9 @@ def _cleanup_stale_mcp( stale_servers = old_mcp_servers - new_mcp_servers wr = workspace_root if workspace_root is not None else Path.cwd() if stale_servers: - MCPIntegrator.remove_stale(stale_servers, workspace_root=wr) + MCPIntegrator.remove_stale( + stale_servers, + workspace_root=wr, + install_scope=install_scope, + ) MCPIntegrator.update_lockfile(new_mcp_servers, lockfile_path) diff --git a/src/apm_cli/integration/mcp_integrator.py b/src/apm_cli/integration/mcp_integrator.py index af1fbcb36..2c79c4512 100644 --- a/src/apm_cli/integration/mcp_integrator.py +++ b/src/apm_cli/integration/mcp_integrator.py @@ -453,6 +453,7 @@ def remove_stale( exclude: str = None, logger=None, workspace_root: Optional[Path] = None, + install_scope=None, ) -> None: """Remove MCP server entries that are no longer required by any dependency. @@ -591,10 +592,14 @@ def remove_stale( exc_info=True, ) - # Clean Claude Code project .mcp.json (mcpServers) + # Clean Claude Code project .mcp.json (mcpServers) and/or user ~/.claude.json if "claude" in target_runtimes: - clean_claude_user = install_scope is InstallScope.USER - clean_claude_project = install_scope is not InstallScope.USER + if install_scope is InstallScope.USER: + clean_claude_user, clean_claude_project = True, False + elif install_scope is InstallScope.PROJECT: + clean_claude_user, clean_claude_project = False, True + else: + clean_claude_user = clean_claude_project = True if clean_claude_project: claude_proj = wr / ".mcp.json" diff --git a/tests/unit/test_claude_mcp.py b/tests/unit/test_claude_mcp.py index f01a5c281..7583365a1 100644 --- a/tests/unit/test_claude_mcp.py +++ b/tests/unit/test_claude_mcp.py @@ -205,7 +205,10 @@ def test_remove_stale_claude_project_mcp_json(self): encoding="utf-8", ) MCPIntegrator.remove_stale( - {"stale"}, runtime="claude", workspace_root=self.root + {"stale"}, + runtime="claude", + workspace_root=self.root, + install_scope=InstallScope.PROJECT, ) data = json.loads(mcp.read_text(encoding="utf-8")) self.assertIn("keep", data["mcpServers"]) @@ -228,7 +231,9 @@ def test_remove_stale_claude_user_claude_json(self): ), encoding="utf-8", ) - MCPIntegrator.remove_stale({"stale"}, runtime="claude") + MCPIntegrator.remove_stale( + {"stale"}, runtime="claude", install_scope=InstallScope.USER + ) data = json.loads(cfg.read_text(encoding="utf-8")) self.assertIn("keep", data["mcpServers"]) self.assertNotIn("stale", data["mcpServers"]) From b0371b52756d4c57ae3147d6aaab0b0eb17fb52b Mon Sep 17 00:00:00 2001 From: Daniele Martinoli Date: Fri, 17 Apr 2026 09:27:09 +0200 Subject: [PATCH 05/10] fix(mcp): restrict USER-scope MCP to home-scoped runtimes - 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 #655 review (scope leakage on global install). Made-with: Cursor --- src/apm_cli/integration/mcp_integrator.py | 68 +++++++++++++++++++++-- 1 file changed, 62 insertions(+), 6 deletions(-) diff --git a/src/apm_cli/integration/mcp_integrator.py b/src/apm_cli/integration/mcp_integrator.py index 2c79c4512..61ab4623a 100644 --- a/src/apm_cli/integration/mcp_integrator.py +++ b/src/apm_cli/integration/mcp_integrator.py @@ -31,6 +31,12 @@ _log = logging.getLogger(__name__) +# MCP configs for these runtimes are resolved from the process working directory, +# not from ``workspace_root``. ``InstallScope.USER`` (``apm install -g``) must not +# target them — see ``install()`` and ``remove_stale()``. +WORKSPACE_SCOPED_MCP_RUNTIMES = frozenset({"vscode", "cursor", "opencode"}) +HOME_SCOPED_MCP_RUNTIMES = frozenset({"copilot", "codex", "claude"}) + def _is_vscode_available() -> bool: """Return True when VS Code can be targeted for MCP configuration. @@ -469,7 +475,17 @@ def remove_stale( 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", "claude"} + if install_scope is InstallScope.USER and not runtime: + all_runtimes = set(HOME_SCOPED_MCP_RUNTIMES) + else: + all_runtimes = { + "vscode", + "copilot", + "codex", + "cursor", + "opencode", + "claude", + } if runtime: target_runtimes = {runtime} else: @@ -981,6 +997,14 @@ def install( wr = workspace_root if workspace_root is not None else Path.cwd() + if install_scope is InstallScope.USER and runtime in WORKSPACE_SCOPED_MCP_RUNTIMES: + raise RuntimeError( + "Global MCP install (--global) does not support workspace-based " + f"runtimes ({', '.join(sorted(WORKSPACE_SCOPED_MCP_RUNTIMES))}). " + "Run `apm install` from your project (without --global) to configure " + "those clients." + ) + # Runtime detection and multi-runtime installation if runtime: # Single runtime mode @@ -1071,6 +1095,13 @@ def install( except (ValueError, ImportError): pass + if install_scope is InstallScope.USER: + installed_runtimes = [ + r + for r in installed_runtimes + if r not in WORKSPACE_SCOPED_MCP_RUNTIMES + ] + # Step 2: Get runtimes referenced in apm.yml scripts script_runtimes = MCPIntegrator._detect_runtimes( apm_config.get("scripts", {}) if apm_config else {} @@ -1173,13 +1204,38 @@ def install( ) return 0 - # Fall back to VS Code only if no runtimes are installed at all + # Fall back to VS Code only if no runtimes are installed at all (project scope). if not target_runtimes and not installed_runtimes: - target_runtimes = ["vscode"] - if logger: - logger.progress("No runtimes installed, using VS Code as fallback") + if install_scope is InstallScope.USER: + if logger: + logger.warning( + "No home-scoped MCP runtimes (Copilot CLI, Codex, " + "Claude Code) found for global install." + ) + logger.progress( + "Install one with: apm runtime setup copilot" + ) + else: + _rich_warning( + "No home-scoped MCP runtimes (Copilot CLI, Codex, " + "Claude Code) found for global install." + ) + _rich_info( + "Install one with: apm runtime setup copilot" + ) else: - _rich_info("No runtimes installed, using VS Code as fallback") + target_runtimes = ["vscode"] + if logger: + logger.progress( + "No runtimes installed, using VS Code as fallback" + ) + else: + _rich_info( + "No runtimes installed, using VS Code as fallback" + ) + + if install_scope is InstallScope.USER and not target_runtimes: + return 0 # Use the new registry operations module for better server detection configured_count = 0 From e1f6040702226f91879b215cd3c770191828467e Mon Sep 17 00:00:00 2001 From: Daniele Martinoli Date: Fri, 17 Apr 2026 09:27:33 +0200 Subject: [PATCH 06/10] fix(mcp): gate Claude project stale cleanup on .claude/ Match install opt-in: only edit project .mcp.json when .claude/ exists. PR #655 Made-with: Cursor --- src/apm_cli/integration/mcp_integrator.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/apm_cli/integration/mcp_integrator.py b/src/apm_cli/integration/mcp_integrator.py index 61ab4623a..c3a3196c0 100644 --- a/src/apm_cli/integration/mcp_integrator.py +++ b/src/apm_cli/integration/mcp_integrator.py @@ -617,7 +617,7 @@ def remove_stale( else: clean_claude_user = clean_claude_project = True - if clean_claude_project: + if clean_claude_project and (wr / ".claude").is_dir(): claude_proj = wr / ".mcp.json" if claude_proj.exists(): try: From 39a61c97522bf727a18689ea080a4c9f42869e04 Mon Sep 17 00:00:00 2001 From: Daniele Martinoli Date: Fri, 17 Apr 2026 09:27:49 +0200 Subject: [PATCH 07/10] style(mcp): use STATUS_SYMBOLS for stale MCP cleanup messages Use _rich_info(..., symbol="check") instead of hardcoded '+' prefix. PR #655 Made-with: Cursor --- src/apm_cli/integration/mcp_integrator.py | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/src/apm_cli/integration/mcp_integrator.py b/src/apm_cli/integration/mcp_integrator.py index c3a3196c0..aa991c5e6 100644 --- a/src/apm_cli/integration/mcp_integrator.py +++ b/src/apm_cli/integration/mcp_integrator.py @@ -524,7 +524,8 @@ def remove_stale( ) else: _rich_info( - f"+ Removed stale MCP server '{name}' from .vscode/mcp.json" + f"Removed stale MCP server '{name}' from .vscode/mcp.json", + symbol="check", ) except Exception: _log.debug( @@ -550,7 +551,8 @@ def remove_stale( ) for name in removed: _rich_info( - f"+ Removed stale MCP server '{name}' from Copilot CLI config" + f"Removed stale MCP server '{name}' from Copilot CLI config", + symbol="check", ) except Exception: _log.debug( @@ -574,7 +576,8 @@ def remove_stale( codex_cfg.write_text(_toml.dumps(config), encoding="utf-8") for name in removed: _rich_info( - f"+ Removed stale MCP server '{name}' from Codex CLI config" + f"Removed stale MCP server '{name}' from Codex CLI config", + symbol="check", ) except Exception: _log.debug( @@ -600,7 +603,8 @@ def remove_stale( ) for name in removed: _rich_info( - f"+ Removed stale MCP server '{name}' from .cursor/mcp.json" + f"Removed stale MCP server '{name}' from .cursor/mcp.json", + symbol="check", ) except Exception: _log.debug( @@ -639,7 +643,8 @@ def remove_stale( ) else: _rich_info( - f"+ Removed stale MCP server '{name}' from .mcp.json" + f"Removed stale MCP server '{name}' from .mcp.json", + symbol="check", ) except Exception: _log.debug( @@ -669,7 +674,8 @@ def remove_stale( ) else: _rich_info( - f"+ Removed stale MCP server '{name}' from ~/.claude.json" + f"Removed stale MCP server '{name}' from ~/.claude.json", + symbol="check", ) except Exception: _log.debug( @@ -700,7 +706,8 @@ def remove_stale( ) else: _rich_info( - f"+ Removed stale MCP server '{name}' from opencode.json" + f"Removed stale MCP server '{name}' from opencode.json", + symbol="check", ) except Exception: _log.debug( From 76f3edf598ad2bb9cccc1d5ea19d1296482edca6 Mon Sep 17 00:00:00 2001 From: Daniele Martinoli Date: Fri, 17 Apr 2026 09:28:13 +0200 Subject: [PATCH 08/10] docs: changelog entry for Claude Code MCP install target PR #655 / #643 Made-with: Cursor --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 942671f8a..4deab99b9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Add Claude Code as MCP install target with project (`.mcp.json`) and user (`~/.claude.json`) scope support (#643) - `apm install` now automatically discovers and deploys local `.apm/` primitives (skills, instructions, agents, prompts, hooks, commands) to target directories, with local content taking priority over dependencies on collision (#626, #644) - Add `temp-dir` configuration key (`apm config set temp-dir PATH`) to override the system temporary directory, resolving `[WinError 5] Access is denied` in corporate Windows environments (#629) From d5214afc5dcc223162c22dbc214ed733f1fc3970 Mon Sep 17 00:00:00 2001 From: Daniele Martinoli Date: Fri, 17 Apr 2026 09:28:14 +0200 Subject: [PATCH 09/10] docs(mcp): clarify workspace_root vs cwd for MCP adapters Document that repo-local paths use process cwd and USER-scope installs filter workspace runtimes upstream. PR #655 Made-with: Cursor --- src/apm_cli/core/safe_installer.py | 7 +++++-- src/apm_cli/integration/mcp_integrator.py | 5 +++++ src/apm_cli/registry/operations.py | 11 ++++++++--- 3 files changed, 18 insertions(+), 5 deletions(-) diff --git a/src/apm_cli/core/safe_installer.py b/src/apm_cli/core/safe_installer.py index 624e26d9b..080c36424 100644 --- a/src/apm_cli/core/safe_installer.py +++ b/src/apm_cli/core/safe_installer.py @@ -65,10 +65,13 @@ def __init__(self, runtime: str, logger=None, workspace_root: Optional[Path] = N Args: runtime: Target runtime (copilot, codex, vscode, claude, ...). logger: Optional CommandLogger for structured output. - workspace_root: Reserved for API compatibility; repo-local adapters - use the process working directory (same as Cursor/VS Code paths). + workspace_root: Reserved for forward-compatible API; MCP client + adapters still resolve repo-local paths from the process working + directory. ``InstallScope.USER`` installs exclude CWD-based + runtimes so ``apm install -g`` does not write arbitrary project dirs. install_scope: ``InstallScope`` for user vs project MCP paths (Claude). """ + # Adapters use cwd for VS Code/Cursor/OpenCode paths; not workspace_root. _ = workspace_root self.runtime = runtime self.adapter = ClientFactory.create_client(runtime) diff --git a/src/apm_cli/integration/mcp_integrator.py b/src/apm_cli/integration/mcp_integrator.py index aa991c5e6..9f81e1574 100644 --- a/src/apm_cli/integration/mcp_integrator.py +++ b/src/apm_cli/integration/mcp_integrator.py @@ -417,6 +417,11 @@ def _check_self_defined_servers_needing_installation( Self-defined servers have no registry UUID, so installation checks use the runtime config keys directly. Runtime config reads are cached per runtime to avoid repeating the same client setup for every dependency. + + *workspace_root* is reserved for API compatibility; adapters read + repo-local MCP paths from the process working directory. USER-scope + installs pass a filtered *target_runtimes* list so CWD-based clients + are not queried. """ _ = workspace_root try: diff --git a/src/apm_cli/registry/operations.py b/src/apm_cli/registry/operations.py index 45231f610..cc1d9e1a4 100644 --- a/src/apm_cli/registry/operations.py +++ b/src/apm_cli/registry/operations.py @@ -39,7 +39,9 @@ def check_servers_needing_installation( Args: target_runtimes: List of target runtimes to check server_references: List of MCP server references (names or IDs) - + workspace_root: Reserved for API compatibility; adapters use cwd. + install_scope: Optional; forwarded to clients for scope-aware paths. + Returns: List of server references that need installation in at least one runtime """ @@ -91,10 +93,11 @@ def _get_installed_server_ids( install_scope=None, ) -> Set[str]: """Get all installed server IDs across target runtimes. - + Args: target_runtimes: List of runtimes to check - + workspace_root: Reserved for API compatibility; adapters use cwd. + Returns: Set of server IDs that are currently installed """ @@ -106,6 +109,8 @@ def _get_installed_server_ids( except ImportError: return installed_ids + # Client adapters read MCP config relative to cwd; workspace_root is API + # compatibility. USER-scope MCP install filters CWD-based runtimes upstream. _ = workspace_root for runtime in target_runtimes: From ef2761186096c1e91957756dca1f56243804cbc0 Mon Sep 17 00:00:00 2001 From: Daniele Martinoli Date: Fri, 17 Apr 2026 09:29:32 +0200 Subject: [PATCH 10/10] test(mcp): USER-scope stale/install and Claude cleanup - 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 #655 Made-with: Cursor --- tests/unit/test_claude_mcp.py | 49 ++++++++++++++++++++++++++++++ tests/unit/test_install_command.py | 3 +- 2 files changed, 51 insertions(+), 1 deletion(-) diff --git a/tests/unit/test_claude_mcp.py b/tests/unit/test_claude_mcp.py index 7583365a1..580b434f2 100644 --- a/tests/unit/test_claude_mcp.py +++ b/tests/unit/test_claude_mcp.py @@ -197,6 +197,7 @@ def test_remove_stale_claude_project_mcp_json(self): """remove_stale drops named servers from project .mcp.json at workspace_root.""" from apm_cli.integration.mcp_integrator import MCPIntegrator + (self.root / ".claude").mkdir() mcp = self.root / ".mcp.json" mcp.write_text( json.dumps( @@ -214,6 +215,39 @@ def test_remove_stale_claude_project_mcp_json(self): self.assertIn("keep", data["mcpServers"]) self.assertNotIn("stale", data["mcpServers"]) + def test_remove_stale_claude_project_skips_without_claude_dir(self): + """Project cleanup does not touch .mcp.json when .claude/ is absent (opt-in).""" + from apm_cli.integration.mcp_integrator import MCPIntegrator + + mcp = self.root / ".mcp.json" + raw = json.dumps( + {"mcpServers": {"keep": {"command": "k"}, "stale": {"command": "s"}}} + ) + mcp.write_text(raw, encoding="utf-8") + MCPIntegrator.remove_stale( + {"stale"}, + runtime="claude", + workspace_root=self.root, + install_scope=InstallScope.PROJECT, + ) + self.assertEqual(mcp.read_text(encoding="utf-8"), raw) + + def test_remove_stale_user_scope_skips_vscode_mcp_json(self): + """USER-scope stale cleanup does not modify .vscode/mcp.json (CWD-based).""" + from apm_cli.integration.mcp_integrator import MCPIntegrator + + vscode_dir = self.root / ".vscode" + vscode_dir.mkdir(parents=True) + mcp_path = vscode_dir / "mcp.json" + raw = json.dumps({"servers": {"stale": {"command": "x"}}}) + mcp_path.write_text(raw, encoding="utf-8") + MCPIntegrator.remove_stale( + {"stale"}, + workspace_root=self.root, + install_scope=InstallScope.USER, + ) + self.assertEqual(mcp_path.read_text(encoding="utf-8"), raw) + def test_remove_stale_claude_user_claude_json(self): """remove_stale drops named servers from ~/.claude.json mcpServers.""" from apm_cli.integration.mcp_integrator import MCPIntegrator @@ -239,5 +273,20 @@ def test_remove_stale_claude_user_claude_json(self): self.assertNotIn("stale", data["mcpServers"]) +class TestMCPIntegratorUserScopeInstall(unittest.TestCase): + """USER-scope install rejects workspace-based --runtime.""" + + def test_install_global_rejects_vscode_runtime(self): + from apm_cli.integration.mcp_integrator import MCPIntegrator + + with self.assertRaises(RuntimeError) as ctx: + MCPIntegrator.install( + ["ghcr.io/example/server"], + runtime="vscode", + install_scope=InstallScope.USER, + ) + self.assertIn("Global MCP install", str(ctx.exception)) + + if __name__ == "__main__": unittest.main() diff --git a/tests/unit/test_install_command.py b/tests/unit/test_install_command.py index e9bbe47a6..a94a2f370 100644 --- a/tests/unit/test_install_command.py +++ b/tests/unit/test_install_command.py @@ -796,7 +796,8 @@ def test_global_without_packages_and_no_manifest_errors(self): patch.dict(os.environ, {"COLUMNS": "200"}): result = self.runner.invoke(cli, ["install", "--global"]) assert result.exit_code == 1 - assert "apm.yml" in result.output + # Rich may wrap long paths; normalize newlines for substring checks. + assert "apm.yml" in result.output.replace("\n", "") finally: os.chdir(self.original_dir)