Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 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
56 changes: 37 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,29 @@ 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 +93,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 +248,8 @@ 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
110 changes: 98 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,42 @@ 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", {})
Loading
Loading