From 92d8211f83f0bf152d9620e096f7c84fd2674c6d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=A8=B1=E5=85=83=E8=B1=AA?= <146086744+edenfunf@users.noreply.github.com> Date: Wed, 13 May 2026 17:18:05 +0800 Subject: [PATCH 1/9] fix(adapters): route Gemini config through project_root and user_scope (#1299) --- CHANGELOG.md | 1 + src/apm_cli/adapters/client/gemini.py | 49 ++++--- tests/integration/test_gemini_integration.py | 18 +-- tests/unit/test_gemini_mcp.py | 129 ++++++++++++++++--- 4 files changed, 149 insertions(+), 48 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d8f5f6cf..f4bcfbecd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 `/.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) diff --git a/src/apm_cli/adapters/client/gemini.py b/src/apm_cli/adapters/client/gemini.py index 78416a39c..f9010ec12 100644 --- a/src/apm_cli/adapters/client/gemini.py +++ b/src/apm_cli/adapters/client/gemini.py @@ -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 @@ -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 ``/.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 @@ -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 + ``/.gemini/settings.json`` (opt-in -- the directory must + already exist), and user scope reads/writes ``~/.gemini/settings.json``. """ supports_user_scope: bool = True @@ -53,21 +60,28 @@ 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 ``/.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(): return config_path = Path(self.get_config_path()) @@ -83,9 +97,9 @@ def update_config(self, config_updates): 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: @@ -230,8 +244,7 @@ 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(): return True try: diff --git a/tests/integration/test_gemini_integration.py b/tests/integration/test_gemini_integration.py index 3de6a0716..8016853f6 100644 --- a/tests/integration/test_gemini_integration.py +++ b/tests/integration/test_gemini_integration.py @@ -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( @@ -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": { @@ -192,13 +190,11 @@ 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()) @@ -248,10 +244,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() diff --git a/tests/unit/test_gemini_mcp.py b/tests/unit/test_gemini_mcp.py index cb2b914e6..72954b5fc 100644 --- a/tests/unit/test_gemini_mcp.py +++ b/tests/unit/test_gemini_mcp.py @@ -1,7 +1,6 @@ """Tests for the Gemini CLI MCP client adapter.""" import json -import os # noqa: F401 import shutil import tempfile import unittest @@ -21,23 +20,21 @@ def test_factory_creates_gemini_adapter(self): class TestGeminiClientAdapter(unittest.TestCase): - """Core config operations for GeminiClientAdapter.""" + """Core config operations for GeminiClientAdapter under project scope.""" def setUp(self): self.tmp = tempfile.TemporaryDirectory() - self.gemini_dir = Path(self.tmp.name) / ".gemini" + self.project_root = Path(self.tmp.name) + self.gemini_dir = self.project_root / ".gemini" self.gemini_dir.mkdir() self.settings_json = self.gemini_dir / "settings.json" - self._cwd_patcher = patch("os.getcwd", return_value=self.tmp.name) - self._cwd_patcher.start() - self.adapter = GeminiClientAdapter() + self.adapter = GeminiClientAdapter(project_root=self.project_root) def tearDown(self): - self._cwd_patcher.stop() self.tmp.cleanup() def test_config_path(self): - expected = str(Path(self.tmp.name) / ".gemini" / "settings.json") + expected = str(self.project_root / ".gemini" / "settings.json") self.assertEqual(self.adapter.get_config_path(), expected) def test_get_current_config_empty(self): @@ -89,16 +86,115 @@ def test_update_config_noop_when_no_gemini_dir(self): self.assertFalse(self.settings_json.exists()) +class TestGeminiProjectRootRouting(unittest.TestCase): + """Regression coverage for #1299: adapter must honour ``project_root`` and + never read or write through ``os.getcwd()``.""" + + def setUp(self): + self.project_tmp = tempfile.TemporaryDirectory() + self.cwd_tmp = tempfile.TemporaryDirectory() + self.project_root = Path(self.project_tmp.name) + self.cwd_root = Path(self.cwd_tmp.name) + (self.project_root / ".gemini").mkdir() + + def tearDown(self): + self.project_tmp.cleanup() + self.cwd_tmp.cleanup() + + def test_writes_to_project_root_when_cwd_lacks_gemini(self): + with patch("os.getcwd", return_value=str(self.cwd_root)): + adapter = GeminiClientAdapter(project_root=self.project_root) + adapter.update_config({"srv": {"command": "node"}}) + + project_settings = self.project_root / ".gemini" / "settings.json" + self.assertTrue(project_settings.exists()) + data = json.loads(project_settings.read_text()) + self.assertEqual(data["mcpServers"]["srv"]["command"], "node") + + def test_does_not_pollute_cwd_when_cwd_also_has_gemini(self): + (self.cwd_root / ".gemini").mkdir() + with patch("os.getcwd", return_value=str(self.cwd_root)): + adapter = GeminiClientAdapter(project_root=self.project_root) + adapter.update_config({"srv": {"command": "node"}}) + + self.assertTrue((self.project_root / ".gemini" / "settings.json").exists()) + self.assertFalse((self.cwd_root / ".gemini" / "settings.json").exists()) + + def test_falls_back_to_cwd_when_project_root_not_passed(self): + (self.cwd_root / ".gemini").mkdir() + with patch("os.getcwd", return_value=str(self.cwd_root)): + adapter = GeminiClientAdapter() + adapter.update_config({"srv": {"command": "node"}}) + + self.assertTrue((self.cwd_root / ".gemini" / "settings.json").exists()) + + +class TestGeminiUserScope(unittest.TestCase): + """Cover the user-scope path: ``~/.gemini/settings.json``.""" + + def setUp(self): + self.home_tmp = tempfile.TemporaryDirectory() + self.home_root = Path(self.home_tmp.name) + self._home_patcher = patch("pathlib.Path.home", return_value=self.home_root) + self._home_patcher.start() + + def tearDown(self): + self._home_patcher.stop() + self.home_tmp.cleanup() + + def test_user_scope_config_path_points_at_home(self): + adapter = GeminiClientAdapter(user_scope=True) + expected = str(self.home_root / ".gemini" / "settings.json") + self.assertEqual(adapter.get_config_path(), expected) + + def test_user_scope_writes_without_requiring_existing_dir(self): + # ``~/.gemini/`` does not yet exist; user scope is not opt-in. + adapter = GeminiClientAdapter(user_scope=True) + adapter.update_config({"srv": {"command": "node"}}) + + home_settings = self.home_root / ".gemini" / "settings.json" + self.assertTrue(home_settings.exists()) + data = json.loads(home_settings.read_text()) + self.assertEqual(data["mcpServers"]["srv"]["command"], "node") + + def test_user_scope_ignores_project_root(self): + project = self.home_root.parent / "elsewhere" + adapter = GeminiClientAdapter(project_root=project, user_scope=True) + self.assertEqual( + adapter.get_config_path(), + str(self.home_root / ".gemini" / "settings.json"), + ) + + def test_user_scope_configure_mcp_server_does_not_short_circuit(self): + """``configure_mcp_server`` must not early-return in user scope just + because ``~/.gemini/`` is missing -- user scope is not opt-in.""" + with patch("apm_cli.adapters.client.copilot.SimpleRegistryClient") as registry_cls, \ + patch("apm_cli.adapters.client.copilot.RegistryIntegration"): + registry = MagicMock() + registry.find_server_by_reference.return_value = { + "packages": [{"name": "pkg", "registry_name": "npm", "runtime_hint": "npx"}] + } + registry_cls.return_value = registry + + adapter = GeminiClientAdapter(user_scope=True) + result = adapter.configure_mcp_server("some/server", server_name="srv") + + self.assertTrue(result) + home_settings = self.home_root / ".gemini" / "settings.json" + self.assertTrue(home_settings.exists()) + data = json.loads(home_settings.read_text()) + self.assertIn("srv", data["mcpServers"]) + + class TestGeminiConfigureMCPServer(unittest.TestCase): """Test configure_mcp_server() for GeminiClientAdapter.""" def setUp(self): self.tmp = tempfile.TemporaryDirectory() - self.gemini_dir = Path(self.tmp.name) / ".gemini" + self.project_root = Path(self.tmp.name) + self.gemini_dir = self.project_root / ".gemini" self.gemini_dir.mkdir() self.settings_json = self.gemini_dir / "settings.json" - self._cwd_patcher = patch("os.getcwd", return_value=self.tmp.name) - self._cwd_patcher.start() self.mock_registry_patcher = patch("apm_cli.adapters.client.copilot.SimpleRegistryClient") self.mock_registry_class = self.mock_registry_patcher.start() @@ -108,10 +204,9 @@ def setUp(self): self.mock_integration_patcher = patch("apm_cli.adapters.client.copilot.RegistryIntegration") self.mock_integration_class = self.mock_integration_patcher.start() - self.adapter = GeminiClientAdapter() + self.adapter = GeminiClientAdapter(project_root=self.project_root) def tearDown(self): - self._cwd_patcher.stop() self.mock_registry_patcher.stop() self.mock_integration_patcher.stop() self.tmp.cleanup() @@ -173,10 +268,9 @@ class TestGeminiFormatServerConfig(unittest.TestCase): def setUp(self): self.tmp = tempfile.TemporaryDirectory() - self.gemini_dir = Path(self.tmp.name) / ".gemini" + self.project_root = Path(self.tmp.name) + self.gemini_dir = self.project_root / ".gemini" self.gemini_dir.mkdir() - self._cwd_patcher = patch("os.getcwd", return_value=self.tmp.name) - self._cwd_patcher.start() self.mock_registry_patcher = patch("apm_cli.adapters.client.copilot.SimpleRegistryClient") self.mock_registry_class = self.mock_registry_patcher.start() @@ -184,10 +278,9 @@ def setUp(self): self.mock_integration_patcher = patch("apm_cli.adapters.client.copilot.RegistryIntegration") self.mock_integration_class = self.mock_integration_patcher.start() - self.adapter = GeminiClientAdapter() + self.adapter = GeminiClientAdapter(project_root=self.project_root) def tearDown(self): - self._cwd_patcher.stop() self.mock_registry_patcher.stop() self.mock_integration_patcher.stop() self.tmp.cleanup() From d02c639153f4496e94174938e4aa32b75f14e293 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=A8=B1=E5=85=83=E8=B1=AA?= <146086744+edenfunf@users.noreply.github.com> Date: Wed, 13 May 2026 18:16:47 +0800 Subject: [PATCH 2/9] style(tests): use parenthesized multi-context with-statement (#1299) --- tests/unit/test_gemini_mcp.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/unit/test_gemini_mcp.py b/tests/unit/test_gemini_mcp.py index 72954b5fc..e9558b458 100644 --- a/tests/unit/test_gemini_mcp.py +++ b/tests/unit/test_gemini_mcp.py @@ -168,8 +168,10 @@ def test_user_scope_ignores_project_root(self): def test_user_scope_configure_mcp_server_does_not_short_circuit(self): """``configure_mcp_server`` must not early-return in user scope just because ``~/.gemini/`` is missing -- user scope is not opt-in.""" - with patch("apm_cli.adapters.client.copilot.SimpleRegistryClient") as registry_cls, \ - patch("apm_cli.adapters.client.copilot.RegistryIntegration"): + with ( + patch("apm_cli.adapters.client.copilot.SimpleRegistryClient") as registry_cls, + patch("apm_cli.adapters.client.copilot.RegistryIntegration"), + ): registry = MagicMock() registry.find_server_by_reference.return_value = { "packages": [{"name": "pkg", "registry_name": "npm", "runtime_hint": "npx"}] From 21ca8ad959a344e9f10fe6fc57254f7ec58f25c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=A8=B1=E5=85=83=E8=B1=AA?= <146086744+edenfunf@users.noreply.github.com> Date: Wed, 13 May 2026 18:16:51 +0800 Subject: [PATCH 3/9] docs(install-mcp-servers): note Gemini user-scope MCP support (#1299) --- .../docs/consumer/install-mcp-servers.md | 23 +++++++++++-------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/docs/src/content/docs/consumer/install-mcp-servers.md b/docs/src/content/docs/consumer/install-mcp-servers.md index f361a82b3..44b3423f9 100644 --- a/docs/src/content/docs/consumer/install-mcp-servers.md +++ b/docs/src/content/docs/consumer/install-mcp-servers.md @@ -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: Copilot +CLI writes to `~/.copilot/mcp-config.json`, Codex CLI to +`~/.codex/config.toml`, and Gemini CLI to `~/.gemini/settings.json`. +Workspace-only runtimes (e.g. Cursor, OpenCode) are skipped at user +scope. ## stdio vs HTTP servers From ebd8ca2a4d5b76352b831200190eb7856c08fbf6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=A8=B1=E5=85=83=E8=B1=AA?= <146086744+edenfunf@users.noreply.github.com> Date: Thu, 14 May 2026 13:43:32 +0800 Subject: [PATCH 4/9] fix(integration): use project_root for Gemini opt-in gate and cleanup (#1299) --- src/apm_cli/integration/mcp_integrator.py | 2 +- src/apm_cli/integration/mcp_integrator_install.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/apm_cli/integration/mcp_integrator.py b/src/apm_cli/integration/mcp_integrator.py index f8827041e..f1df337ca 100644 --- a/src/apm_cli/integration/mcp_integrator.py +++ b/src/apm_cli/integration/mcp_integrator.py @@ -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 diff --git a/src/apm_cli/integration/mcp_integrator_install.py b/src/apm_cli/integration/mcp_integrator_install.py index c086f98c9..854dd388f 100644 --- a/src/apm_cli/integration/mcp_integrator_install.py +++ b/src/apm_cli/integration/mcp_integrator_install.py @@ -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": @@ -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(): From e3ec9bd3e0e4f0b12c3dbd924b629f2afab36122 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=A8=B1=E5=85=83=E8=B1=AA?= <146086744+edenfunf@users.noreply.github.com> Date: Thu, 14 May 2026 13:43:40 +0800 Subject: [PATCH 5/9] test(integration): cover MCPIntegrator project_root threading for Gemini (#1299) --- tests/integration/test_gemini_integration.py | 53 ++++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/tests/integration/test_gemini_integration.py b/tests/integration/test_gemini_integration.py index 8016853f6..05c049285 100644 --- a/tests/integration/test_gemini_integration.py +++ b/tests/integration/test_gemini_integration.py @@ -202,6 +202,59 @@ def test_creates_mcp_servers_key_if_missing(self): 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: From 54a0b74b13b74cbaf3ce5767b610fa998b704c92 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=A8=B1=E5=85=83=E8=B1=AA?= <146086744+edenfunf@users.noreply.github.com> Date: Thu, 14 May 2026 13:52:22 +0800 Subject: [PATCH 6/9] docs(changelog,install-mcp-servers): align with mcp_integrator scope and hedge -g enumeration (#1299) --- docs/src/content/docs/consumer/install-mcp-servers.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/src/content/docs/consumer/install-mcp-servers.md b/docs/src/content/docs/consumer/install-mcp-servers.md index 44b3423f9..b243a4f2a 100644 --- a/docs/src/content/docs/consumer/install-mcp-servers.md +++ b/docs/src/content/docs/consumer/install-mcp-servers.md @@ -94,11 +94,11 @@ 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: Copilot -CLI writes to `~/.copilot/mcp-config.json`, Codex CLI to -`~/.codex/config.toml`, and Gemini CLI to `~/.gemini/settings.json`. -Workspace-only runtimes (e.g. Cursor, OpenCode) are skipped at user -scope. +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 (e.g. Cursor, +OpenCode) are skipped at user scope. ## stdio vs HTTP servers From 58f257b2605b90b85c534aad13fc83894a5c55c1 Mon Sep 17 00:00:00 2001 From: danielmeppiel Date: Thu, 14 May 2026 17:58:06 +0200 Subject: [PATCH 7/9] fix: address APM review panel recommendations (#1306) - Add 'gemini' to user-scope warning in mcp_integrator_install.py - Add logger.debug/warning calls for silent skips in gemini.py - Add VS Code to runtime docs prose - Add test: get_current_config returns {} when .gemini/ absent - Add test: remove_stale uses project_root not cwd for Gemini Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../docs/consumer/install-mcp-servers.md | 2 +- src/apm_cli/adapters/client/gemini.py | 7 +++- .../integration/mcp_integrator_install.py | 2 +- tests/integration/test_gemini_integration.py | 39 +++++++++++++++++++ tests/unit/test_gemini_mcp.py | 6 +++ 5 files changed, 53 insertions(+), 3 deletions(-) diff --git a/docs/src/content/docs/consumer/install-mcp-servers.md b/docs/src/content/docs/consumer/install-mcp-servers.md index b243a4f2a..df0f60a30 100644 --- a/docs/src/content/docs/consumer/install-mcp-servers.md +++ b/docs/src/content/docs/consumer/install-mcp-servers.md @@ -97,7 +97,7 @@ Gemini's user-scope path (`~/.gemini/settings.json`, selected with 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 (e.g. Cursor, +`~/.gemini/settings.json`. Workspace-only runtimes (VS Code, Cursor, OpenCode) are skipped at user scope. ## stdio vs HTTP servers diff --git a/src/apm_cli/adapters/client/gemini.py b/src/apm_cli/adapters/client/gemini.py index f9010ec12..17366a1c1 100644 --- a/src/apm_cli/adapters/client/gemini.py +++ b/src/apm_cli/adapters/client/gemini.py @@ -82,6 +82,7 @@ def update_config(self, config_updates): """ 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()) @@ -92,6 +93,8 @@ 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) @@ -104,7 +107,8 @@ def get_current_config(self): 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): @@ -245,6 +249,7 @@ def configure_mcp_server( return False 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: diff --git a/src/apm_cli/integration/mcp_integrator_install.py b/src/apm_cli/integration/mcp_integrator_install.py index 854dd388f..322a5feef 100644 --- a/src/apm_cli/integration/mcp_integrator_install.py +++ b/src/apm_cli/integration/mcp_integrator_install.py @@ -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 diff --git a/tests/integration/test_gemini_integration.py b/tests/integration/test_gemini_integration.py index 05c049285..a6d284527 100644 --- a/tests/integration/test_gemini_integration.py +++ b/tests/integration/test_gemini_integration.py @@ -515,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", {}) diff --git a/tests/unit/test_gemini_mcp.py b/tests/unit/test_gemini_mcp.py index e9558b458..0be0b5deb 100644 --- a/tests/unit/test_gemini_mcp.py +++ b/tests/unit/test_gemini_mcp.py @@ -51,6 +51,12 @@ def test_get_current_config_invalid_json(self): config = self.adapter.get_current_config() self.assertEqual(config, {}) + def test_get_current_config_returns_empty_dict_when_no_dir(self): + """get_current_config returns {} when the .gemini directory does not exist.""" + adapter = GeminiClientAdapter(project_root=Path(tempfile.mkdtemp())) + config = adapter.get_current_config() + self.assertEqual(config, {}) + def test_update_config_creates_file(self): self.adapter.update_config({"my-server": {"command": "npx", "args": ["-y", "pkg"]}}) data = json.loads(self.settings_json.read_text()) From af1a6973315a1cdb90cd822c4083ca05c2946331 Mon Sep 17 00:00:00 2001 From: danielmeppiel Date: Fri, 15 May 2026 06:10:48 +0200 Subject: [PATCH 8/9] style: use contextlib.suppress for SIM105 lint in test_registry Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- tests/integration/test_registry.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/tests/integration/test_registry.py b/tests/integration/test_registry.py index abab11426..84372f95c 100644 --- a/tests/integration/test_registry.py +++ b/tests/integration/test_registry.py @@ -1,5 +1,6 @@ """Integration tests for MCP registry client.""" +import contextlib import gc import json import os @@ -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: From 04a057ddf82b00a35ce65f9e1cb9ae92f3d74c08 Mon Sep 17 00:00:00 2001 From: danielmeppiel Date: Fri, 15 May 2026 06:14:37 +0200 Subject: [PATCH 9/9] style: ruff format gemini.py and test_gemini_integration.py Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/apm_cli/adapters/client/gemini.py | 9 +++++++-- tests/integration/test_gemini_integration.py | 4 +++- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/src/apm_cli/adapters/client/gemini.py b/src/apm_cli/adapters/client/gemini.py index 17366a1c1..091c5fbf4 100644 --- a/src/apm_cli/adapters/client/gemini.py +++ b/src/apm_cli/adapters/client/gemini.py @@ -82,7 +82,9 @@ def update_config(self, config_updates): """ 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) + logger.debug( + "Skipping Gemini project-scope write -- %s does not exist (opt-in)", gemini_dir + ) return config_path = Path(self.get_config_path()) @@ -249,7 +251,10 @@ def configure_mcp_server( return False 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()) + logger.debug( + "Gemini opt-in gate: %s absent, skipping configure_mcp_server", + self._get_gemini_dir(), + ) return True try: diff --git a/tests/integration/test_gemini_integration.py b/tests/integration/test_gemini_integration.py index a6d284527..593d9f4ae 100644 --- a/tests/integration/test_gemini_integration.py +++ b/tests/integration/test_gemini_integration.py @@ -537,7 +537,9 @@ def test_remove_stale_gemini_uses_project_root_not_cwd(self, monkeypatch): from apm_cli.integration.mcp_integrator import MCPIntegrator self.settings_json.write_text( - json.dumps({"mcpServers": {"stale-srv": {"command": "echo"}, "keep-srv": {"command": "cat"}}}) + json.dumps( + {"mcpServers": {"stale-srv": {"command": "echo"}, "keep-srv": {"command": "cat"}}} + ) ) other_cwd = tempfile.mkdtemp(prefix="apm-not-project-")