Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
162 changes: 159 additions & 3 deletions src/apm_cli/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -754,6 +754,16 @@ def install(ctx, packages, runtime, exclude, only, update, dry_run, force, verbo
apm_count = 0
prompt_count = 0
agent_count = 0

# Capture old MCP servers from lockfile BEFORE _install_apm_dependencies
# regenerates it (which drops the mcp_servers field).
old_mcp_servers: builtins.set = builtins.set()
if should_install_mcp:
_lock_path = Path.cwd() / "apm.lock"
_existing_lock = LockFile.read(_lock_path)
if _existing_lock:
Comment thread
sergio-sisternes-epam marked this conversation as resolved.
Outdated
old_mcp_servers = builtins.set(_existing_lock.mcp_servers)

if should_install_apm and apm_deps:
if not APM_DEPS_AVAILABLE:
_rich_error("APM dependency system not available")
Expand All @@ -774,6 +784,12 @@ def install(ctx, packages, runtime, exclude, only, update, dry_run, force, verbo
elif should_install_apm and not apm_deps:
_rich_info("No APM dependencies found in apm.yml")

# When --update is used, package files on disk may have changed.
# Clear the parse cache so transitive MCP collection reads fresh data.
if update:
from apm_cli.models.apm_package import clear_apm_yml_cache
clear_apm_yml_cache()

# Collect transitive MCP dependencies from resolved APM packages
apm_modules_path = Path.cwd() / "apm_modules"
if should_install_mcp and apm_modules_path.exists():
Expand All @@ -785,9 +801,23 @@ def install(ctx, packages, runtime, exclude, only, update, dry_run, force, verbo

# Continue with MCP installation (existing logic)
mcp_count = 0
new_mcp_servers: builtins.set = builtins.set()
if should_install_mcp and mcp_deps:
mcp_count = _install_mcp_dependencies(mcp_deps, runtime, exclude, verbose)
new_mcp_servers = _get_mcp_dep_names(mcp_deps)

# Remove stale MCP servers that are no longer needed
stale_servers = old_mcp_servers - new_mcp_servers
if stale_servers:
_remove_stale_mcp_servers(stale_servers)

# Persist the new MCP server set in the lockfile
_update_lockfile_mcp_servers(new_mcp_servers)
elif should_install_mcp and not mcp_deps:
# No MCP deps at all — remove any old APM-managed servers
if old_mcp_servers:
_remove_stale_mcp_servers(old_mcp_servers)
_update_lockfile_mcp_servers(builtins.set())
_rich_warning("No MCP dependencies found in apm.yml")

# Show beautiful post-install summary
Expand Down Expand Up @@ -1476,6 +1506,19 @@ def _find_transitive_orphans(lockfile, removed_urls):
instructions_cleaned = result.get("files_removed", 0)

# Phase 2: Re-integrate from remaining installed packages in apm_modules/
# Detect target so we only re-create Claude artefacts when appropriate
from apm_cli.core.target_detection import (
detect_target,
should_integrate_claude,
)
config_target = apm_package.target
detected_target, _ = detect_target(
project_root=project_root,
explicit_target=None,
config_target=config_target,
)
integrate_claude = should_integrate_claude(detected_target)

prompt_integrator = PromptIntegrator()
agent_integrator = AgentIntegrator()
skill_integrator = SkillIntegrator()
Expand Down Expand Up @@ -1511,13 +1554,14 @@ def _find_transitive_orphans(lockfile, removed_urls):
prompt_integrator.integrate_package_prompts(pkg_info, project_root)
if agent_integrator.should_integrate(project_root):
agent_integrator.integrate_package_agents(pkg_info, project_root)
if Path(".claude").exists():
if integrate_claude:
agent_integrator.integrate_package_agents_claude(pkg_info, project_root)
skill_integrator.integrate_package_skill(pkg_info, project_root)
if command_integrator.should_integrate(project_root):
if integrate_claude:
command_integrator.integrate_package_commands(pkg_info, project_root)
hook_integrator_reint.integrate_package_hooks(pkg_info, project_root)
hook_integrator_reint.integrate_package_hooks_claude(pkg_info, project_root)
if integrate_claude:
hook_integrator_reint.integrate_package_hooks_claude(pkg_info, project_root)
instruction_integrator.integrate_package_instructions(pkg_info, project_root)
except Exception:
pass # Best effort re-integration
Expand All @@ -1539,6 +1583,29 @@ def _find_transitive_orphans(lockfile, removed_urls):
if instructions_cleaned > 0:
_rich_info(f"✓ Cleaned up {instructions_cleaned} instruction(s)")

# Clean up stale MCP servers after uninstall
try:
lock_path = Path.cwd() / "apm.lock"
existing_lock = LockFile.read(lock_path)
old_mcp_servers = builtins.set(existing_lock.mcp_servers) if existing_lock else builtins.set()
if old_mcp_servers:
# Recompute MCP deps from remaining packages
apm_modules_path = Path.cwd() / "apm_modules"
remaining_mcp = _collect_transitive_mcp_deps(apm_modules_path, lock_path, trust_private=True)
# Also include root-level MCP deps from apm.yml
Comment thread
sergio-sisternes-epam marked this conversation as resolved.
Outdated
try:
remaining_root_mcp = apm_package.get_mcp_dependencies()
except Exception:
remaining_root_mcp = []
all_remaining_mcp = _deduplicate_mcp_deps(remaining_root_mcp + remaining_mcp)
new_mcp_servers = _get_mcp_dep_names(all_remaining_mcp)
stale_servers = old_mcp_servers - new_mcp_servers
if stale_servers:
_remove_stale_mcp_servers(stale_servers)
_update_lockfile_mcp_servers(new_mcp_servers)
except Exception:
pass # best-effort MCP cleanup

# Final summary
summary_lines = []
summary_lines.append(
Expand Down Expand Up @@ -2662,6 +2729,95 @@ def _apply_mcp_overlay(server_info_cache: dict, dep) -> None:
)


def _get_mcp_dep_names(mcp_deps: list) -> builtins.set:
"""Extract unique server names from a list of MCP dependencies.

Args:
mcp_deps: List of MCP dependency entries (MCPDependency objects or strings).

Returns:
Set of MCP server names.
"""
names: builtins.set = builtins.set()
for dep in mcp_deps:
if hasattr(dep, "name"):
names.add(dep.name)
elif isinstance(dep, str):
names.add(dep)
return names


def _remove_stale_mcp_servers(stale_names: builtins.set) -> None:
"""Remove MCP server entries that are no longer required by any dependency.

Cleans up both .vscode/mcp.json and ~/.copilot/mcp-config.json.

Args:
stale_names: Set of MCP server names to remove.
"""
if not stale_names:
return

# Clean .vscode/mcp.json
vscode_mcp = Path.cwd() / ".vscode" / "mcp.json"
if vscode_mcp.exists():
try:
import json as _json

config = _json.loads(vscode_mcp.read_text(encoding="utf-8"))
servers = config.get("servers", {})
removed = [n for n in stale_names if n in servers]
for name in removed:
del servers[name]
if removed:
vscode_mcp.write_text(
_json.dumps(config, indent=2), encoding="utf-8"
)
for name in removed:
_rich_info(f"✓ Removed stale MCP server '{name}' from .vscode/mcp.json")
except Exception:
pass # best-effort cleanup

# Clean ~/.copilot/mcp-config.json
copilot_mcp = Path.home() / ".copilot" / "mcp-config.json"
if copilot_mcp.exists():
try:
Comment thread
sergio-sisternes-epam marked this conversation as resolved.
Outdated
import json as _json

config = _json.loads(copilot_mcp.read_text(encoding="utf-8"))
servers = config.get("mcpServers", {})
removed = [n for n in stale_names if n in servers]
for name in removed:
del servers[name]
if removed:
copilot_mcp.write_text(
_json.dumps(config, indent=2), encoding="utf-8"
)
for name in removed:
_rich_info(f"✓ Removed stale MCP server '{name}' from Copilot CLI config")
except Exception:
pass # best-effort cleanup
Comment thread
sergio-sisternes-epam marked this conversation as resolved.
Outdated


Comment thread
sergio-sisternes-epam marked this conversation as resolved.
def _update_lockfile_mcp_servers(mcp_server_names: builtins.set) -> None:
"""Update the lockfile with the current set of APM-managed MCP server names.

Args:
mcp_server_names: Set of MCP server names currently managed by APM.
"""
lock_path = Path.cwd() / "apm.lock"
if not lock_path.exists():
return
try:
lockfile = LockFile.read(lock_path)
if lockfile is None:
return
lockfile.mcp_servers = sorted(mcp_server_names)
lockfile.save(lock_path)
except Exception:
pass # best-effort


def _install_mcp_dependencies(
mcp_deps: list, runtime: str = None, exclude: str = None, verbose: bool = False
):
Expand Down
4 changes: 4 additions & 0 deletions src/apm_cli/deps/lockfile.py
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,7 @@ class LockFile:
)
apm_version: Optional[str] = None
dependencies: Dict[str, LockedDependency] = field(default_factory=dict)
mcp_servers: List[str] = field(default_factory=list)

def add_dependency(self, dep: LockedDependency) -> None:
"""Add a dependency to the lock file."""
Expand Down Expand Up @@ -146,6 +147,8 @@ def to_yaml(self) -> str:
if self.apm_version:
data["apm_version"] = self.apm_version
data["dependencies"] = [dep.to_dict() for dep in self.get_all_dependencies()]
if self.mcp_servers:
data["mcp_servers"] = sorted(self.mcp_servers)
Comment thread
sergio-sisternes-epam marked this conversation as resolved.
return yaml.dump(
data, default_flow_style=False, sort_keys=False, allow_unicode=True
)
Expand All @@ -165,6 +168,7 @@ def from_yaml(cls, yaml_str: str) -> "LockFile":
)
for dep_data in data.get("dependencies", []):
lock.add_dependency(LockedDependency.from_dict(dep_data))
lock.mcp_servers = list(data.get("mcp_servers", []))
Comment thread
sergio-sisternes-epam marked this conversation as resolved.
return lock

def write(self, path: Path) -> None:
Expand Down