-
Notifications
You must be signed in to change notification settings - Fork 168
feat: support transitive MCP dependency propagation #123
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. Weβll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
96d2a83
5560597
f334098
e9c64e9
7f80e89
f746291
6dd7bab
0d5e529
05861c6
46e0ce0
940c9e4
c4969c5
b45122f
720d040
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -701,6 +701,15 @@ def install(ctx, packages, runtime, exclude, only, update, dry_run, verbose): | |
| elif should_install_apm and not apm_deps: | ||
| _rich_info("No APM dependencies found in apm.yml") | ||
|
|
||
| # Collect transitive MCP dependencies from resolved APM packages | ||
| apm_modules_path = Path.cwd() / "apm_modules" | ||
| if should_install_mcp and apm_modules_path.exists(): | ||
| lock_path = Path.cwd() / "apm.lock" | ||
| transitive_mcp = _collect_transitive_mcp_deps(apm_modules_path, lock_path) | ||
| if transitive_mcp: | ||
| _rich_info(f"Collected {len(transitive_mcp)} transitive MCP dependency(ies)") | ||
| mcp_deps = _deduplicate_mcp_deps(mcp_deps + transitive_mcp) | ||
|
|
||
| # Continue with MCP installation (existing logic) | ||
| mcp_count = 0 | ||
| if should_install_mcp and mcp_deps: | ||
|
|
@@ -2212,24 +2221,107 @@ def matches_filter(dep): | |
| raise RuntimeError(f"Failed to resolve APM dependencies: {e}") | ||
|
|
||
|
|
||
| def _collect_transitive_mcp_deps(apm_modules_dir: Path, lock_path: Path = None) -> list: | ||
| """Collect MCP dependencies from resolved APM packages listed in apm.lock. | ||
|
|
||
| Only scans apm.yml files for packages present in apm.lock to avoid | ||
| picking up stale/orphaned packages from previous installs. | ||
| Falls back to scanning all apm.yml files if no lock file is available. | ||
|
|
||
| Args: | ||
| apm_modules_dir: Path to the apm_modules directory. | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fix return type annotation.
|
||
| lock_path: Path to the apm.lock file (optional). | ||
|
|
||
| Returns: | ||
| List of MCP dependency entries (str or dict). | ||
| """ | ||
| if not apm_modules_dir.exists(): | ||
| return [] | ||
|
|
||
| from apm_cli.models.apm_package import APMPackage | ||
|
|
||
| # Build set of expected apm.yml paths from apm.lock | ||
| locked_paths = None | ||
| if lock_path and lock_path.exists(): | ||
| lockfile = LockFile.read(lock_path) | ||
| if lockfile is not None: | ||
| locked_paths = builtins.set() | ||
| for dep in lockfile.get_all_dependencies(): | ||
| if dep.repo_url: | ||
| 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" | ||
| locked_paths.add(yml.resolve()) | ||
|
|
||
| # Prefer iterating lock-derived paths directly (existing files only). | ||
| # Fall back to full scan only when lock parsing is unavailable. | ||
| if locked_paths is not None: | ||
| apm_yml_paths = [path for path in sorted(locked_paths) if path.exists()] | ||
| else: | ||
| apm_yml_paths = apm_modules_dir.rglob("apm.yml") | ||
|
|
||
| collected = [] | ||
| for apm_yml_path in apm_yml_paths: | ||
| try: | ||
| pkg = APMPackage.from_apm_yml(apm_yml_path) | ||
| mcp = pkg.get_mcp_dependencies() | ||
| if mcp: | ||
| collected.extend(mcp) | ||
|
sergio-sisternes-epam marked this conversation as resolved.
|
||
| except Exception: | ||
| # Skip packages that fail to parse | ||
| continue | ||
| return collected | ||
|
|
||
|
|
||
| def _deduplicate_mcp_deps(deps: list) -> list: | ||
| """Deduplicate a mixed list of MCP deps (strings and dicts). | ||
|
|
||
| Strings are deduped by value; dicts are deduped by their 'name' key. | ||
|
|
||
|
sergio-sisternes-epam marked this conversation as resolved.
|
||
| Args: | ||
| deps: Mixed list of str and dict MCP entries. | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Strip dict logic from The def _deduplicate_mcp_deps(deps: List[str]) -> List[str]:
"""Deduplicate MCP dependency strings preserving insertion order."""
seen: builtins.set = builtins.set()
result = []
for dep in deps:
if dep not in seen:
seen.add(dep)
result.append(dep)
return resultAlso: |
||
|
|
||
| Returns: | ||
| Deduplicated list preserving insertion order. | ||
| """ | ||
| seen_strings: builtins.set = builtins.set() | ||
| seen_names: builtins.set = builtins.set() | ||
| result = [] | ||
| for dep in deps: | ||
| if isinstance(dep, str): | ||
| if dep not in seen_strings: | ||
| seen_strings.add(dep) | ||
| result.append(dep) | ||
| elif isinstance(dep, dict): | ||
| name = dep.get("name", "") | ||
| if name and name not in seen_names: | ||
| seen_names.add(name) | ||
| result.append(dep) | ||
| elif not name and dep not in result: | ||
| result.append(dep) | ||
| return result | ||
|
|
||
|
|
||
| def _install_mcp_dependencies( | ||
| mcp_deps: List[str], runtime: str = None, exclude: str = None, verbose: bool = False | ||
| mcp_deps: list, runtime: str = None, exclude: str = None, verbose: bool = False | ||
| ): | ||
| """Install MCP dependencies using existing logic. | ||
|
|
||
| Args: | ||
| mcp_deps: List of MCP dependency names | ||
| mcp_deps: List of MCP dependency entries (registry strings) | ||
| runtime: Target specific runtime only | ||
| exclude: Exclude specific runtime from installation | ||
| verbose: Show detailed installation information | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Remove Three things here:
|
||
|
|
||
| Returns: | ||
| int: Number of MCP servers configured | ||
| int: Number of MCP servers newly configured | ||
| """ | ||
| if not mcp_deps: | ||
| _rich_warning("No MCP dependencies found in apm.yml") | ||
| return 0 | ||
|
|
||
| # Filter to registry strings only (inline dicts are preserved during | ||
| # parsing for data fidelity but not installed β see #132). | ||
| registry_deps = [d for d in mcp_deps if isinstance(d, str)] | ||
|
|
||
|
Comment on lines
+2321
to
+2324
|
||
| console = _get_console() | ||
|
|
||
| # Start MCP section with clean header | ||
|
|
@@ -2356,99 +2448,116 @@ def _install_mcp_dependencies( | |
|
|
||
| # Use the new registry operations module for better server detection | ||
| configured_count = 0 | ||
| try: | ||
| from apm_cli.registry.operations import MCPServerOperations | ||
|
|
||
| operations = MCPServerOperations() | ||
| # --- Registry-based deps (strings) --- | ||
| if registry_deps: | ||
| try: | ||
| from apm_cli.registry.operations import MCPServerOperations | ||
|
|
||
| # Early validation: check if all servers exist in registry (fail-fast like npm) | ||
| if verbose: | ||
| _rich_info(f"Validating {len(mcp_deps)} servers...") | ||
| valid_servers, invalid_servers = operations.validate_servers_exist(mcp_deps) | ||
| operations = MCPServerOperations() | ||
|
|
||
| if invalid_servers: | ||
| _rich_error( | ||
| f"Server(s) not found in registry: {', '.join(invalid_servers)}" | ||
| ) | ||
| _rich_info("Run 'apm mcp search <query>' to find available servers") | ||
| raise RuntimeError( | ||
| f"Cannot install {len(invalid_servers)} missing server(s)" | ||
| ) | ||
| # Early validation: check if all servers exist in registry (fail-fast like npm) | ||
| if verbose: | ||
| _rich_info(f"Validating {len(registry_deps)} registry servers...") | ||
| valid_servers, invalid_servers = operations.validate_servers_exist(registry_deps) | ||
|
|
||
| if not valid_servers: | ||
| if console: | ||
| console.print("ββ [green]No servers to install[/green]") | ||
| else: | ||
| _rich_success("No servers to install") | ||
| return 0 | ||
| if invalid_servers: | ||
| _rich_error( | ||
| f"Server(s) not found in registry: {', '.join(invalid_servers)}" | ||
| ) | ||
| _rich_info("Run 'apm mcp search <query>' to find available servers") | ||
| raise RuntimeError( | ||
| f"Cannot install {len(invalid_servers)} missing server(s)" | ||
| ) | ||
|
|
||
| # Check which valid servers actually need installation | ||
| servers_to_install = operations.check_servers_needing_installation( | ||
| target_runtimes, valid_servers | ||
| ) | ||
| if valid_servers: | ||
| # Check which valid servers actually need installation | ||
| servers_to_install = operations.check_servers_needing_installation( | ||
| target_runtimes, valid_servers | ||
| ) | ||
| already_configured_servers = [ | ||
| dep for dep in valid_servers if dep not in servers_to_install | ||
| ] | ||
|
|
||
| if not servers_to_install: | ||
| # All already configured | ||
| if console: | ||
| for dep in mcp_deps: | ||
| console.print( | ||
| f"β [green]β[/green] {dep} [dim](already configured)[/dim]" | ||
| ) | ||
| console.print("ββ [green]All servers up to date[/green]") | ||
| else: | ||
| _rich_success("All MCP servers already configured") | ||
| return len(mcp_deps) | ||
| else: | ||
| # Batch fetch server info once to avoid duplicate registry calls | ||
| if verbose: | ||
| _rich_info(f"Installing {len(servers_to_install)} servers...") | ||
| server_info_cache = operations.batch_fetch_server_info(servers_to_install) | ||
| if not servers_to_install: | ||
| # All already configured | ||
| if console: | ||
| for dep in already_configured_servers: | ||
| console.print( | ||
| f"β [green]β[/green] {dep} [dim](already configured)[/dim]" | ||
| ) | ||
| else: | ||
| _rich_success("All registry MCP servers already configured") | ||
| else: | ||
| # Surface already-configured servers distinctly from newly configured ones | ||
| if already_configured_servers: | ||
| if console: | ||
| for dep in already_configured_servers: | ||
| console.print( | ||
| f"β [green]β[/green] {dep} [dim](already configured)[/dim]" | ||
| ) | ||
| elif verbose: | ||
| _rich_info( | ||
| "Already configured registry MCP servers: " | ||
| f"{', '.join(already_configured_servers)}" | ||
| ) | ||
|
|
||
| # Collect both environment and runtime variables using cached server info | ||
| shared_env_vars = operations.collect_environment_variables( | ||
| servers_to_install, server_info_cache | ||
| ) | ||
| shared_runtime_vars = operations.collect_runtime_variables( | ||
| servers_to_install, server_info_cache | ||
| ) | ||
| # Batch fetch server info once to avoid duplicate registry calls | ||
| if verbose: | ||
| _rich_info(f"Installing {len(servers_to_install)} servers...") | ||
| server_info_cache = operations.batch_fetch_server_info(servers_to_install) | ||
|
|
||
| # Install for each target runtime using cached server info and shared variables | ||
| for dep in servers_to_install: | ||
| if console: | ||
| console.print(f"β [cyan]β¬οΈ[/cyan] {dep}") | ||
| console.print( | ||
| f"β ββ Configuring for {', '.join([rt.title() for rt in target_runtimes])}..." | ||
| # Collect both environment and runtime variables using cached server info | ||
| shared_env_vars = operations.collect_environment_variables( | ||
| servers_to_install, server_info_cache | ||
| ) | ||
|
|
||
| for rt in target_runtimes: | ||
| if verbose: | ||
| _rich_info(f"Configuring {rt}...") | ||
| _install_for_runtime( | ||
| rt, | ||
| [dep], # Install one at a time for better output | ||
| shared_env_vars, | ||
| server_info_cache, | ||
| shared_runtime_vars, | ||
| shared_runtime_vars = operations.collect_runtime_variables( | ||
| servers_to_install, server_info_cache | ||
| ) | ||
|
|
||
| if console: | ||
| console.print( | ||
| f"β [green]β[/green] {dep} β {', '.join([rt.title() for rt in target_runtimes])}" | ||
| ) | ||
| configured_count += 1 | ||
| # Install for each target runtime using cached server info and shared variables | ||
| for dep in servers_to_install: | ||
| if console: | ||
| console.print(f"β [cyan]β¬οΈ[/cyan] {dep}") | ||
| console.print( | ||
| f"β ββ Configuring for {', '.join([rt.title() for rt in target_runtimes])}..." | ||
| ) | ||
|
|
||
| for rt in target_runtimes: | ||
| if verbose: | ||
| _rich_info(f"Configuring {rt}...") | ||
| _install_for_runtime( | ||
| rt, | ||
| [dep], # Install one at a time for better output | ||
| shared_env_vars, | ||
| server_info_cache, | ||
| shared_runtime_vars, | ||
| ) | ||
|
|
||
| if console: | ||
| console.print( | ||
| f"β [green]β[/green] {dep} β {', '.join([rt.title() for rt in target_runtimes])}" | ||
| ) | ||
| configured_count += 1 | ||
|
|
||
| except ImportError: | ||
| _rich_warning("Registry operations not available") | ||
| _rich_error("Cannot validate MCP servers without registry operations") | ||
| raise RuntimeError("Registry operations module required for MCP installation") | ||
|
|
||
| # Close the panel | ||
| if console: | ||
| if configured_count > 0: | ||
| console.print( | ||
| f"ββ [green]Configured {configured_count} server{'s' if configured_count != 1 else ''}[/green]" | ||
| ) | ||
| else: | ||
| console.print("ββ [green]All servers up to date[/green]") | ||
|
|
||
| return configured_count | ||
|
|
||
| # Close the panel | ||
| if console: | ||
| console.print( | ||
| f"ββ [green]Configured {configured_count} server{'s' if configured_count != 1 else ''}[/green]" | ||
| ) | ||
|
|
||
| return configured_count | ||
|
|
||
| except ImportError: | ||
| _rich_warning("Registry operations not available") | ||
| _rich_error("Cannot validate MCP servers without registry operations") | ||
| raise RuntimeError("Registry operations module required for MCP installation") | ||
|
|
||
|
|
||
| def _show_install_summary( | ||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -709,7 +709,7 @@ class APMPackage: | |||||||||||||||
| license: Optional[str] = None | ||||||||||||||||
| source: Optional[str] = None # Source location (for dependencies) | ||||||||||||||||
| resolved_commit: Optional[str] = None # Resolved commit SHA (for dependencies) | ||||||||||||||||
| dependencies: Optional[Dict[str, List[Union[DependencyReference, str]]]] = None # Mixed types for APM/MCP | ||||||||||||||||
| dependencies: Optional[Dict[str, List[Union[DependencyReference, str, dict]]]] = None # Mixed types for APM/MCP/inline | ||||||||||||||||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Revert type widening β descoped to #132. This adds Revert to |
||||||||||||||||
| scripts: Optional[Dict[str, str]] = None | ||||||||||||||||
| package_path: Optional[Path] = None # Local path to package | ||||||||||||||||
| target: Optional[str] = None # Target agent: vscode, claude, or all (applies to compile and install) | ||||||||||||||||
|
|
@@ -764,8 +764,8 @@ def from_apm_yml(cls, apm_yml_path: Path) -> "APMPackage": | |||||||||||||||
| raise ValueError(f"Invalid APM dependency '{dep_str}': {e}") | ||||||||||||||||
| dependencies[dep_type] = parsed_deps | ||||||||||||||||
| else: | ||||||||||||||||
| # Other dependencies (like MCP) remain as strings | ||||||||||||||||
| dependencies[dep_type] = [str(dep) for dep in dep_list if isinstance(dep, str)] | ||||||||||||||||
| # Other dependencies (like MCP): keep strings and dicts | ||||||||||||||||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Revert parser change β descoped to #132.
Revert to the original |
||||||||||||||||
| dependencies[dep_type] = [dep for dep in dep_list if isinstance(dep, (str, dict))] | ||||||||||||||||
|
|
||||||||||||||||
| # Parse package content type | ||||||||||||||||
| pkg_type = None | ||||||||||||||||
|
|
@@ -798,13 +798,12 @@ def get_apm_dependencies(self) -> List[DependencyReference]: | |||||||||||||||
| # Filter to only return DependencyReference objects | ||||||||||||||||
| return [dep for dep in self.dependencies['apm'] if isinstance(dep, DependencyReference)] | ||||||||||||||||
|
|
||||||||||||||||
| def get_mcp_dependencies(self) -> List[str]: | ||||||||||||||||
| """Get list of MCP dependencies (as strings for compatibility).""" | ||||||||||||||||
| def get_mcp_dependencies(self) -> List[Union[str, dict]]: | ||||||||||||||||
| """Get list of MCP dependencies (strings for registry, dicts for inline configs).""" | ||||||||||||||||
| if not self.dependencies or 'mcp' not in self.dependencies: | ||||||||||||||||
| return [] | ||||||||||||||||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Revert return type to The return type widening to
Suggested change
|
||||||||||||||||
| # MCP deps are stored as strings, not DependencyReference objects | ||||||||||||||||
| return [str(dep) if isinstance(dep, DependencyReference) else dep | ||||||||||||||||
| for dep in self.dependencies.get('mcp', [])] | ||||||||||||||||
| return [dep for dep in (self.dependencies.get('mcp') or []) | ||||||||||||||||
| if isinstance(dep, (str, dict))] | ||||||||||||||||
|
|
||||||||||||||||
| def has_apm_dependencies(self) -> bool: | ||||||||||||||||
| """Check if this package has APM dependencies.""" | ||||||||||||||||
|
|
||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Remove
# Registry referencequalifier β misleading after descoping.The
# Registry referenceinline comment implies the existence of a non-registry format. Since inline dict deps were descoped to #132, this qualifier is misleading. Remove the comment or use something neutral like# MCP server.