Skip to content
Open
Show file tree
Hide file tree
Changes from 4 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 @@ -10,7 +10,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 @@ -327,7 +327,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 @@ -740,13 +740,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 @@ -854,21 +847,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