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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
17 changes: 17 additions & 0 deletions src/apm_cli/adapters/client/antigravity.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
11 changes: 11 additions & 0 deletions src/apm_cli/integration/mcp_integrator_install.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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)
Expand All @@ -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
Expand Down
1 change: 1 addition & 0 deletions src/apm_cli/integration/targets.py
Original file line number Diff line number Diff line change
Expand Up @@ -450,6 +450,7 @@ def for_scope(self, user_scope: bool = False) -> TargetProfile | None:
"vscode": "copilot",
"agents": "copilot",
"intellij": "copilot",
"antigravity": "antigravity",
}


Expand Down
60 changes: 60 additions & 0 deletions tests/integration/test_mcp_targets_gating_e2e.py
Original file line number Diff line number Diff line change
Expand Up @@ -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":
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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"
45 changes: 45 additions & 0 deletions tests/unit/integration/test_antigravity_target.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
# ---------------------------------------------------------------------------
Expand Down