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

### Added

- `feat(codex): project-scoped MCP config and user target support` adds repo-local `.codex/config.toml` support for project installs, keeps Codex MCP gated to active project targets, and enables Codex user-scope primitive deployment. (#803)
- New `enterprise/governance-guide.md` documentation page: flagship governance reference for CISO / VPE / Platform Tech Lead audiences, covering enforcement points, bypass contract, failure semantics, air-gapped operation, rollout playbook, and known gaps. Trims duplicated content in `governance.md`, `apm-policy.md`, and `integrations/github-rulesets.md`. Adds `templates/apm-policy-starter.yml`. (#851)
- `apm install` now supports Azure DevOps AAD bearer-token auth via `az account get-access-token`, with PAT-first fallback for orgs that disable PAT creation. Closes #852 (#856)
- New CI safety net: `merge-gate.yml` orchestrator turns dropped `pull_request` webhook deliveries into clear red checks instead of stuck `Expected -- Waiting for status to be reported`. Triggers on both `pull_request` and `pull_request_target` for redundancy. (#865) (PR follow-up to #856 CI flake)
Expand Down
11 changes: 7 additions & 4 deletions docs/src/content/docs/integrations/ide-tool-integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -472,11 +472,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 |
| Codex CLI (project) | `.codex/config.toml` | TOML `mcp_servers` section |
| Codex CLI (`--global`) | `~/.codex/config.toml` | TOML `mcp_servers` section |

**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. Codex MCP is project-scoped during project installs, so it is written to `.codex/config.toml` only when Codex is an active project target. Use `--runtime <name>` or `--exclude <name>` to control which clients receive configuration.

Comment thread
Nickolaus marked this conversation as resolved.
> **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).
> **VS Code detection**: APM considers VS Code available when either the `code` CLI command is on PATH **or** a `.vscode/` directory exists in the resolved project root (defaulting to the current working directory when no explicit project root is provided). 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).

> **Commit safety for `.codex/`**: Review `.codex/config.toml` before committing. If any MCP server config uses inline credentials instead of environment-variable references, add `.codex/` to `.gitignore`.

```bash
# Install MCP dependencies for all detected runtimes
Expand Down Expand Up @@ -599,4 +602,4 @@ The following IDE integrations are planned for future releases:
- **[CLI Reference](../../reference/cli-commands/)** -- Complete command documentation
- Review the [VSCode Copilot Customization Guide](https://code.visualstudio.com/docs/copilot/copilot-customization) for VSCode-specific features
- Check the [Spec-kit documentation](https://github.com/github/spec-kit) for SDD integration details
- Explore [MCP servers](https://modelcontextprotocol.io/servers) for tool integration options
- Explore [MCP servers](https://modelcontextprotocol.io/servers) for tool integration options
2 changes: 1 addition & 1 deletion docs/src/content/docs/reference/cli-commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -1664,7 +1664,7 @@ apm runtime setup llm
**Default Behavior:**
- Installs runtime binary from official sources
- Configures with GitHub Models (free) as APM default
- Creates configuration file at `~/.codex/config.toml` or similar
- Creates Codex runtime configuration (global `~/.codex/config.toml`; project MCP config is managed separately in `.codex/config.toml`)
- Provides clear logging about what's being configured

**Vanilla Behavior (`--vanilla` flag):**
Expand Down
38 changes: 37 additions & 1 deletion src/apm_cli/adapters/client/base.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
"""Base adapter interface for MCP clients."""

import os
import re
from pathlib import Path
from abc import ABC, abstractmethod

_INPUT_VAR_RE = re.compile(r"\$\{input:([^}]+)\}")
Expand All @@ -15,6 +17,30 @@ class MCPClientAdapter(ABC):
# so that ``apm install --global`` can install MCP servers to them.
supports_user_scope: bool = False

def __init__(
self,
project_root: Path | str | None = None,
user_scope: bool = False,
):
"""Initialize the adapter with optional scope-aware path context.

Args:
project_root: Project root used to resolve project-local config paths.
When not provided, adapters fall back to the current working
directory for project-scoped paths.
user_scope: Whether the adapter should resolve user-scope config
paths instead of project-local paths when supported.
"""
self._project_root = Path(project_root) if project_root is not None else None
self.user_scope = user_scope

@property
def project_root(self) -> Path:
"""Return the explicit project root or the current working directory."""
if self._project_root is not None:
return self._project_root
return Path(os.getcwd())

@abstractmethod
def get_config_path(self):
"""Get the path to the MCP configuration file."""
Expand Down Expand Up @@ -120,6 +146,16 @@ def _warn_input_variables(mapping, server_name, runtime_label):
seen.add(var_id)
print(
f"[!] Warning: ${{input:{var_id}}} in server "
f"'{server_name}' will not be resolved \u2014 "
f"'{server_name}' will not be resolved -- "
f"{runtime_label} does not support input variable prompts"
)

def normalize_project_arg(self, value):
"""Normalize workspace placeholders for project-local runtimes."""
if (
not self.user_scope
and isinstance(value, str)
and value in {"${workspaceFolder}", "${projectRoot}"}
):
return "."
return value
78 changes: 52 additions & 26 deletions src/apm_cli/adapters/client/codex.py
Comment thread
Nickolaus marked this conversation as resolved.
Comment thread
Nickolaus marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -1,88 +1,114 @@
"""OpenAI Codex CLI implementation of MCP client adapter.

This adapter implements the Codex CLI-specific handling of MCP server configuration,
targeting the global ~/.codex/config.toml file as specified in the MCP installation
architecture specification.
"""
"""OpenAI Codex CLI implementation of MCP client adapter."""

import logging
import os
import toml
from pathlib import Path
from .base import MCPClientAdapter
from ...registry.client import SimpleRegistryClient
from ...registry.integration import RegistryIntegration
from ...utils.console import _rich_warning


_log = logging.getLogger(__name__)


class CodexClientAdapter(MCPClientAdapter):
"""Codex CLI implementation of MCP client adapter.

This adapter handles Codex CLI-specific configuration for MCP servers using
a global ~/.codex/config.toml file, following the TOML format for
MCP server configuration.
a scope-resolved config.toml file, following the TOML format for MCP
server configuration.
"""

supports_user_scope: bool = True

def __init__(self, registry_url=None):
def __init__(
self,
registry_url=None,
project_root: Path | str | None = None,
user_scope: bool = False,
):
"""Initialize the Codex CLI client adapter.

Args:
registry_url (str, optional): URL of the MCP registry.
If not provided, uses the MCP_REGISTRY_URL environment variable
or falls back to the default GitHub registry.
project_root: Project root used to resolve project-local Codex
config paths.
user_scope: Whether the adapter should resolve user-scope Codex
config paths instead of project-local paths.
"""
super().__init__(project_root=project_root, user_scope=user_scope)
self.registry_client = SimpleRegistryClient(registry_url)
self.registry_integration = RegistryIntegration(registry_url)

def _get_codex_dir(self):
"""Return the root directory used for Codex config in the current scope."""
if self.user_scope:
return Path.home() / ".codex"
return self.project_root / ".codex"

def get_config_path(self):
"""Get the path to the Codex CLI MCP configuration file.

Returns:
str: Path to ~/.codex/config.toml
str: Path to the scope-resolved Codex config.toml
"""
codex_dir = Path.home() / ".codex"
return str(codex_dir / "config.toml")
return str(self._get_codex_dir() / "config.toml")

def update_config(self, config_updates):
"""Update the Codex CLI MCP configuration.

Args:
config_updates (dict): Configuration updates to apply.
"""
config_path = Path(self.get_config_path())
current_config = self.get_current_config()
if current_config is None:
return False

# Ensure mcp_servers section exists
if "mcp_servers" not in current_config:
current_config["mcp_servers"] = {}

# Apply updates to mcp_servers section
current_config["mcp_servers"].update(config_updates)

# Write back to file
config_path = Path(self.get_config_path())


# Ensure directory exists
config_path.parent.mkdir(parents=True, exist_ok=True)
with open(config_path, 'w') as f:

with open(config_path, 'w', encoding='utf-8') as f:
toml.dump(current_config, f)
_log.debug("Codex config written to %s", config_path)
return True

def get_current_config(self):
"""Get the current Codex CLI MCP configuration.

Returns:
dict: Current configuration, or empty dict if file doesn't exist.
dict | None: Current configuration, empty dict if file doesn't
exist, or None when an existing config cannot be parsed safely.
"""
config_path = self.get_config_path()

if not os.path.exists(config_path):
return {}

try:
with open(config_path, 'r') as f:
with open(config_path, 'r', encoding='utf-8') as f:
return toml.load(f)
except (toml.TomlDecodeError, IOError):
return {}
except toml.TomlDecodeError as exc:
_log.debug("Failed to parse Codex config at %s", config_path, exc_info=True)
_rich_warning(
f"Could not parse {config_path}: {exc} -- "
"skipping config write to avoid data loss",
symbol="warning",
)
return None
except IOError:
_log.debug("Failed to read Codex config at %s", config_path, exc_info=True)
return None

def configure_mcp_server(self, server_url, server_name=None, enabled=True, env_overrides=None, server_info_cache=None, runtime_vars=None):
"""Configure an MCP server in Codex CLI configuration.
Expand Down Expand Up @@ -148,7 +174,8 @@ def configure_mcp_server(self, server_url, server_name=None, enabled=True, env_o
server_config = self._format_server_config(server_info, env_overrides, runtime_vars)

# Update configuration using the chosen key
self.update_config({config_key: server_config})
if not self.update_config({config_key: server_config}):
return False

print(f"Successfully configured MCP server '{config_key}' for Codex CLI")
return True
Expand Down Expand Up @@ -180,7 +207,7 @@ def _format_server_config(self, server_info, env_overrides=None, runtime_vars=No
raw = server_info.get("_raw_stdio")
if raw:
config["command"] = raw["command"]
config["args"] = raw["args"]
config["args"] = [self.normalize_project_arg(arg) for arg in raw["args"]]
if raw.get("env"):
config["env"] = raw["env"]
self._warn_input_variables(raw["env"], server_info.get("name", ""), "Codex CLI")
Expand Down Expand Up @@ -555,4 +582,3 @@ def _select_best_package(self, packages):

# If no priority package found, return the first one
return packages[0] if packages else None

15 changes: 12 additions & 3 deletions src/apm_cli/adapters/client/copilot.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,17 +23,26 @@ 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):
def __init__(
self,
registry_url=None,
project_root: Path | str | None = None,
user_scope: bool = False,
):
"""Initialize the Copilot CLI client adapter.

Args:
registry_url (str, optional): URL of the MCP registry.
If not provided, uses the MCP_REGISTRY_URL environment variable
or falls back to the default GitHub registry.
project_root: Project root context passed through to the base
adapter for scope-aware operations.
user_scope: Whether the adapter should resolve user-scope config
paths instead of project-local paths when supported.
"""
super().__init__(project_root=project_root, user_scope=user_scope)
self.registry_client = SimpleRegistryClient(registry_url)
self.registry_integration = RegistryIntegration(registry_url)

Expand Down Expand Up @@ -714,4 +723,4 @@ def _is_github_server(self, server_name, url):
# If URL parsing fails, assume it's not a GitHub server
return False

return False
return False
4 changes: 2 additions & 2 deletions src/apm_cli/adapters/client/cursor.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ def get_config_path(self):
``.cursor/`` directory is *not* created automatically — APM only
writes here when the directory already exists.
"""
cursor_dir = Path(os.getcwd()) / ".cursor"
cursor_dir = self.project_root / ".cursor"
return str(cursor_dir / "mcp.json")

# ------------------------------------------------------------------ #
Expand Down Expand Up @@ -102,7 +102,7 @@ def configure_mcp_server(
return False

# Opt-in: skip silently when .cursor/ does not exist
cursor_dir = Path(os.getcwd()) / ".cursor"
cursor_dir = self.project_root / ".cursor"
if not cursor_dir.exists():
return True # nothing to do, not an error

Expand Down
6 changes: 3 additions & 3 deletions src/apm_cli/adapters/client/opencode.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ class OpenCodeClientAdapter(CopilotClientAdapter):

def get_config_path(self):
"""Return the path to ``opencode.json`` in the repository root."""
return str(Path(os.getcwd()) / "opencode.json")
return str(self.project_root / "opencode.json")

def update_config(self, config_updates, enabled=True):
"""Merge *config_updates* into the ``mcp`` section of ``opencode.json``.
Expand All @@ -55,7 +55,7 @@ def update_config(self, config_updates, enabled=True):
Translates Copilot-format entries (``command``/``args``/``env``) into
OpenCode format (``command`` array / ``environment``).
"""
opencode_dir = Path(os.getcwd()) / ".opencode"
opencode_dir = self.project_root / ".opencode"
if not opencode_dir.is_dir():
return
Comment thread
Nickolaus marked this conversation as resolved.

Expand Down Expand Up @@ -99,7 +99,7 @@ def configure_mcp_server(
print("Error: server_url cannot be empty")
return False

opencode_dir = Path(os.getcwd()) / ".opencode"
opencode_dir = self.project_root / ".opencode"
if not opencode_dir.is_dir():
return False

Expand Down
16 changes: 13 additions & 3 deletions src/apm_cli/adapters/client/vscode.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,14 +21,24 @@ class VSCodeClientAdapter(MCPClientAdapter):
in the VSCode documentation.
"""

def __init__(self, registry_url=None):
def __init__(
self,
registry_url=None,
project_root: Path | str | None = None,
user_scope: bool = False,
):
"""Initialize the VSCode client adapter.

Args:
registry_url (str, optional): URL of the MCP registry.
If not provided, uses the MCP_REGISTRY_URL environment variable
or falls back to the default demo registry.
project_root: Project root used to resolve the repository-local
`.vscode/mcp.json` path.
user_scope: Whether to resolve user-scope config paths instead of
project-local paths when supported.
"""
super().__init__(project_root=project_root, user_scope=user_scope)
self.registry_client = SimpleRegistryClient(registry_url)
self.registry_integration = RegistryIntegration(registry_url)

Expand All @@ -38,8 +48,8 @@ def get_config_path(self, logger=None):
Returns:
str: Path to the .vscode/mcp.json file.
"""
# Use the current working directory as the repository root
repo_root = Path(os.getcwd())
# Use the resolved project root, which may be explicitly provided
repo_root = self.project_root

# Path to .vscode/mcp.json in the repository
vscode_dir = repo_root / ".vscode"
Expand Down
Loading