Skip to content
Merged
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
7 changes: 7 additions & 0 deletions cmd/mcp/install.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ Supported targets:
vscode - Visual Studio Code
goose - Goose AI
zed - Zed editor
fx - fx coding agent

Examples:
# Install for Cursor
Expand Down Expand Up @@ -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")
}
}
47 changes: 47 additions & 0 deletions cmd/mcp/mcp.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -49,6 +50,7 @@ func AllTargets() []Target {
TargetVSCode,
TargetGoose,
TargetZed,
TargetFx,
}
}

Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand All @@ -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)
}
Expand Down
121 changes: 121 additions & 0 deletions cmd/mcp/mcp_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading