diff --git a/cmd/mcp/install.go b/cmd/mcp/install.go index 2812962a..f1cd880e 100644 --- a/cmd/mcp/install.go +++ b/cmd/mcp/install.go @@ -24,6 +24,7 @@ Supported targets: vscode - Visual Studio Code goose - Goose AI zed - Zed editor + fx - fx coding agent Examples: # Install for Cursor @@ -135,5 +136,11 @@ func printPostInstallInstructions(target Target) { pterm.Println(" 1. Restart Zed") pterm.Println(" 2. The Kernel context server will be available") pterm.Println(" 3. You'll be prompted to authenticate when first using Kernel tools") + + case TargetFx: + pterm.Info.Println("Next steps:") + pterm.Println(" 1. Start fx, or run '/mcp reload' in an existing session") + pterm.Println(" 2. Run '/mcp auth kernel --open' to authenticate") + pterm.Println(" 3. Run '/mcp list' to verify that Kernel is connected") } } diff --git a/cmd/mcp/mcp.go b/cmd/mcp/mcp.go index c698309c..0b871d3c 100644 --- a/cmd/mcp/mcp.go +++ b/cmd/mcp/mcp.go @@ -34,6 +34,7 @@ const ( TargetVSCode Target = "vscode" TargetGoose Target = "goose" TargetZed Target = "zed" + TargetFx Target = "fx" ) // KernelMCPURL is the URL for the Kernel MCP server @@ -49,6 +50,7 @@ func AllTargets() []Target { TargetVSCode, TargetGoose, TargetZed, + TargetFx, } } @@ -103,6 +105,8 @@ func getConfigPath(target Target) (string, error) { return filepath.Join(homeDir, ".config", "goose", "config.yaml"), nil case TargetZed: return filepath.Join(homeDir, ".config", "zed", "settings.json"), nil + case TargetFx: + return filepath.Join(homeDir, ".fx", "mcp.json"), nil default: return "", fmt.Errorf("unsupported target: %s", target) } @@ -236,6 +240,25 @@ func writeJSONFile(path string, config map[string]interface{}) error { return nil } +func writePrivateJSONFile(path string, config map[string]interface{}) error { + dir := filepath.Dir(path) + if err := os.MkdirAll(dir, 0700); err != nil { + return fmt.Errorf("failed to create directory: %w", err) + } + + data, err := json.MarshalIndent(config, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal JSON: %w", err) + } + if err := os.WriteFile(path, data, 0600); err != nil { + return fmt.Errorf("failed to write file: %w", err) + } + if err := os.Chmod(path, 0600); err != nil { + return fmt.Errorf("failed to secure file: %w", err) + } + return nil +} + // installForCursor installs MCP config for Cursor func installForCursor(configPath string) error { config, err := readJSONFile(configPath) @@ -394,6 +417,28 @@ func installForZed(configPath string) error { return writeJSONFile(configPath, config) } +// installForFx installs MCP config for fx +func installForFx(configPath string) error { + config, err := readJSONFile(configPath) + if err != nil { + return err + } + + mcpServers, ok := config["mcp"].(map[string]interface{}) + if !ok { + mcpServers = make(map[string]interface{}) + } + + mcpServers["kernel"] = map[string]interface{}{ + "type": "http", + "url": KernelMCPURL, + "oauth": map[string]interface{}{}, + } + config["mcp"] = mcpServers + + return writePrivateJSONFile(configPath, config) +} + // Install configures the MCP server for the specified target func Install(target Target) error { configPath, err := getConfigPath(target) @@ -416,6 +461,8 @@ func Install(target Target) error { return installForGoose(configPath) case TargetZed: return installForZed(configPath) + case TargetFx: + return installForFx(configPath) default: return fmt.Errorf("unsupported target: %s", target) } diff --git a/cmd/mcp/mcp_test.go b/cmd/mcp/mcp_test.go new file mode 100644 index 00000000..86d1befb --- /dev/null +++ b/cmd/mcp/mcp_test.go @@ -0,0 +1,121 @@ +package mcp + +import ( + "encoding/json" + "os" + "path/filepath" + "runtime" + "testing" +) + +func TestInstallForFx(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("USERPROFILE", home) + + configPath := filepath.Join(home, ".fx", "mcp.json") + if err := os.MkdirAll(filepath.Dir(configPath), 0755); err != nil { + t.Fatal(err) + } + + existing := `{ + "mcp": { + "existing": { + "type": "http", + "url": "https://example.com/mcp" + } + }, + "setting": "preserved" +}` + if err := os.WriteFile(configPath, []byte(existing), 0644); err != nil { + t.Fatal(err) + } + + if err := Install(TargetFx); 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["mcp"].(map[string]interface{}) + if !ok { + t.Fatalf("mcp = %#v, want object", config["mcp"]) + } + 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"]) + } + if kernel["type"] != "http" { + t.Fatalf("type = %v, want http", kernel["type"]) + } + if kernel["url"] != KernelMCPURL { + t.Fatalf("url = %v, want %s", kernel["url"], KernelMCPURL) + } + if oauth, ok := kernel["oauth"].(map[string]interface{}); !ok || len(oauth) != 0 { + t.Fatalf("oauth = %#v, want empty object", kernel["oauth"]) + } + + if runtime.GOOS != "windows" { + fileInfo, err := os.Stat(configPath) + if err != nil { + t.Fatal(err) + } + if got := fileInfo.Mode().Perm(); got != 0600 { + t.Fatalf("config permissions = %o, want 600", got) + } + + dirInfo, err := os.Stat(filepath.Dir(configPath)) + if err != nil { + t.Fatal(err) + } + if got := dirInfo.Mode().Perm(); got != 0755 { + t.Fatalf("config directory permissions = %o, want preserved 755", got) + } + } +} + +func TestInstallForFxClean(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("USERPROFILE", home) + + if err := Install(TargetFx); err != nil { + t.Fatal(err) + } + + if runtime.GOOS == "windows" { + return + } + + configPath := filepath.Join(home, ".fx", "mcp.json") + fileInfo, err := os.Stat(configPath) + if err != nil { + t.Fatal(err) + } + if got := fileInfo.Mode().Perm(); got != 0600 { + t.Fatalf("config permissions = %o, want 600", got) + } + + dirInfo, err := os.Stat(filepath.Dir(configPath)) + if err != nil { + t.Fatal(err) + } + if got := dirInfo.Mode().Perm(); got != 0700 { + t.Fatalf("config directory permissions = %o, want 700", got) + } +}