diff --git a/cmd/mcp/install.go b/cmd/mcp/install.go index f1cd880e..9ba67a18 100644 --- a/cmd/mcp/install.go +++ b/cmd/mcp/install.go @@ -20,6 +20,7 @@ Supported targets: cursor - Cursor editor claude - Claude Desktop app claude-code - Claude Code CLI + antigravity - Google Antigravity windsurf - Windsurf editor vscode - Visual Studio Code goose - Goose AI @@ -119,6 +120,12 @@ func printPostInstallInstructions(target Target) { pterm.Println(" 1. Run '/mcp' in the Claude Code REPL to authenticate") pterm.Println(" 2. The Kernel tools will then be available") + case TargetAntigravity: + pterm.Info.Println("Next steps:") + pterm.Println(" 1. Go to Settings -> Customizations, scroll to Installed MCP Servers, click the reload button") + pterm.Println(" 2. Complete the OAuth flow that opens in your browser") + pterm.Println(" 3. Verify that Kernel appears connected in Antigravity") + case TargetWindsurf: pterm.Info.Println("Next steps:") pterm.Println(" 1. Open Windsurf settings and navigate to MCP servers") diff --git a/cmd/mcp/mcp.go b/cmd/mcp/mcp.go index 0b871d3c..cbed7ffc 100644 --- a/cmd/mcp/mcp.go +++ b/cmd/mcp/mcp.go @@ -27,14 +27,15 @@ var MCPCmd = &cobra.Command{ type Target string const ( - TargetCursor Target = "cursor" - TargetClaude Target = "claude" - TargetClaudeCode Target = "claude-code" - TargetWindsurf Target = "windsurf" - TargetVSCode Target = "vscode" - TargetGoose Target = "goose" - TargetZed Target = "zed" - TargetFx Target = "fx" + TargetCursor Target = "cursor" + TargetClaude Target = "claude" + TargetClaudeCode Target = "claude-code" + TargetAntigravity Target = "antigravity" + TargetWindsurf Target = "windsurf" + TargetVSCode Target = "vscode" + TargetGoose Target = "goose" + TargetZed Target = "zed" + TargetFx Target = "fx" ) // KernelMCPURL is the URL for the Kernel MCP server @@ -46,6 +47,7 @@ func AllTargets() []Target { TargetCursor, TargetClaude, TargetClaudeCode, + TargetAntigravity, TargetWindsurf, TargetVSCode, TargetGoose, @@ -86,6 +88,8 @@ func getConfigPath(target Target) (string, error) { case TargetClaudeCode: // Claude Code uses the ~/.claude.json file return filepath.Join(homeDir, ".claude.json"), nil + case TargetAntigravity: + return filepath.Join(homeDir, ".gemini", "config", "mcp_config.json"), nil case TargetWindsurf: return filepath.Join(homeDir, ".codeium", "windsurf", "mcp_config.json"), nil case TargetVSCode: @@ -327,6 +331,43 @@ func installForClaudeCode(configPath string) error { return writeJSONFile(configPath, config) } +// installForAntigravity installs MCP config for Google Antigravity +func installForAntigravity(configPath string) error { + config, err := readJSONFile(configPath) + if err != nil { + return err + } + + // Get or create mcpServers section + mcpServers, ok := config["mcpServers"].(map[string]interface{}) + if !ok { + mcpServers = make(map[string]interface{}) + } + + // Antigravity's remote-server client can finish OAuth and still send + // initialize without the bearer token, so Kernel goes over stdio via + // mcp-remote like Claude Desktop, Windsurf and Zed. + // + // Merge into any existing entry so hand-added fields survive a reinstall; + // the remote-transport keys are dropped now that nothing reads them. + kernel, ok := mcpServers["kernel"].(map[string]interface{}) + if !ok { + kernel = make(map[string]interface{}) + } + kernel["command"] = "npx" + kernel["args"] = []string{"-y", "mcp-remote", KernelMCPURL} + delete(kernel, "serverUrl") + delete(kernel, "url") + delete(kernel, "httpUrl") + + mcpServers["kernel"] = kernel + config["mcpServers"] = mcpServers + + // Preserving headers means this file can now hold an API key, so it is + // written 0600 rather than world-readable. + return writePrivateJSONFile(configPath, config) +} + // installForWindsurf installs MCP config for Windsurf func installForWindsurf(configPath string) error { config, err := readJSONFile(configPath) @@ -453,6 +494,8 @@ func Install(target Target) error { return installForClaude(configPath) case TargetClaudeCode: return installForClaudeCode(configPath) + case TargetAntigravity: + return installForAntigravity(configPath) case TargetWindsurf: return installForWindsurf(configPath) case TargetVSCode: diff --git a/cmd/mcp/mcp_test.go b/cmd/mcp/mcp_test.go index 86d1befb..13bcfb16 100644 --- a/cmd/mcp/mcp_test.go +++ b/cmd/mcp/mcp_test.go @@ -119,3 +119,186 @@ func TestInstallForFxClean(t *testing.T) { t.Fatalf("config directory permissions = %o, want 700", got) } } + +func assertMCPRemoteShape(t *testing.T, kernel map[string]interface{}) { + t.Helper() + + if kernel["command"] != "npx" { + t.Fatalf("command = %v, want npx", kernel["command"]) + } + args, ok := kernel["args"].([]interface{}) + if !ok { + t.Fatalf("args = %#v, want array", kernel["args"]) + } + want := []string{"-y", "mcp-remote", KernelMCPURL} + if len(args) != len(want) { + t.Fatalf("args = %v, want %v", args, want) + } + for i, w := range want { + if args[i] != w { + t.Fatalf("args[%d] = %v, want %s", i, args[i], w) + } + } + // A leftover remote-transport key would misrepresent how the server is reached + for _, key := range []string{"serverUrl", "url", "httpUrl"} { + if _, ok := kernel[key]; ok { + t.Fatalf("kernel = %#v, want %s removed", kernel, key) + } + } +} + +func TestInstallForAntigravity(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("USERPROFILE", home) + + configPath := filepath.Join(home, ".gemini", "config", "mcp_config.json") + if err := os.MkdirAll(filepath.Dir(configPath), 0755); err != nil { + t.Fatal(err) + } + + existing := `{ + "mcpServers": { + "existing": { + "serverUrl": "https://example.com/mcp" + } + }, + "setting": "preserved" +}` + if err := os.WriteFile(configPath, []byte(existing), 0644); err != nil { + t.Fatal(err) + } + + if err := Install(TargetAntigravity); err != nil { + t.Fatal(err) + } + + data, err := os.ReadFile(configPath) + if err != nil { + t.Fatal(err) + } + + var config map[string]interface{} + if err := json.Unmarshal(data, &config); err != nil { + t.Fatal(err) + } + if config["setting"] != "preserved" { + t.Fatalf("setting = %v, want preserved", config["setting"]) + } + + servers, ok := config["mcpServers"].(map[string]interface{}) + if !ok { + t.Fatalf("mcpServers = %#v, want object", config["mcpServers"]) + } + if _, ok := servers["existing"]; !ok { + t.Fatal("existing MCP server was removed") + } + + kernel, ok := servers["kernel"].(map[string]interface{}) + if !ok { + t.Fatalf("kernel = %#v, want object", servers["kernel"]) + } + assertMCPRemoteShape(t, kernel) +} + +func TestInstallForAntigravityClean(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("USERPROFILE", home) + + if err := Install(TargetAntigravity); err != nil { + t.Fatal(err) + } + + data, err := os.ReadFile(filepath.Join(home, ".gemini", "config", "mcp_config.json")) + if err != nil { + t.Fatal(err) + } + + var config map[string]interface{} + if err := json.Unmarshal(data, &config); err != nil { + t.Fatal(err) + } + + servers, ok := config["mcpServers"].(map[string]interface{}) + if !ok { + t.Fatalf("mcpServers = %#v, want object", config["mcpServers"]) + } + kernel, ok := servers["kernel"].(map[string]interface{}) + if !ok { + t.Fatalf("kernel = %#v, want object", servers["kernel"]) + } + assertMCPRemoteShape(t, kernel) +} + +func TestInstallForAntigravityPreservesExistingKernelFields(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("USERPROFILE", home) + + configPath := filepath.Join(home, ".gemini", "config", "mcp_config.json") + if err := os.MkdirAll(filepath.Dir(configPath), 0755); err != nil { + t.Fatal(err) + } + + // headers carries the documented API-key workaround, and url is a stale key + // from an earlier install that Antigravity ignores. + existing := `{ + "mcpServers": { + "kernel": { + "serverUrl": "https://mcp.onkernel.com/stale", + "url": "https://mcp.onkernel.com/stale", + "httpUrl": "https://mcp.onkernel.com/stale", + "headers": { + "Authorization": "Bearer sk-test" + } + } + } +}` + if err := os.WriteFile(configPath, []byte(existing), 0644); err != nil { + t.Fatal(err) + } + + if err := Install(TargetAntigravity); err != nil { + t.Fatal(err) + } + + data, err := os.ReadFile(configPath) + if err != nil { + t.Fatal(err) + } + + var config map[string]interface{} + if err := json.Unmarshal(data, &config); err != nil { + t.Fatal(err) + } + + servers, ok := config["mcpServers"].(map[string]interface{}) + if !ok { + t.Fatalf("mcpServers = %#v, want object", config["mcpServers"]) + } + kernel, ok := servers["kernel"].(map[string]interface{}) + if !ok { + t.Fatalf("kernel = %#v, want object", servers["kernel"]) + } + + headers, ok := kernel["headers"].(map[string]interface{}) + if !ok { + t.Fatalf("headers = %#v, want the existing block preserved", kernel["headers"]) + } + if headers["Authorization"] != "Bearer sk-test" { + t.Fatalf("Authorization = %v, want the existing value", headers["Authorization"]) + } + + assertMCPRemoteShape(t, kernel) + + // The preserved headers block can hold an API key, so the file must not stay + // world-readable after a reinstall. + info, err := os.Stat(configPath) + if err != nil { + t.Fatal(err) + } + if got := info.Mode().Perm(); got != 0600 { + t.Fatalf("config permissions = %o, want 600", got) + } +}