diff --git a/CHANGELOG.md b/CHANGELOG.md index 134ae96aa..86f469143 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- `apm install` now supports target-native MCP config generation for the `antigravity` target, writing to `.agents/mcp_config.json` (project scope) or `~/.gemini/config/mcp_config.json` (user scope). Remote SSE and HTTP servers are correctly formatted to use the `serverUrl` field required by Google Antigravity. (by @okamiconcept, #2039) + ### Fixed - Sub-skill and native-skill ownership tracking now keys on the full diff --git a/src/apm_cli/adapters/client/antigravity.py b/src/apm_cli/adapters/client/antigravity.py index 53a52c61e..b7900cc80 100644 --- a/src/apm_cli/adapters/client/antigravity.py +++ b/src/apm_cli/adapters/client/antigravity.py @@ -52,3 +52,20 @@ def _get_config_dir(self) -> Path: def get_config_path(self): """Return the path to ``mcp_config.json`` for the active scope.""" return str(self._get_config_dir() / "mcp_config.json") + + def _format_server_config(self, server_info, env_overrides=None, runtime_vars=None): + """Format server info into Antigravity CLI MCP configuration. + + Delegates to the parent (GeminiClientAdapter) for initial formatting, + then replaces any ``url`` or ``httpUrl`` key with ``serverUrl`` for + remote endpoints to match Antigravity's schema. + """ + config = super()._format_server_config( + server_info, env_overrides=env_overrides, runtime_vars=runtime_vars + ) + if isinstance(config, dict): + if "url" in config: + config["serverUrl"] = config.pop("url") + elif "httpUrl" in config: + config["serverUrl"] = config.pop("httpUrl") + return config diff --git a/src/apm_cli/integration/mcp_integrator_install.py b/src/apm_cli/integration/mcp_integrator_install.py index 2f252376e..945dac8eb 100644 --- a/src/apm_cli/integration/mcp_integrator_install.py +++ b/src/apm_cli/integration/mcp_integrator_install.py @@ -240,6 +240,7 @@ def _discover_installed_runtimes(project_root_path, *, user_scope: bool) -> list "claude", "intellij", "hermes", + "antigravity", ]: try: if not _runtime_is_present( @@ -283,6 +284,10 @@ def _runtime_is_present( from apm_cli.adapters.client.intellij import _intellij_config_dir return _intellij_config_dir().is_dir() + if runtime_name == "antigravity": + if user_scope: + return find_runtime_binary("agy") is not None + return (project_root_path / ".agents").is_dir() if runtime_name == "hermes": return _hermes_runtime_opted_in() return manager.is_runtime_available(runtime_name) @@ -307,6 +312,12 @@ def _discover_installed_runtimes_fallback( # Claude Code: directory-presence OR binary-on-PATH if (project_root_path / ".claude").is_dir() or find_runtime_binary("claude") is not None: installed_runtimes.append("claude") + # Antigravity: directory-presence (project-scope) OR binary-on-PATH (user-scope) + if user_scope: + if find_runtime_binary("agy") is not None: + installed_runtimes.append("antigravity") + elif (project_root_path / ".agents").is_dir(): + installed_runtimes.append("antigravity") # JetBrains Copilot: user-scope config directory presence try: from apm_cli.adapters.client.intellij import _intellij_config_dir diff --git a/src/apm_cli/integration/targets.py b/src/apm_cli/integration/targets.py index 5889d1469..7a970ccd4 100644 --- a/src/apm_cli/integration/targets.py +++ b/src/apm_cli/integration/targets.py @@ -450,6 +450,7 @@ def for_scope(self, user_scope: bool = False) -> TargetProfile | None: "vscode": "copilot", "agents": "copilot", "intellij": "copilot", + "antigravity": "antigravity", } diff --git a/tests/integration/test_mcp_targets_gating_e2e.py b/tests/integration/test_mcp_targets_gating_e2e.py index 9114a656e..58a6a6099 100644 --- a/tests/integration/test_mcp_targets_gating_e2e.py +++ b/tests/integration/test_mcp_targets_gating_e2e.py @@ -45,6 +45,21 @@ def _make_stdio_dep(name: str = "test-srv") -> MCPDependency: ) +def _make_remote_dep(name: str = "test-remote") -> MCPDependency: + """Build a synthetic remote MCP dependency that needs no network.""" + return MCPDependency.from_dict( + { + "name": name, + "registry": False, + "transport": "http", + "url": "https://api.example.com/mcp/", + "headers": { + "Authorization": "Bearer TEST_TOKEN", + }, + } + ) + + def _seed_signal(project: Path, target: str) -> None: """Seed an on-disk signal that ``detect_signals`` recognizes.""" if target == "copilot": @@ -77,6 +92,10 @@ def _codex_mcp_path(project: Path) -> Path: return project / ".codex" / "config.toml" +def _agents_mcp_path(project: Path) -> Path: + return project / ".agents" / "mcp_config.json" + + class TestMCPTargetsGatingE2E: def test_user_scope_claude_install_honors_claude_config_dir(self, tmp_path, monkeypatch): project = tmp_path / "project" @@ -232,3 +251,44 @@ def test_greenfield_no_targets_no_signals_no_flag_writes_nothing( "not receive a silent copilot-vscode MCP write -- the " "pre-#1336 fallback is gone." ) + + def test_targets_whitelist_antigravity_allows_listed_runtimes(self, tmp_path, monkeypatch): + """``targets: [antigravity]`` -> antigravity MCP config IS written + when .agents/ project-scope directory exists. + """ + project = tmp_path / "proj-whitelist-antigravity" + project.mkdir() + fake_home = tmp_path / "home" + fake_home.mkdir() + monkeypatch.setenv("HOME", str(fake_home)) + + # Seed the .agents directory (the required signal for project scope) + (project / ".agents").mkdir() + + MCPIntegrator.install( + [ + _make_stdio_dep("e2e-antigravity-stdio"), + _make_remote_dep("e2e-antigravity-remote"), + ], + project_root=project, + apm_config={"targets": ["antigravity"]}, + ) + + assert _agents_mcp_path(project).exists(), ( + "antigravity MCP config MUST be written when antigravity IS in targets " + "and .agents/ directory exists." + ) + agents_data = json.loads(_agents_mcp_path(project).read_text(encoding="utf-8")) + + # Verify stdio server formatting + stdio_srv = agents_data.get("mcpServers", {}).get("e2e-antigravity-stdio", {}) + assert stdio_srv.get("command") == "echo" + assert stdio_srv.get("args") == ["mcp-targets-gate-e2e"] + + # Verify remote server formatting uses serverUrl instead of url or httpUrl + remote_srv = agents_data.get("mcpServers", {}).get("e2e-antigravity-remote", {}) + assert "serverUrl" in remote_srv + assert remote_srv["serverUrl"] == "https://api.example.com/mcp/" + assert "url" not in remote_srv + assert "httpUrl" not in remote_srv + assert remote_srv.get("headers", {}).get("Authorization") == "Bearer TEST_TOKEN" diff --git a/tests/unit/integration/test_antigravity_target.py b/tests/unit/integration/test_antigravity_target.py index 883fa789d..d6fd1f6af 100644 --- a/tests/unit/integration/test_antigravity_target.py +++ b/tests/unit/integration/test_antigravity_target.py @@ -267,6 +267,51 @@ def test_antigravity_mcp_writes_mcp_servers_to_dedicated_file(tmp_path: Path) -> assert data["mcpServers"]["demo"]["command"] == "npx" +def test_antigravity_mcp_remote_server_uses_server_url(tmp_path: Path) -> None: + (tmp_path / ".agents").mkdir() + adapter = AntigravityClientAdapter(project_root=tmp_path, user_scope=False) + + server_info = { + "name": "remote-demo", + "remotes": [ + { + "url": "https://api.example.com/mcp/", + "transport_type": "sse", + } + ], + } + server_config = adapter._format_server_config(server_info) + assert "serverUrl" in server_config + assert server_config["serverUrl"] == "https://api.example.com/mcp/" + assert "url" not in server_config + assert "httpUrl" not in server_config + + +def test_antigravity_mcp_runtime_is_detected_when_agents_dir_exists(tmp_path: Path) -> None: + from apm_cli.integration.mcp_integrator_install import _discover_installed_runtimes + + # Without .agents, not detected + assert "antigravity" not in _discover_installed_runtimes(tmp_path, user_scope=False) + + # With .agents, detected + (tmp_path / ".agents").mkdir() + assert "antigravity" in _discover_installed_runtimes(tmp_path, user_scope=False) + + +def test_antigravity_mcp_runtime_is_detected_at_user_scope_when_agy_binary_exists( + monkeypatch, +) -> None: + import shutil + + from apm_cli.integration.mcp_integrator_install import _discover_installed_runtimes + + # Mock shutil.which to find 'agy' + monkeypatch.setattr(shutil, "which", lambda cmd: "/usr/local/bin/agy" if cmd == "agy" else None) + + # At user scope, should detect antigravity since 'agy' binary exists + assert "antigravity" in _discover_installed_runtimes(Path("/nonexistent"), user_scope=True) + + # --------------------------------------------------------------------------- # Hooks -> .agents/hooks.json in Antigravity's OWN native schema # ---------------------------------------------------------------------------