Skip to content
Merged
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 @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Fixed

- Gemini CLI: `apm install -g --mcp NAME` now correctly writes to `~/.gemini/settings.json` (user scope) and `apm install` from outside the target project writes to `<project_root>/.gemini/settings.json` instead of `cwd`. Previously `--global` had no effect on Gemini and project-scope writes silently landed in the wrong directory. The matching opt-in gate and cleanup paths in `MCPIntegrator` are aligned in the same change. (#1299)
- `apm install --target claude` now preserves self-defined stdio MCP `env` values from `apm.yml` and writes non-string values such as `PORT: 3000` and `DEBUG: false` as MCP-compatible strings. (#1222)
- Non-skill integrators (agent, instruction, prompt, command, hook script-copy) silently adopt byte-identical pre-existing files so a degraded `deployed_files=[]` lockfile no longer permanently blocks installs gated by `required-packages-deployed`. (#1313)
- `apm audit` drift check now returns skip-with-info (`passed=True`) when the install cache is cold, instead of failing the audit; bare `apm audit` surfaces the skip reason on stderr so CI pipelines that have not yet run `apm install` are not incorrectly red-marked. (#1289)
Expand Down
23 changes: 14 additions & 9 deletions docs/src/content/docs/consumer/install-mcp-servers.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,18 +82,23 @@ writes a runtime-specific MCP config file. The schemas differ; the
| Claude Code | `.mcp.json` (project) or `~/.claude.json` (`-g`) | both | JSON `mcpServers` |
| Cursor | `.cursor/mcp.json` | project (only if `.cursor/` exists) | JSON `mcpServers` |
| Codex CLI | `~/.codex/config.toml` | global | TOML `[mcp_servers.*]` |
| Gemini CLI | `.gemini/settings.json` | project (only if `.gemini/` exists) | JSON `mcpServers` |
| Gemini CLI | `.gemini/settings.json` (project, only if `.gemini/` exists) or `~/.gemini/settings.json` (`-g`) | both | JSON `mcpServers` |
| OpenCode | `opencode.json` | project (only if `.opencode/` exists) | JSON `mcp` |
| Windsurf | `~/.codeium/windsurf/mcp_config.json` | global | JSON `mcpServers` |

Cursor, Gemini, and OpenCode are opt-in by directory: APM only writes
their config when the corresponding `.cursor/`, `.gemini/`, or
`.opencode/` directory already exists in the project. This avoids
creating runtime artifacts for tools you do not use.

`apm install -g --mcp NAME` is restricted to the two harnesses with
true global MCP support: Copilot CLI and Codex CLI. The other
runtimes are project-scoped.
Cursor, Gemini, and OpenCode are opt-in by directory in project scope:
APM only writes their config when the corresponding `.cursor/`,
`.gemini/`, or `.opencode/` directory already exists in the project.
This avoids creating runtime artifacts for tools you do not use.
Gemini's user-scope path (`~/.gemini/settings.json`, selected with
`-g`) is unconditional and creates `~/.gemini/` if needed.

`apm install -g --mcp NAME` routes the write to each runtime's
user-scope MCP config when that runtime supports user scope -- for
example Copilot CLI writes to `~/.copilot/mcp-config.json`, Codex
CLI to `~/.codex/config.toml`, and Gemini CLI to
`~/.gemini/settings.json`. Workspace-only runtimes (VS Code, Cursor,
OpenCode) are skipped at user scope.

## stdio vs HTTP servers

Expand Down
61 changes: 42 additions & 19 deletions src/apm_cli/adapters/client/gemini.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
"""Gemini CLI implementation of MCP client adapter.

Gemini CLI uses ``.gemini/settings.json`` at the project root with an
``mcpServers`` key. Unlike Copilot, Gemini infers transport from which
key is present (``command`` for stdio, ``url`` for SSE, ``httpUrl`` for
streamable HTTP) and does not use ``type``, ``tools``, or ``id`` fields.
Gemini CLI reads ``.gemini/settings.json`` with an ``mcpServers`` key.
Unlike Copilot, Gemini infers transport from which key is present
(``command`` for stdio, ``url`` for SSE, ``httpUrl`` for streamable
HTTP) and does not use ``type``, ``tools``, or ``id`` fields.

.. code-block:: json

Expand All @@ -17,15 +17,17 @@
}
}

APM only writes to ``.gemini/settings.json`` when the ``.gemini/``
directory already exists -- Gemini CLI support is opt-in.
Scope resolution follows the shared adapter contract: project scope
writes to ``<project_root>/.gemini/settings.json`` and is opt-in --
the directory must already exist or the write is skipped silently.
User scope writes to ``~/.gemini/settings.json`` unconditionally and
creates the directory if needed.

Ref: https://geminicli.com/docs/reference/configuration/
"""

import json
import logging
import os
from pathlib import Path

from ...core.docker_args import DockerArgsProcessor
Expand All @@ -41,6 +43,11 @@ class GeminiClientAdapter(CopilotClientAdapter):
Inherits Copilot's helper methods for package selection, env-var
resolution, and argument processing but fully reimplements
``_format_server_config`` to emit Gemini-valid JSON.

Scope routing is governed by ``user_scope``/``project_root`` inherited
from :class:`MCPClientAdapter`: project scope reads/writes
``<project_root>/.gemini/settings.json`` (opt-in -- the directory must
already exist), and user scope reads/writes ``~/.gemini/settings.json``.
"""

supports_user_scope: bool = True
Expand All @@ -53,21 +60,31 @@ class GeminiClientAdapter(CopilotClientAdapter):
# revisit in a follow-up.
_supports_runtime_env_substitution: bool = False

def _get_gemini_dir(self) -> Path:
"""Return the ``.gemini`` directory for the active scope."""
if self.user_scope:
return Path.home() / ".gemini"
return self.project_root / ".gemini"

def get_config_path(self):
"""Return the path to ``.gemini/settings.json`` in the repository root."""
return str(Path(os.getcwd()) / ".gemini" / "settings.json")
"""Return the path to ``settings.json`` for the active scope."""
return str(self._get_gemini_dir() / "settings.json")

def update_config(self, config_updates):
"""Merge *config_updates* into the ``mcpServers`` section of settings.json.

The ``.gemini/`` directory must already exist; if it does not, this
method returns silently (opt-in behaviour).
Project scope is opt-in: if ``<project_root>/.gemini/`` does not
exist, this method returns silently. User scope always writes,
creating ``~/.gemini/`` if needed.

Preserves all other top-level keys in settings.json (theme, tools,
hooks, etc.).
"""
gemini_dir = Path(os.getcwd()) / ".gemini"
if not gemini_dir.is_dir():
gemini_dir = self._get_gemini_dir()
if not self.user_scope and not gemini_dir.is_dir():
logger.debug(
"Skipping Gemini project-scope write -- %s does not exist (opt-in)", gemini_dir
)
return

config_path = Path(self.get_config_path())
Expand All @@ -78,19 +95,22 @@ def update_config(self, config_updates):
for name, entry in config_updates.items():
current_config["mcpServers"][name] = entry

if not config_path.parent.is_dir():
logger.debug("Creating %s for Gemini CLI user configuration", config_path.parent)
config_path.parent.mkdir(parents=True, exist_ok=True)
with open(config_path, "w", encoding="utf-8") as f:
json.dump(current_config, f, indent=2)

def get_current_config(self):
"""Read the current ``.gemini/settings.json`` contents."""
config_path = self.get_config_path()
if not os.path.exists(config_path):
"""Read the current ``settings.json`` contents for the active scope."""
config_path = Path(self.get_config_path())
if not config_path.exists():
return {}
try:
with open(config_path, encoding="utf-8") as f:
return json.load(f)
except (OSError, json.JSONDecodeError):
except (OSError, json.JSONDecodeError) as exc:
logger.warning("Could not read %s: %s", config_path, exc)
return {}

def _format_server_config(self, server_info, env_overrides=None, runtime_vars=None):
Expand Down Expand Up @@ -230,8 +250,11 @@ def configure_mcp_server(
_rich_error("server_url cannot be empty", symbol="error")
return False

gemini_dir = Path(os.getcwd()) / ".gemini"
if not gemini_dir.is_dir():
if not self.user_scope and not self._get_gemini_dir().is_dir():
logger.debug(
"Gemini opt-in gate: %s absent, skipping configure_mcp_server",
self._get_gemini_dir(),
)
return True

try:
Expand Down
2 changes: 1 addition & 1 deletion src/apm_cli/integration/mcp_integrator.py
Original file line number Diff line number Diff line change
Expand Up @@ -663,7 +663,7 @@ def remove_stale(

# Clean .gemini/settings.json (only if .gemini/ directory exists)
if "gemini" in target_runtimes:
gemini_cfg = Path.cwd() / ".gemini" / "settings.json"
gemini_cfg = project_root_path / ".gemini" / "settings.json"
if gemini_cfg.exists():
try:
import json as _json
Expand Down
6 changes: 3 additions & 3 deletions src/apm_cli/integration/mcp_integrator_install.py
Original file line number Diff line number Diff line change
Expand Up @@ -172,7 +172,7 @@ def run_mcp_install( # noqa: PLR0915
installed_runtimes.append(runtime_name)
elif runtime_name == "gemini":
# Gemini CLI is opt-in: only target when .gemini/ exists
if (Path.cwd() / ".gemini").is_dir():
if (project_root_path / ".gemini").is_dir():
ClientFactory.create_client(runtime_name)
installed_runtimes.append(runtime_name)
elif runtime_name == "windsurf":
Expand Down Expand Up @@ -210,7 +210,7 @@ def run_mcp_install( # noqa: PLR0915
if (project_root_path / ".opencode").is_dir():
installed_runtimes.append("opencode")
# Gemini CLI is directory-presence based
if (Path.cwd() / ".gemini").is_dir():
if (project_root_path / ".gemini").is_dir():
installed_runtimes.append("gemini")
# Windsurf is directory-presence based
if (project_root_path / ".windsurf").is_dir():
Expand Down Expand Up @@ -317,7 +317,7 @@ def run_mcp_install( # noqa: PLR0915
logger.warning(msg)
if not target_runtimes:
logger.warning(
"No runtimes support user-scope MCP installation (supported: copilot, codex)"
"No runtimes support user-scope MCP installation (supported: copilot, codex, gemini)"
)
return 0

Expand Down
112 changes: 100 additions & 12 deletions tests/integration/test_gemini_integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -161,9 +161,7 @@ def setup_method(self):
def teardown_method(self):
shutil.rmtree(self.tmp, ignore_errors=True)

def test_adds_server_preserving_existing_keys(self, monkeypatch):
monkeypatch.chdir(self.root)

def test_adds_server_preserving_existing_keys(self):
settings = self.gemini_dir / "settings.json"
settings.write_text(
json.dumps(
Expand All @@ -175,7 +173,7 @@ def test_adds_server_preserving_existing_keys(self, monkeypatch):
)
)

adapter = GeminiClientAdapter.__new__(GeminiClientAdapter)
adapter = GeminiClientAdapter(project_root=self.root)
adapter.update_config(
{
"my-server": {
Expand All @@ -192,20 +190,71 @@ def test_adds_server_preserving_existing_keys(self, monkeypatch):
assert result["theme"] == "dark"
assert result["tools"] == {"enabled": True}

def test_creates_mcp_servers_key_if_missing(self, monkeypatch):
monkeypatch.chdir(self.root)

def test_creates_mcp_servers_key_if_missing(self):
settings = self.gemini_dir / "settings.json"
settings.write_text(json.dumps({"theme": "light"}))

adapter = GeminiClientAdapter.__new__(GeminiClientAdapter)
adapter = GeminiClientAdapter(project_root=self.root)
adapter.update_config({"srv": {"command": "echo"}})

result = json.loads(settings.read_text())
assert "mcpServers" in result
assert "srv" in result["mcpServers"]
assert result["theme"] == "light"

def test_install_via_mcp_integrator_uses_project_root_not_cwd(self, monkeypatch):
"""Regression for #1299: when ``MCPIntegrator.install`` is called
with ``project_root`` distinct from the current process cwd, the
Gemini opt-in detection gate must check ``project_root/.gemini/``
(not ``cwd/.gemini/``), and the MCP write must land at
``project_root/.gemini/settings.json``.

Pre-fix: the detection gate at ``mcp_integrator.py`` read
``Path.cwd() / .gemini`` for Gemini only (every other opt-in
runtime used ``project_root_path``), so when cwd lacked
``.gemini/`` Gemini was excluded from ``installed_runtimes`` and
no write occurred even though ``project_root/.gemini/`` existed.
"""
from apm_cli.integration.mcp_integrator import MCPIntegrator
from apm_cli.models.dependency.mcp import MCPDependency

# cwd is a fresh tmp dir with NO .gemini/ -- mirrors the issue's
# "checkout that is not the target project" premise.
other_cwd = tempfile.mkdtemp(prefix="apm-not-project-")
try:
monkeypatch.chdir(other_cwd)

dep = MCPDependency.from_dict(
{
"name": "regression-1299-srv",
"registry": False,
"transport": "stdio",
"command": "echo",
"args": ["regression-1299"],
}
)

# Intentionally do NOT pass runtime= so the auto-detection
# block at mcp_integrator.py exercises the opt-in gate that
# was the bug site (Path.cwd() vs project_root_path).
MCPIntegrator.install(
[dep],
project_root=self.root,
)

settings = self.gemini_dir / "settings.json"
assert settings.exists(), (
"MCPIntegrator.install must write Gemini config at project_root/.gemini/, "
"not silently drop it because the cwd-based opt-in gate misclassified "
"Gemini as unavailable."
)
data = json.loads(settings.read_text())
assert "regression-1299-srv" in data.get("mcpServers", {}), (
"Self-defined MCP server should be written to project_root/.gemini/settings.json"
)
finally:
shutil.rmtree(other_cwd, ignore_errors=True)


@pytest.mark.integration
class TestGeminiOptInBehavior:
Expand Down Expand Up @@ -248,10 +297,8 @@ def test_instructions_not_deployed_without_gemini_dir(self):
assert result.files_integrated == 0
assert not (self.root / ".gemini").exists()

def test_mcp_update_noop_without_gemini_dir(self, monkeypatch):
monkeypatch.chdir(self.root)

adapter = GeminiClientAdapter.__new__(GeminiClientAdapter)
def test_mcp_update_noop_without_gemini_dir(self):
adapter = GeminiClientAdapter(project_root=self.root)
adapter.update_config({"srv": {"command": "echo"}})

assert not (self.root / ".gemini").exists()
Expand Down Expand Up @@ -468,3 +515,44 @@ def test_uninstall_transitive_dep_cleans_skill(self):

assert stats["files_removed"] == 1
assert not skill_dir.exists()


@pytest.mark.integration
class TestRemoveStaleGeminiUsesProjectRoot:
"""Verify remove_stale reads .gemini/settings.json from project_root, not cwd."""

def setup_method(self):
self.tmp = tempfile.mkdtemp()
self.root = Path(self.tmp)
self.gemini_dir = self.root / ".gemini"
self.gemini_dir.mkdir()
self.settings_json = self.gemini_dir / "settings.json"

def teardown_method(self):
shutil.rmtree(self.tmp, ignore_errors=True)

def test_remove_stale_gemini_uses_project_root_not_cwd(self, monkeypatch):
"""remove_stale must resolve .gemini/settings.json via project_root,
not Path.cwd(), so stale cleanup works when cwd != project_root."""
from apm_cli.integration.mcp_integrator import MCPIntegrator

self.settings_json.write_text(
json.dumps(
{"mcpServers": {"stale-srv": {"command": "echo"}, "keep-srv": {"command": "cat"}}}
)
)

other_cwd = tempfile.mkdtemp(prefix="apm-not-project-")
try:
monkeypatch.chdir(other_cwd)
MCPIntegrator.remove_stale(
{"stale-srv"},
runtime="gemini",
project_root=self.root,
)
finally:
shutil.rmtree(other_cwd, ignore_errors=True)

data = json.loads(self.settings_json.read_text())
assert "stale-srv" not in data.get("mcpServers", {})
assert "keep-srv" in data.get("mcpServers", {})
5 changes: 2 additions & 3 deletions tests/integration/test_registry.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""Integration tests for MCP registry client."""

import contextlib
import gc
import json
import os
Expand Down Expand Up @@ -62,10 +63,8 @@ def teardown_method(self):
# Leave the temp tree before unlinking it. Otherwise cwd can still
# reference the directory inode and os.getcwd() raises FileNotFoundError
# on POSIX — breaking later tests on the same xdist worker.
try:
with contextlib.suppress(FileNotFoundError, OSError):
os.chdir(tempfile.gettempdir())
except (FileNotFoundError, OSError):
pass

# First, try the standard cleanup
try:
Expand Down
Loading
Loading