Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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")
}
}
50 changes: 50 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,28 @@ 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)
}
if err := os.Chmod(dir, 0700); err != nil {
return fmt.Errorf("failed to secure 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 +420,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 +464,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
90 changes: 90 additions & 0 deletions cmd/mcp/mcp_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
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 != 0700 {
t.Fatalf("config directory permissions = %o, want 700", got)
}
}
}
Loading