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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Fixed

- `apm install --global` now installs MCP servers to global-capable runtimes (Copilot CLI, Codex CLI) instead of blanket-skipping all MCP installation at user scope (#638)
- Propagate headers and environment variables through OpenCode MCP adapter with defensive copies to prevent mutation (#622)

### Changed

- `apm marketplace browse/search/add/update` now route through the registry proxy when `PROXY_REGISTRY_URL` is set; `PROXY_REGISTRY_ONLY=1` blocks direct GitHub API calls (#506)
Expand Down
2 changes: 1 addition & 1 deletion docs/src/content/docs/guides/dependencies.md
Original file line number Diff line number Diff line change
Expand Up @@ -329,7 +329,7 @@ falling back to Copilot. Security scanning runs for global installs.
| Cross-project coding standards | User |

:::note
MCP servers are not supported at user scope. Each target uses a different MCP configuration format; user-scope MCP support is planned for a future release.
MCP servers at user scope (`--global`) are installed only to runtimes with global config paths (Copilot CLI, Codex CLI). Workspace-only runtimes (VS Code, Cursor, OpenCode) are skipped.
Comment thread
sergio-sisternes-epam marked this conversation as resolved.
:::

:::caution
Expand Down
6 changes: 6 additions & 0 deletions src/apm_cli/adapters/client/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,12 @@
class MCPClientAdapter(ABC):
"""Base adapter for MCP clients."""

# Whether this adapter's config path is user/global-scoped (e.g.
# ``~/.copilot/``) rather than workspace-scoped (e.g. ``.vscode/``).
# Adapters that target a global path should override this to ``True``
# so that ``apm install --global`` can install MCP servers to them.
supports_user_scope: bool = False

@abstractmethod
def get_config_path(self):
"""Get the path to the MCP configuration file."""
Expand Down
4 changes: 3 additions & 1 deletion src/apm_cli/adapters/client/codex.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,9 @@ class CodexClientAdapter(MCPClientAdapter):
a global ~/.codex/config.toml file, following the TOML format for
MCP server configuration.
"""


supports_user_scope: bool = True

def __init__(self, registry_url=None):
"""Initialize the Codex CLI client adapter.

Expand Down
4 changes: 3 additions & 1 deletion src/apm_cli/adapters/client/copilot.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,9 @@ class CopilotClientAdapter(MCPClientAdapter):
a global ~/.copilot/mcp-config.json file, following the JSON format for
MCP server configuration.
"""


supports_user_scope: bool = True

def __init__(self, registry_url=None):
"""Initialize the Copilot CLI client adapter.

Expand Down
2 changes: 2 additions & 0 deletions src/apm_cli/adapters/client/cursor.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ class CursorClientAdapter(CopilotClientAdapter):
of global ``~/.copilot/mcp-config.json``.
"""

supports_user_scope: bool = False

# ------------------------------------------------------------------ #
# Config path
# ------------------------------------------------------------------ #
Expand Down
2 changes: 2 additions & 0 deletions src/apm_cli/adapters/client/opencode.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ class OpenCodeClientAdapter(CopilotClientAdapter):
and writes to ``opencode.json`` in the project root.
"""

supports_user_scope: bool = False

def get_config_path(self):
"""Return the path to ``opencode.json`` in the repository root."""
return str(Path(os.getcwd()) / "opencode.json")
Expand Down
12 changes: 3 additions & 9 deletions src/apm_cli/commands/install.py
Original file line number Diff line number Diff line change
Expand Up @@ -744,13 +744,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 @@ -860,21 +853,22 @@ def install(ctx, packages, runtime, exclude, only, update, dry_run, force, verbo
mcp_deps, runtime, exclude, verbose,
stored_mcp_configs=old_mcp_configs,
diagnostics=apm_diagnostics,
scope=scope,
)
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, 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, 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
2 changes: 1 addition & 1 deletion src/apm_cli/commands/uninstall/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -195,7 +195,7 @@ def uninstall(ctx, packages, dry_run, verbose, global_):
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))
modules_dir=get_modules_dir(scope), scope=scope)
except Exception:
logger.warning("MCP cleanup during uninstall failed")

Expand Down
4 changes: 2 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,7 @@ 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, scope=None):
"""Remove MCP servers that are no longer needed after uninstall."""
if not old_mcp_servers:
return
Expand All @@ -368,5 +368,5 @@ def _cleanup_stale_mcp(apm_package, lockfile, lockfile_path, old_mcp_servers, mo
new_mcp_servers = MCPIntegrator.get_server_names(all_remaining_mcp)
stale_servers = old_mcp_servers - new_mcp_servers
if stale_servers:
MCPIntegrator.remove_stale(stale_servers)
MCPIntegrator.remove_stale(stale_servers, scope=scope)
MCPIntegrator.update_lockfile(new_mcp_servers, lockfile_path)
64 changes: 64 additions & 0 deletions src/apm_cli/integration/mcp_integrator.py
Original file line number Diff line number Diff line change
Expand Up @@ -444,6 +444,7 @@ def remove_stale(
runtime: str = None,
exclude: str = None,
logger=None,
scope=None,
) -> None:
"""Remove MCP server entries that are no longer required by any dependency.

Expand All @@ -452,6 +453,10 @@ def remove_stale(
dependency references (e.g. ``"io.github.github/github-mcp-server"``).
For Copilot CLI and Codex, config keys are derived from the last path
segment, so we match against both the full reference and the short name.

Args:
scope: InstallScope (PROJECT or USER). When USER, only
global-capable runtimes are cleaned.
"""
if not stale_names:
return
Expand All @@ -465,6 +470,21 @@ def remove_stale(
if exclude:
target_runtimes.discard(exclude)

# Scope filtering: at USER scope, only clean global-capable runtimes.
from apm_cli.core.scope import InstallScope

if scope is InstallScope.USER:
from apm_cli.factory import ClientFactory as _CF

supported = builtins.set()
for rt in target_runtimes:
try:
if _CF.create_client(rt).supports_user_scope:
supported.add(rt)
except ValueError:
pass
target_runtimes = supported

# Build an expanded set that includes both the full reference and the
# last-segment short name so we match config keys in every runtime.
expanded_stale: builtins.set = builtins.set()
Expand Down Expand Up @@ -804,6 +824,7 @@ def install(
stored_mcp_configs: dict = None,
logger=None,
diagnostics=None,
scope=None,
) -> int:
"""Install MCP dependencies.

Expand All @@ -818,6 +839,9 @@ 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.
scope: InstallScope (PROJECT or USER). When USER, only
runtimes whose adapter declares ``supports_user_scope``
are targeted; workspace-only runtimes are skipped.

Returns:
Number of MCP servers newly configured or updated.
Expand Down Expand Up @@ -1056,6 +1080,46 @@ def install(
else:
_rich_info("No runtimes installed, using VS Code as fallback")

# Scope filtering: at USER scope, keep only global-capable runtimes.
# Applied after both explicit --runtime and auto-discovery paths.
from apm_cli.core.scope import InstallScope

if scope is InstallScope.USER:
from apm_cli.factory import ClientFactory as _CF

pre_filter = list(target_runtimes)
filtered_runtimes = []
for rt in target_runtimes:
try:
client = _CF.create_client(rt)
except ValueError:
continue
if client.supports_user_scope:
filtered_runtimes.append(rt)
target_runtimes = filtered_runtimes
skipped = set(pre_filter) - set(target_runtimes)
if skipped:
msg = (
f"Skipped workspace-only runtimes at user scope: "
f"{', '.join(sorted(skipped))}"
)
if logger:
logger.verbose_detail(msg)
else:
_rich_info(msg)
Comment thread
sergio-sisternes-epam marked this conversation as resolved.
if not target_runtimes:
if logger:
logger.warning(
"No runtimes support user-scope MCP installation "
"(supported: copilot, codex)"
)
else:
_rich_warning(
"No runtimes support user-scope MCP installation "
"(supported: copilot, codex)"
)
return 0

# Use the new registry operations module for better server detection
configured_count = 0

Expand Down
Loading
Loading