Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion docs/src/content/docs/integrations/ide-tool-integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <name>` or `--exclude <name>` to control which clients receive configuration.
**Runtime targeting**: APM detects which runtimes are installed and configures MCP servers for all of them. Use `--runtime <name>` or `--exclude <name>` 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).

Expand All @@ -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

Expand Down
209 changes: 209 additions & 0 deletions src/apm_cli/adapters/client/claude.py
Original file line number Diff line number Diff line change
@@ -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
46 changes: 34 additions & 12 deletions src/apm_cli/commands/install.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -851,24 +856,41 @@ 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,
)
Comment on lines 863 to 867
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.
new_mcp_servers = MCPIntegrator.get_server_names(mcp_deps)
new_mcp_configs = MCPIntegrator.get_server_configs(mcp_deps)

# 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),
install_scope=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),
install_scope=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:
Expand Down
11 changes: 9 additions & 2 deletions src/apm_cli/commands/uninstall/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -194,8 +194,15 @@ 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,
install_scope=scope,
)
except Exception:
logger.warning("MCP cleanup during uninstall failed")

Expand Down
17 changes: 15 additions & 2 deletions src/apm_cli/commands/uninstall/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -354,7 +354,15 @@ 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,
install_scope=None,
):
"""Remove MCP servers that are no longer needed after uninstall."""
if not old_mcp_servers:
return
Expand All @@ -367,6 +375,11 @@ 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,
install_scope=install_scope,
)
MCPIntegrator.update_lockfile(new_mcp_servers, lockfile_path)
9 changes: 8 additions & 1 deletion src/apm_cli/core/conflict_detector.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]:
Expand Down
Loading
Loading