Skip to content

Commit 1a2c351

Browse files
authored
Add fx MCP install target (#234)
## Summary - add `fx` as a `kernel mcp install` target - write Kernel's Streamable HTTP and OAuth configuration to `~/.fx/mcp.json` - preserve existing fx MCP servers and secure the profile directory and file - print fx-specific authentication and verification steps ## Testing - `make test` - built the CLI and smoke-tested `kernel mcp install --target fx` with a clean home directory - verified generated permissions are `0700` for `~/.fx` and `0600` for `mcp.json` - `git diff --check` <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Low Risk** > Local CLI config-file writes only; no auth, network, or shared-service changes. Restrictive file modes slightly reduce risk of leaking MCP config. > > **Overview** > Adds `fx` as a `kernel mcp install` target so Kernel can be registered as an HTTP MCP server with OAuth in `~/.fx/mcp.json`. > > Install merges a `kernel` entry into the existing `mcp` map without dropping other servers, writes the file with `0600` (and `0700` for a newly created `~/.fx`), and prints fx-specific reload/auth/verify steps. Tests cover merge behavior and permission handling. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 0e50188. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent 99e42f3 commit 1a2c351

3 files changed

Lines changed: 175 additions & 0 deletions

File tree

‎cmd/mcp/install.go‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ Supported targets:
2424
vscode - Visual Studio Code
2525
goose - Goose AI
2626
zed - Zed editor
27+
fx - fx coding agent
2728
2829
Examples:
2930
# Install for Cursor
@@ -135,5 +136,11 @@ func printPostInstallInstructions(target Target) {
135136
pterm.Println(" 1. Restart Zed")
136137
pterm.Println(" 2. The Kernel context server will be available")
137138
pterm.Println(" 3. You'll be prompted to authenticate when first using Kernel tools")
139+
140+
case TargetFx:
141+
pterm.Info.Println("Next steps:")
142+
pterm.Println(" 1. Start fx, or run '/mcp reload' in an existing session")
143+
pterm.Println(" 2. Run '/mcp auth kernel --open' to authenticate")
144+
pterm.Println(" 3. Run '/mcp list' to verify that Kernel is connected")
138145
}
139146
}

‎cmd/mcp/mcp.go‎

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ const (
3434
TargetVSCode Target = "vscode"
3535
TargetGoose Target = "goose"
3636
TargetZed Target = "zed"
37+
TargetFx Target = "fx"
3738
)
3839

3940
// KernelMCPURL is the URL for the Kernel MCP server
@@ -49,6 +50,7 @@ func AllTargets() []Target {
4950
TargetVSCode,
5051
TargetGoose,
5152
TargetZed,
53+
TargetFx,
5254
}
5355
}
5456

@@ -103,6 +105,8 @@ func getConfigPath(target Target) (string, error) {
103105
return filepath.Join(homeDir, ".config", "goose", "config.yaml"), nil
104106
case TargetZed:
105107
return filepath.Join(homeDir, ".config", "zed", "settings.json"), nil
108+
case TargetFx:
109+
return filepath.Join(homeDir, ".fx", "mcp.json"), nil
106110
default:
107111
return "", fmt.Errorf("unsupported target: %s", target)
108112
}
@@ -236,6 +240,25 @@ func writeJSONFile(path string, config map[string]interface{}) error {
236240
return nil
237241
}
238242

243+
func writePrivateJSONFile(path string, config map[string]interface{}) error {
244+
dir := filepath.Dir(path)
245+
if err := os.MkdirAll(dir, 0700); err != nil {
246+
return fmt.Errorf("failed to create directory: %w", err)
247+
}
248+
249+
data, err := json.MarshalIndent(config, "", " ")
250+
if err != nil {
251+
return fmt.Errorf("failed to marshal JSON: %w", err)
252+
}
253+
if err := os.WriteFile(path, data, 0600); err != nil {
254+
return fmt.Errorf("failed to write file: %w", err)
255+
}
256+
if err := os.Chmod(path, 0600); err != nil {
257+
return fmt.Errorf("failed to secure file: %w", err)
258+
}
259+
return nil
260+
}
261+
239262
// installForCursor installs MCP config for Cursor
240263
func installForCursor(configPath string) error {
241264
config, err := readJSONFile(configPath)
@@ -394,6 +417,28 @@ func installForZed(configPath string) error {
394417
return writeJSONFile(configPath, config)
395418
}
396419

420+
// installForFx installs MCP config for fx
421+
func installForFx(configPath string) error {
422+
config, err := readJSONFile(configPath)
423+
if err != nil {
424+
return err
425+
}
426+
427+
mcpServers, ok := config["mcp"].(map[string]interface{})
428+
if !ok {
429+
mcpServers = make(map[string]interface{})
430+
}
431+
432+
mcpServers["kernel"] = map[string]interface{}{
433+
"type": "http",
434+
"url": KernelMCPURL,
435+
"oauth": map[string]interface{}{},
436+
}
437+
config["mcp"] = mcpServers
438+
439+
return writePrivateJSONFile(configPath, config)
440+
}
441+
397442
// Install configures the MCP server for the specified target
398443
func Install(target Target) error {
399444
configPath, err := getConfigPath(target)
@@ -416,6 +461,8 @@ func Install(target Target) error {
416461
return installForGoose(configPath)
417462
case TargetZed:
418463
return installForZed(configPath)
464+
case TargetFx:
465+
return installForFx(configPath)
419466
default:
420467
return fmt.Errorf("unsupported target: %s", target)
421468
}

‎cmd/mcp/mcp_test.go‎

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
package mcp
2+
3+
import (
4+
"encoding/json"
5+
"os"
6+
"path/filepath"
7+
"runtime"
8+
"testing"
9+
)
10+
11+
func TestInstallForFx(t *testing.T) {
12+
home := t.TempDir()
13+
t.Setenv("HOME", home)
14+
t.Setenv("USERPROFILE", home)
15+
16+
configPath := filepath.Join(home, ".fx", "mcp.json")
17+
if err := os.MkdirAll(filepath.Dir(configPath), 0755); err != nil {
18+
t.Fatal(err)
19+
}
20+
21+
existing := `{
22+
"mcp": {
23+
"existing": {
24+
"type": "http",
25+
"url": "https://example.com/mcp"
26+
}
27+
},
28+
"setting": "preserved"
29+
}`
30+
if err := os.WriteFile(configPath, []byte(existing), 0644); err != nil {
31+
t.Fatal(err)
32+
}
33+
34+
if err := Install(TargetFx); err != nil {
35+
t.Fatal(err)
36+
}
37+
38+
data, err := os.ReadFile(configPath)
39+
if err != nil {
40+
t.Fatal(err)
41+
}
42+
43+
var config map[string]interface{}
44+
if err := json.Unmarshal(data, &config); err != nil {
45+
t.Fatal(err)
46+
}
47+
if config["setting"] != "preserved" {
48+
t.Fatalf("setting = %v, want preserved", config["setting"])
49+
}
50+
51+
servers, ok := config["mcp"].(map[string]interface{})
52+
if !ok {
53+
t.Fatalf("mcp = %#v, want object", config["mcp"])
54+
}
55+
if _, ok := servers["existing"]; !ok {
56+
t.Fatal("existing MCP server was removed")
57+
}
58+
59+
kernel, ok := servers["kernel"].(map[string]interface{})
60+
if !ok {
61+
t.Fatalf("kernel = %#v, want object", servers["kernel"])
62+
}
63+
if kernel["type"] != "http" {
64+
t.Fatalf("type = %v, want http", kernel["type"])
65+
}
66+
if kernel["url"] != KernelMCPURL {
67+
t.Fatalf("url = %v, want %s", kernel["url"], KernelMCPURL)
68+
}
69+
if oauth, ok := kernel["oauth"].(map[string]interface{}); !ok || len(oauth) != 0 {
70+
t.Fatalf("oauth = %#v, want empty object", kernel["oauth"])
71+
}
72+
73+
if runtime.GOOS != "windows" {
74+
fileInfo, err := os.Stat(configPath)
75+
if err != nil {
76+
t.Fatal(err)
77+
}
78+
if got := fileInfo.Mode().Perm(); got != 0600 {
79+
t.Fatalf("config permissions = %o, want 600", got)
80+
}
81+
82+
dirInfo, err := os.Stat(filepath.Dir(configPath))
83+
if err != nil {
84+
t.Fatal(err)
85+
}
86+
if got := dirInfo.Mode().Perm(); got != 0755 {
87+
t.Fatalf("config directory permissions = %o, want preserved 755", got)
88+
}
89+
}
90+
}
91+
92+
func TestInstallForFxClean(t *testing.T) {
93+
home := t.TempDir()
94+
t.Setenv("HOME", home)
95+
t.Setenv("USERPROFILE", home)
96+
97+
if err := Install(TargetFx); err != nil {
98+
t.Fatal(err)
99+
}
100+
101+
if runtime.GOOS == "windows" {
102+
return
103+
}
104+
105+
configPath := filepath.Join(home, ".fx", "mcp.json")
106+
fileInfo, err := os.Stat(configPath)
107+
if err != nil {
108+
t.Fatal(err)
109+
}
110+
if got := fileInfo.Mode().Perm(); got != 0600 {
111+
t.Fatalf("config permissions = %o, want 600", got)
112+
}
113+
114+
dirInfo, err := os.Stat(filepath.Dir(configPath))
115+
if err != nil {
116+
t.Fatal(err)
117+
}
118+
if got := dirInfo.Mode().Perm(); got != 0700 {
119+
t.Fatalf("config directory permissions = %o, want 700", got)
120+
}
121+
}

0 commit comments

Comments
 (0)