From 56b22c2c331a570bd6a20358e41192eddc43868c Mon Sep 17 00:00:00 2001 From: Sayan- <1415138+Sayan-@users.noreply.github.com> Date: Wed, 23 Sep 2026 19:14:30 +0000 Subject: [PATCH 1/5] Preserve and secure MCP client configuration --- cmd/mcp/config.go | 472 +++++++++++++++++++++++++++++++++++++++++++++ cmd/mcp/install.go | 43 +---- cmd/mcp/mcp.go | 457 +------------------------------------------ go.mod | 1 + go.sum | 4 + 5 files changed, 487 insertions(+), 490 deletions(-) create mode 100644 cmd/mcp/config.go diff --git a/cmd/mcp/config.go b/cmd/mcp/config.go new file mode 100644 index 00000000..71f02970 --- /dev/null +++ b/cmd/mcp/config.go @@ -0,0 +1,472 @@ +package mcp + +import ( + "bytes" + "encoding/json" + "fmt" + "os" + "path/filepath" + "runtime" + "sort" + "strings" + + "github.com/tailscale/hujson" +) + +type transport string + +const ( + stdio transport = "stdio" + http transport = "http" +) + +type configField struct { + name string + value any + ifMissing bool + skipWhen string +} + +type targetSpec struct { + target Target + description string + path func(string) string + section string + transport transport + fields []configField + stdioArgs []string + remove []string + legacyName string + legacyKey string + printOnly bool +} + +func homePath(parts ...string) func(string) string { + return func(home string) string { + return filepath.Join(append([]string{home}, parts...)...) + } +} + +func claudePath(home string) string { + switch runtime.GOOS { + case "darwin": + return filepath.Join(home, "Library", "Application Support", "Claude", "claude_desktop_config.json") + case "windows": + return filepath.Join(appDataPath(home), "Claude", "claude_desktop_config.json") + default: + return filepath.Join(home, ".config", "Claude", "claude_desktop_config.json") + } +} + +func vsCodePath(home string) string { + switch runtime.GOOS { + case "darwin": + return filepath.Join(home, "Library", "Application Support", "Code", "User", "mcp.json") + case "windows": + return filepath.Join(appDataPath(home), "Code", "User", "mcp.json") + default: + return filepath.Join(home, ".config", "Code", "User", "mcp.json") + } +} + +func appDataPath(home string) string { + if path := os.Getenv("APPDATA"); path != "" { + return path + } + return filepath.Join(home, "AppData", "Roaming") +} + +var targetSpecs = []targetSpec{ + {target: TargetCursor, description: "Cursor editor", path: homePath(".cursor", "mcp.json"), section: "mcpServers", transport: http, + fields: []configField{{name: "url", value: KernelMCPURL}}}, + {target: TargetClaude, description: "Claude Desktop app", path: claudePath, section: "mcpServers", transport: stdio}, + {target: TargetClaudeCode, description: "Claude Code CLI", path: homePath(".claude.json"), section: "mcpServers", transport: http, + fields: []configField{{name: "type", value: "http"}, {name: "url", value: KernelMCPURL}}}, + {target: TargetAntigravity, description: "Google Antigravity", path: homePath(".gemini", "config", "mcp_config.json"), section: "mcpServers", transport: stdio, + stdioArgs: []string{"-y", "mcp-remote", KernelMCPURL, "--static-oauth-client-metadata", "{\"client_name\":\"Antigravity\"}"}}, + {target: TargetWindsurf, description: "Windsurf editor", path: homePath(".codeium", "windsurf", "mcp_config.json"), section: "mcpServers", transport: stdio}, + {target: TargetVSCode, description: "Visual Studio Code", path: vsCodePath, section: "servers", transport: http, + legacyName: "settings.json", legacyKey: "mcp.servers", + fields: []configField{{name: "url", value: KernelMCPURL}, {name: "type", value: "http"}}}, + {target: TargetGoose, description: "Goose AI", path: homePath(".config", "goose", "config.yaml"), transport: stdio, printOnly: true}, + // Current Zed settings omit source; its settings migrator removes that old field. + {target: TargetZed, description: "Zed editor", path: homePath(".config", "zed", "settings.json"), section: "context_servers", transport: stdio, + remove: []string{"source"}}, + {target: TargetFx, description: "fx coding agent", path: homePath(".fx", "mcp.json"), section: "mcp", transport: http, + fields: []configField{{name: "type", value: "http"}, {name: "url", value: KernelMCPURL}, {name: "oauth", value: map[string]any{}, ifMissing: true, skipWhen: "bearer_token_env"}}}, +} + +func specFor(target Target) (targetSpec, bool) { + for _, spec := range targetSpecs { + if spec.target == target { + return spec, true + } + } + return targetSpec{}, false +} + +func AllTargets() []Target { + targets := make([]Target, 0, len(targetSpecs)) + for _, spec := range targetSpecs { + targets = append(targets, spec.target) + } + return targets +} + +func getConfigPath(target Target) (string, error) { + spec, ok := specFor(target) + if !ok { + return "", fmt.Errorf("unsupported target: %s", target) + } + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("failed to get home directory: %w", err) + } + return spec.path(home), nil +} + +func GetConfigPath(target Target) (string, error) { + return getConfigPath(target) +} + +func Install(target Target) error { + spec, ok := specFor(target) + if !ok { + return fmt.Errorf("unsupported target: %s", target) + } + path, err := getConfigPath(target) + if err != nil { + return err + } + if spec.printOnly { + return installForGoose(path) + } + return installConfig(path, spec) +} + +func installConfig(path string, spec targetSpec) error { + data, mode, err := readConfig(path) + if err != nil { + return err + } + var legacy map[string]json.RawMessage + var legacyPath string + var legacyMode os.FileMode + if spec.legacyName != "" { + legacyPath = filepath.Join(filepath.Dir(path), spec.legacyName) + legacy, legacyMode, err = readLegacyKernel(legacyPath, spec.legacyKey) + if err != nil { + return err + } + } + updated, err := mergeConfig(data, spec, legacy) + if err != nil { + return err + } + if legacy != nil && legacyMode != legacyMode&0600 { + if err := os.Chmod(legacyPath, legacyMode&0600); err != nil { + return fmt.Errorf("failed to secure legacy config: %w", err) + } + } + privateMode := mode & 0600 + if bytes.Equal(data, updated) && mode == privateMode { + return nil + } + return writeConfigAtomic(path, updated, privateMode) +} + +func readLegacyKernel(path, sectionKey string) (map[string]json.RawMessage, os.FileMode, error) { + info, err := os.Lstat(path) + if os.IsNotExist(err) { + return nil, 0, nil + } + if err != nil { + return nil, 0, err + } + if !info.Mode().IsRegular() { + return nil, 0, fmt.Errorf("legacy config is not a regular file: %s", path) + } + data, err := os.ReadFile(path) + if err != nil { + return nil, 0, err + } + if len(bytes.TrimSpace(data)) == 0 { + return nil, 0, fmt.Errorf("legacy config is empty: %s", path) + } + root, err := hujson.Parse(data) + if err != nil { + return nil, 0, fmt.Errorf("failed to parse legacy config: %w", err) + } + obj, err := objectAt(&root, "") + if err != nil { + return nil, 0, err + } + if _, exists := member(obj, sectionKey); !exists { + return nil, 0, nil + } + section, err := objectAt(&root, "/"+sectionKey) + if err != nil { + return nil, 0, err + } + if _, exists := member(section, "kernel"); !exists { + return nil, 0, nil + } + kernel, err := objectAt(&root, "/"+sectionKey+"/kernel") + if err != nil { + return nil, 0, err + } + fields := make(map[string]json.RawMessage, len(kernel.Members)) + for _, item := range kernel.Members { + name := item.Name.Value.(hujson.Literal).String() + value := item.Value.Clone() + value.Standardize() + fields[name] = value.Pack() + } + return fields, info.Mode().Perm(), nil +} + +func readConfig(path string) ([]byte, os.FileMode, error) { + info, err := os.Lstat(path) + if os.IsNotExist(err) { + return []byte("{}\n"), 0600, nil + } + if err != nil { + return nil, 0, err + } + if !info.Mode().IsRegular() { + return nil, 0, fmt.Errorf("config is not a regular file: %s", path) + } + data, err := os.ReadFile(path) + if err != nil { + return nil, 0, err + } + if len(bytes.TrimSpace(data)) == 0 { + return nil, 0, fmt.Errorf("config is empty: %s", path) + } + return data, info.Mode().Perm(), nil +} + +// Patch the syntax tree so comments and formatting outside edited fields survive. +// Comments inside replaced values or on removed fields may be lost. +func mergeConfig(data []byte, spec targetSpec, legacy map[string]json.RawMessage) ([]byte, error) { + root, err := hujson.Parse(data) + if err != nil { + return nil, fmt.Errorf("failed to parse config: %w", err) + } + section, err := objectAt(&root, "") + if err != nil { + return nil, err + } + sectionPath := "/" + spec.section + if _, ok := member(section, spec.section); !ok { + if err := patch(&root, "add", sectionPath, map[string]any{}); err != nil { + return nil, err + } + } + section, err = objectAt(&root, sectionPath) + if err != nil { + return nil, err + } + if _, ok := member(section, "kernel"); !ok { + if err := patch(&root, "add", sectionPath+"/kernel", map[string]any{}); err != nil { + return nil, err + } + } + kernelPath := sectionPath + "/kernel" + kernel, err := objectAt(&root, kernelPath) + if err != nil { + return nil, err + } + names := make([]string, 0, len(legacy)) + for name := range legacy { + names = append(names, name) + } + sort.Strings(names) + for _, name := range names { + if _, exists := member(kernel, name); !exists { + if err := patch(&root, "add", kernelPath+"/"+pointerName(name), legacy[name]); err != nil { + return nil, err + } + } + } + kernel, err = objectAt(&root, kernelPath) + if err != nil { + return nil, err + } + + remove := []string{"serverUrl", "httpUrl"} + if spec.transport == stdio { + remove = append(remove, "url", "type") + } else { + remove = append(remove, "command", "args", "source") + } + remove = append(remove, spec.remove...) + for _, name := range remove { + if _, ok := member(kernel, name); ok { + if err := patch(&root, "remove", kernelPath+"/"+name, nil); err != nil { + return nil, err + } + } + } + fields := spec.fields + if spec.transport == stdio { + args := spec.stdioArgs + if args == nil { + args = []string{"-y", "mcp-remote", KernelMCPURL} + } + args, err = mergeStdioArgs(kernel, args) + if err != nil { + return nil, err + } + fields = append([]configField{{name: "command", value: "npx"}, {name: "args", value: args}}, fields...) + } + for _, field := range fields { + kernel, err = objectAt(&root, kernelPath) + if err != nil { + return nil, err + } + current, exists := member(kernel, field.name) + if field.skipWhen != "" { + if _, found := member(kernel, field.skipWhen); found { + if exists { + if err := patch(&root, "remove", kernelPath+"/"+field.name, nil); err != nil { + return nil, err + } + } + continue + } + } + if field.ifMissing && exists { + continue + } + wanted, err := json.Marshal(field.value) + if err != nil { + return nil, err + } + if exists { + normalized := current.Clone() + normalized.Standardize() + var compact bytes.Buffer + if err := json.Compact(&compact, normalized.Pack()); err != nil { + return nil, err + } + if bytes.Equal(compact.Bytes(), wanted) { + continue + } + } + if err := patch(&root, "add", kernelPath+"/"+field.name, field.value); err != nil { + return nil, err + } + } + return root.Pack(), nil +} + +func mergeStdioArgs(kernel *hujson.Object, defaults []string) ([]string, error) { + value, exists := member(kernel, "args") + if !exists { + return defaults, nil + } + normalized := value.Clone() + normalized.Standardize() + var args []string + if err := json.Unmarshal(normalized.Pack(), &args); err != nil { + return nil, fmt.Errorf("invalid kernel args: %w", err) + } + if len(args) < 3 || args[0] != "-y" || (args[1] != "mcp-remote" && !strings.HasPrefix(args[1], "mcp-remote@")) { + return defaults, nil + } + args[2] = KernelMCPURL + if len(defaults) > 3 { + flag := defaults[3] + for i := 3; i < len(args); i++ { + if args[i] == flag { + if i+1 >= len(args) { + return nil, fmt.Errorf("kernel args missing value for %s", flag) + } + args[i+1] = defaults[4] + return args, nil + } + } + args = append(args, defaults[3:]...) + } + return args, nil +} + +func pointerName(name string) string { + return strings.ReplaceAll(strings.ReplaceAll(name, "~", "~0"), "/", "~1") +} + +func objectAt(root *hujson.Value, path string) (*hujson.Object, error) { + value := root + if path != "" { + value = root.Find(path) + } + if value == nil { + return nil, fmt.Errorf("missing config object at %s", path) + } + obj, ok := value.Value.(*hujson.Object) + if !ok { + return nil, fmt.Errorf("expected config object at %s", path) + } + seen := make(map[string]bool, len(obj.Members)) + for _, item := range obj.Members { + name := item.Name.Value.(hujson.Literal).String() + if seen[name] { + return nil, fmt.Errorf("duplicate config key %q at %s", name, path) + } + seen[name] = true + } + return obj, nil +} + +func member(obj *hujson.Object, name string) (*hujson.Value, bool) { + for i := range obj.Members { + if obj.Members[i].Name.Value.(hujson.Literal).String() == name { + return &obj.Members[i].Value, true + } + } + return nil, false +} + +func patch(root *hujson.Value, operation, path string, value any) error { + item := map[string]any{"op": operation, "path": path} + if operation == "add" { + item["value"] = value + } + data, err := json.Marshal([]any{item}) + if err != nil { + return err + } + return root.Patch(data) +} + +func writeConfigAtomic(path string, data []byte, mode os.FileMode) error { + dir := filepath.Dir(path) + if err := os.MkdirAll(dir, 0700); err != nil { + return fmt.Errorf("failed to create config directory: %w", err) + } + file, err := os.CreateTemp(dir, ".kernel-mcp-*") + if err != nil { + return fmt.Errorf("failed to create temporary config: %w", err) + } + defer os.Remove(file.Name()) + if _, err := file.Write(data); err != nil { + file.Close() + return fmt.Errorf("failed to write temporary config: %w", err) + } + if err := file.Chmod(mode); err != nil { + file.Close() + return fmt.Errorf("failed to secure temporary config: %w", err) + } + if err := file.Sync(); err != nil { + file.Close() + return fmt.Errorf("failed to sync temporary config: %w", err) + } + if err := file.Close(); err != nil { + return fmt.Errorf("failed to close temporary config: %w", err) + } + if err := os.Rename(file.Name(), path); err != nil { + return fmt.Errorf("failed to replace config: %w", err) + } + return nil +} diff --git a/cmd/mcp/install.go b/cmd/mcp/install.go index 9ba67a18..58e4098b 100644 --- a/cmd/mcp/install.go +++ b/cmd/mcp/install.go @@ -14,35 +14,18 @@ var installCmd = &cobra.Command{ Long: `Install Kernel MCP server configuration for a supported AI development tool. This command modifies the configuration file for the specified target to add -the Kernel MCP server, enabling browser automation capabilities in your AI tool. - -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 - zed - Zed editor - fx - fx coding agent - -Examples: - # Install for Cursor - kernel mcp install --target cursor - - # Install for Claude Desktop - kernel mcp install --target claude - - # Install for VS Code - kernel mcp install --target vscode`, +the Kernel MCP server, enabling browser automation capabilities in your AI tool.`, RunE: runInstall, } func init() { MCPCmd.AddCommand(installCmd) + installCmd.Long += "\n\nSupported targets:\n" + for _, spec := range targetSpecs { + installCmd.Long += fmt.Sprintf(" %-12s - %s\n", spec.target, spec.description) + } + installCmd.Long += "\nExamples:\n kernel mcp install --target cursor\n kernel mcp install --target vscode" - // Build target list for help text targets := AllTargets() targetStrs := make([]string, len(targets)) for i, t := range targets { @@ -57,16 +40,8 @@ func runInstall(cmd *cobra.Command, args []string) error { targetStr, _ := cmd.Flags().GetString("target") target := Target(strings.ToLower(targetStr)) - // Validate target - validTarget := false - for _, t := range AllTargets() { - if target == t { - validTarget = true - break - } - } - - if !validTarget { + spec, ok := specFor(target) + if !ok { targets := AllTargets() targetStrs := make([]string, len(targets)) for i, t := range targets { @@ -87,7 +62,7 @@ func runInstall(cmd *cobra.Command, args []string) error { } // For Goose, the install function already printed instructions - if target == TargetGoose { + if spec.printOnly { return nil } diff --git a/cmd/mcp/mcp.go b/cmd/mcp/mcp.go index 354e9d48..1dc4fab2 100644 --- a/cmd/mcp/mcp.go +++ b/cmd/mcp/mcp.go @@ -1,12 +1,7 @@ package mcp import ( - "encoding/json" "fmt" - "os" - "path/filepath" - "runtime" - "strings" "github.com/pterm/pterm" "github.com/spf13/cobra" @@ -41,82 +36,7 @@ const ( // KernelMCPURL is the URL for the Kernel MCP server const KernelMCPURL = "https://mcp.onkernel.com/mcp" -// AllTargets returns all supported targets -func AllTargets() []Target { - return []Target{ - TargetCursor, - TargetClaude, - TargetClaudeCode, - TargetAntigravity, - TargetWindsurf, - TargetVSCode, - TargetGoose, - TargetZed, - TargetFx, - } -} - -// getHomeDir returns the user's home directory -func getHomeDir() (string, error) { - return os.UserHomeDir() -} - -// getConfigPath returns the config file path for a given target -func getConfigPath(target Target) (string, error) { - homeDir, err := getHomeDir() - if err != nil { - return "", fmt.Errorf("failed to get home directory: %w", err) - } - - switch target { - case TargetCursor: - return filepath.Join(homeDir, ".cursor", "mcp.json"), nil - case TargetClaude: - switch runtime.GOOS { - case "darwin": - return filepath.Join(homeDir, "Library", "Application Support", "Claude", "claude_desktop_config.json"), nil - case "windows": - appData := os.Getenv("APPDATA") - if appData == "" { - appData = filepath.Join(homeDir, "AppData", "Roaming") - } - return filepath.Join(appData, "Claude", "claude_desktop_config.json"), nil - default: - // Linux - Claude Desktop doesn't officially support Linux, but use XDG config - return filepath.Join(homeDir, ".config", "Claude", "claude_desktop_config.json"), nil - } - 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: - switch runtime.GOOS { - case "darwin": - return filepath.Join(homeDir, "Library", "Application Support", "Code", "User", "settings.json"), nil - case "windows": - appData := os.Getenv("APPDATA") - if appData == "" { - appData = filepath.Join(homeDir, "AppData", "Roaming") - } - return filepath.Join(appData, "Code", "User", "settings.json"), nil - default: - return filepath.Join(homeDir, ".config", "Code", "User", "settings.json"), nil - } - case TargetGoose: - 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) - } -} - -// MCPServerConfig represents the configuration for an MCP server +// MCPServerConfig represents the configuration for an MCP server. type MCPServerConfig struct { URL string `json:"url,omitempty"` Command string `json:"command,omitempty"` @@ -124,299 +44,6 @@ type MCPServerConfig struct { Type string `json:"type,omitempty"` } -// stripJSONComments removes single-line (//) and multi-line (/* */) comments from JSON -// It properly handles strings to avoid removing // or /* */ that appear inside string literals -func stripJSONComments(data []byte) []byte { - content := string(data) - var result strings.Builder - i := 0 - inString := false - inMultiLineComment := false - escapeNext := false - - for i < len(content) { - char := content[i] - - if escapeNext { - result.WriteByte(char) - escapeNext = false - i++ - continue - } - - // Check multi-line comment first - skip all content including quotes - if inMultiLineComment { - if i+1 < len(content) && char == '*' && content[i+1] == '/' { - inMultiLineComment = false - i += 2 - continue - } - i++ - continue - } - - if char == '\\' && inString { - escapeNext = true - result.WriteByte(char) - i++ - continue - } - - if char == '"' { - inString = !inString - result.WriteByte(char) - i++ - continue - } - - if inString { - result.WriteByte(char) - i++ - continue - } - - if i+1 < len(content) && char == '/' && content[i+1] == '/' { - // Single-line comment - skip to end of line - for i < len(content) && content[i] != '\n' { - i++ - } - if i < len(content) { - result.WriteByte('\n') - i++ - } - continue - } - - if i+1 < len(content) && char == '/' && content[i+1] == '*' { - inMultiLineComment = true - i += 2 - continue - } - - result.WriteByte(char) - i++ - } - - return []byte(result.String()) -} - -// readJSONFile reads and parses a JSON config file -func readJSONFile(path string) (map[string]interface{}, error) { - data, err := os.ReadFile(path) - if err != nil { - if os.IsNotExist(err) { - return make(map[string]interface{}), nil - } - return nil, err - } - - // Handle empty files - if len(data) == 0 { - return make(map[string]interface{}), nil - } - - // Strip comments to support JSON5 format (used by Zed) - data = stripJSONComments(data) - - var config map[string]interface{} - if err := json.Unmarshal(data, &config); err != nil { - return nil, fmt.Errorf("failed to parse JSON: %w", err) - } - return config, nil -} - -// writeJSONFile writes a config map to a JSON file with proper formatting -func writeJSONFile(path string, config map[string]interface{}) error { - // Ensure parent directory exists - dir := filepath.Dir(path) - if err := os.MkdirAll(dir, 0755); 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, 0644); err != nil { - return fmt.Errorf("failed to write file: %w", err) - } - 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) - if err != nil { - return err - } - - // Get or create mcpServers section - mcpServers, ok := config["mcpServers"].(map[string]interface{}) - if !ok { - mcpServers = make(map[string]interface{}) - } - - // Add kernel server config - mcpServers["kernel"] = map[string]interface{}{ - "url": KernelMCPURL, - } - config["mcpServers"] = mcpServers - - return writeJSONFile(configPath, config) -} - -// installForClaude installs MCP config for Claude Desktop -func installForClaude(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{}) - } - - // Claude Desktop uses stdio transport via mcp-remote - mcpServers["kernel"] = map[string]interface{}{ - "command": "npx", - "args": []string{"-y", "mcp-remote", KernelMCPURL}, - } - config["mcpServers"] = mcpServers - - return writeJSONFile(configPath, config) -} - -// installForClaudeCode installs MCP config for Claude Code CLI -func installForClaudeCode(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{}) - } - - // Claude Code uses HTTP transport - mcpServers["kernel"] = map[string]interface{}{ - "type": "http", - "url": KernelMCPURL, - } - config["mcpServers"] = mcpServers - - 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{}) - } - // Without static client metadata, mcp-remote registers itself as "MCP CLI - // Proxy" and the consent screen asks the user to trust that rather than the - // application they are connecting. - kernel["command"] = "npx" - kernel["args"] = []string{"-y", "mcp-remote", KernelMCPURL, "--static-oauth-client-metadata", `{"client_name":"Antigravity"}`} - 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) - if err != nil { - return err - } - - // Get or create mcpServers section - mcpServers, ok := config["mcpServers"].(map[string]interface{}) - if !ok { - mcpServers = make(map[string]interface{}) - } - - // Windsurf uses stdio transport via mcp-remote - mcpServers["kernel"] = map[string]interface{}{ - "command": "npx", - "args": []string{"-y", "mcp-remote", KernelMCPURL}, - } - config["mcpServers"] = mcpServers - - return writeJSONFile(configPath, config) -} - -// installForVSCode installs MCP config for VS Code -func installForVSCode(configPath string) error { - config, err := readJSONFile(configPath) - if err != nil { - return err - } - - // Get or create mcp.servers section (VS Code uses dot notation in settings) - mcpServers, ok := config["mcp.servers"].(map[string]interface{}) - if !ok { - mcpServers = make(map[string]interface{}) - } - - // VS Code uses HTTP transport - mcpServers["kernel"] = map[string]interface{}{ - "url": KernelMCPURL, - "type": "http", - } - config["mcp.servers"] = mcpServers - - return writeJSONFile(configPath, config) -} - // installForGoose installs MCP config for Goose (YAML format) func installForGoose(configPath string) error { // For Goose, we'll output instructions since it uses YAML format @@ -436,85 +63,3 @@ func installForGoose(configPath string) error { pterm.Info.Printf("Config file location: %s\n", configPath) return nil } - -// installForZed installs MCP config for Zed -func installForZed(configPath string) error { - config, err := readJSONFile(configPath) - if err != nil { - return err - } - - // Get or create context_servers section - contextServers, ok := config["context_servers"].(map[string]interface{}) - if !ok { - contextServers = make(map[string]interface{}) - } - - // Zed uses context_servers with custom source - contextServers["kernel"] = map[string]interface{}{ - "source": "custom", - "command": "npx", - "args": []string{"-y", "mcp-remote", KernelMCPURL}, - } - config["context_servers"] = contextServers - - 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) - if err != nil { - return err - } - - switch target { - case TargetCursor: - return installForCursor(configPath) - case TargetClaude: - return installForClaude(configPath) - case TargetClaudeCode: - return installForClaudeCode(configPath) - case TargetAntigravity: - return installForAntigravity(configPath) - case TargetWindsurf: - return installForWindsurf(configPath) - case TargetVSCode: - return installForVSCode(configPath) - case TargetGoose: - return installForGoose(configPath) - case TargetZed: - return installForZed(configPath) - case TargetFx: - return installForFx(configPath) - default: - return fmt.Errorf("unsupported target: %s", target) - } -} - -// GetConfigPath returns the config path for a target (exported for display) -func GetConfigPath(target Target) (string, error) { - return getConfigPath(target) -} diff --git a/go.mod b/go.mod index dcae6b62..1444d946 100644 --- a/go.mod +++ b/go.mod @@ -17,6 +17,7 @@ require ( github.com/spf13/cobra v1.9.1 github.com/spf13/pflag v1.0.6 github.com/stretchr/testify v1.11.1 + github.com/tailscale/hujson v0.0.0-20250605163823-992244df8c5a github.com/zalando/go-keyring v0.2.6 golang.org/x/crypto v0.52.0 golang.org/x/oauth2 v0.30.0 diff --git a/go.sum b/go.sum index 1f47cec9..482a784b 100644 --- a/go.sum +++ b/go.sum @@ -54,6 +54,8 @@ github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8= github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +github.com/google/go-cmp v0.5.8 h1:e6P7q2lk1O+qJJb4BtCQXlK8vWEO8V1ZeuEdJNOqZyg= +github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4= github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ= github.com/gookit/color v1.4.2/go.mod h1:fqRyamkC1W8uxl+lxCQxOT09l/vYfZ+QeiX3rKQHCoQ= @@ -128,6 +130,8 @@ github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/ github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/tailscale/hujson v0.0.0-20250605163823-992244df8c5a h1:a6TNDN9CgG+cYjaeN8l2mc4kSz2iMiCDQxPEyltUV/I= +github.com/tailscale/hujson v0.0.0-20250605163823-992244df8c5a/go.mod h1:EbW0wDK/qEUYI0A5bqq0C2kF8JTQwWONmGDBbzsxxHo= github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= From 67bd283b965e2882c32701b085b13b6442799e9e Mon Sep 17 00:00:00 2001 From: Sayan- <1415138+Sayan-@users.noreply.github.com> Date: Wed, 23 Sep 2026 19:14:30 +0000 Subject: [PATCH 2/5] Cover MCP install merges across client targets --- cmd/mcp/config_test.go | 589 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 589 insertions(+) create mode 100644 cmd/mcp/config_test.go diff --git a/cmd/mcp/config_test.go b/cmd/mcp/config_test.go new file mode 100644 index 00000000..34b72640 --- /dev/null +++ b/cmd/mcp/config_test.go @@ -0,0 +1,589 @@ +package mcp + +import ( + "bytes" + "encoding/json" + "os" + "path/filepath" + "reflect" + "runtime" + "strings" + "testing" + + "github.com/tailscale/hujson" +) + +func testHome(t *testing.T) { + t.Helper() + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("USERPROFILE", home) + t.Setenv("APPDATA", filepath.Join(home, "AppData", "Roaming")) +} + +func readTestConfig(t *testing.T, path string) map[string]any { + t.Helper() + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + standard, err := hujson.Standardize(data) + if err != nil { + t.Fatal(err) + } + var config map[string]any + if err := json.Unmarshal(standard, &config); err != nil { + t.Fatal(err) + } + return config +} + +func TestInstallPreservesTargetConfigs(t *testing.T) { + targets := []struct { + target Target + section string + stdio bool + }{ + {TargetCursor, "mcpServers", false}, + {TargetClaude, "mcpServers", true}, + {TargetClaudeCode, "mcpServers", false}, + {TargetAntigravity, "mcpServers", true}, + {TargetWindsurf, "mcpServers", true}, + {TargetVSCode, "servers", false}, + {TargetZed, "context_servers", true}, + {TargetFx, "mcp", false}, + } + if got := AllTargets(); len(got) != len(targets)+1 { + t.Fatalf("registered targets = %d, want %d including Goose", len(got), len(targets)+1) + } + for _, tc := range targets { + t.Run(string(tc.target), func(t *testing.T) { + testHome(t) + path, err := GetConfigPath(tc.target) + if err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + t.Fatal(err) + } + seed := map[string]any{ + "unrelated": map[string]any{"nested": true}, + tc.section: map[string]any{ + "other": map[string]any{"command": "other"}, + "kernel": map[string]any{ + "headers": map[string]any{"Authorization": "Bearer example"}, + "custom": []any{"leave", "alone"}, + "url": "https://old.example", + "serverUrl": "https://old.example", + "httpUrl": "https://old.example", + "command": "old", + "args": []any{"old"}, + }, + }, + } + if tc.target == TargetFx { + seed[tc.section].(map[string]any)["kernel"].(map[string]any)["oauth"] = map[string]any{"clientId": "custom"} + } + if tc.target == TargetZed { + seed[tc.section].(map[string]any)["kernel"].(map[string]any)["source"] = "custom" + } + input, err := json.Marshal(seed) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, input, 0644); err != nil { + t.Fatal(err) + } + if err := Install(tc.target); err != nil { + t.Fatal(err) + } + config := readTestConfig(t, path) + if !reflect.DeepEqual(config["unrelated"], seed["unrelated"]) { + t.Fatalf("unrelated config changed: %#v", config["unrelated"]) + } + servers := config[tc.section].(map[string]any) + if !reflect.DeepEqual(servers["other"], seed[tc.section].(map[string]any)["other"]) { + t.Fatalf("other server changed: %#v", servers["other"]) + } + kernel := servers["kernel"].(map[string]any) + if !reflect.DeepEqual(kernel["headers"], seed[tc.section].(map[string]any)["kernel"].(map[string]any)["headers"]) { + t.Fatalf("headers changed: %#v", kernel["headers"]) + } + if !reflect.DeepEqual(kernel["custom"], []any{"leave", "alone"}) { + t.Fatalf("custom field changed: %#v", kernel["custom"]) + } + for _, stale := range []string{"serverUrl", "httpUrl"} { + if _, ok := kernel[stale]; ok { + t.Errorf("stale field %s retained", stale) + } + } + if tc.stdio { + if kernel["command"] != "npx" || kernel["url"] != nil { + t.Fatalf("stdio entry = %#v", kernel) + } + args := kernel["args"].([]any) + if args[0] != "-y" || args[1] != "mcp-remote" || args[2] != KernelMCPURL { + t.Fatalf("stdio args = %#v", args) + } + if tc.target == TargetAntigravity && (len(args) != 5 || args[3] != "--static-oauth-client-metadata") { + t.Fatalf("Antigravity args = %#v", args) + } + if tc.target == TargetZed { + if _, exists := kernel["source"]; exists { + t.Fatal("obsolete Zed source field retained") + } + } + } else { + if kernel["url"] != KernelMCPURL || kernel["command"] != nil || kernel["args"] != nil { + t.Fatalf("HTTP entry = %#v", kernel) + } + if tc.target == TargetFx { + if !reflect.DeepEqual(kernel["oauth"], map[string]any{"clientId": "custom"}) { + t.Fatalf("fx oauth = %#v", kernel["oauth"]) + } + } + } + first, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if err := Install(tc.target); err != nil { + t.Fatal(err) + } + second, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(first, second) { + t.Fatal("second install changed config bytes") + } + if runtime.GOOS != "windows" { + info, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + if info.Mode().Perm() != 0600 { + t.Fatalf("config mode = %o, want 600", info.Mode().Perm()) + } + } + }) + } +} + +func TestInstallFreshAndStrictModes(t *testing.T) { + for _, target := range AllTargets() { + if target == TargetGoose { + continue + } + t.Run(string(target), func(t *testing.T) { + testHome(t) + path, err := GetConfigPath(target) + if err != nil { + t.Fatal(err) + } + if err := Install(target); err != nil { + t.Fatal(err) + } + config := readTestConfig(t, path) + spec, _ := specFor(target) + if _, ok := config[spec.section].(map[string]any)["kernel"]; !ok { + t.Fatal("missing kernel entry") + } + if runtime.GOOS != "windows" { + info, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + if info.Mode().Perm() != 0600 { + t.Fatalf("new file mode = %o", info.Mode().Perm()) + } + dir, err := os.Stat(filepath.Dir(path)) + if err != nil { + t.Fatal(err) + } + if filepath.Dir(path) != os.Getenv("HOME") && dir.Mode().Perm() != 0700 { + t.Fatalf("new directory mode = %o", dir.Mode().Perm()) + } + if err := os.WriteFile(path, []byte("{\"unrelated\":true}"), 0600); err != nil { + t.Fatal(err) + } + if err := os.Chmod(path, 0400); err != nil { + t.Fatal(err) + } + if err := Install(target); err != nil { + t.Fatal(err) + } + info, err = os.Stat(path) + if err != nil { + t.Fatal(err) + } + if info.Mode().Perm() != 0400 { + t.Fatalf("stricter file mode = %o", info.Mode().Perm()) + } + if !readTestConfig(t, path)["unrelated"].(bool) { + t.Fatal("existing config lost") + } + } + }) + } +} + +func TestInstallPreservesCommentsAndFormatting(t *testing.T) { + for _, target := range []Target{TargetVSCode, TargetZed} { + t.Run(string(target), func(t *testing.T) { + testHome(t) + path, err := GetConfigPath(target) + if err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { + t.Fatal(err) + } + spec, _ := specFor(target) + seed := "{\n // retain this setting\n \"editor.fontSize\": 14,\n \"" + spec.section + "\": {\n // retain this server\n \"other\": {\"command\": \"other\"},\n \"kernel\": {\n // retain this field\n \"custom\": true,\n },\n },\n}\n" + if err := os.WriteFile(path, []byte(seed), 0600); err != nil { + t.Fatal(err) + } + if err := Install(target); err != nil { + t.Fatal(err) + } + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + for _, keep := range []string{"// retain this setting", "// retain this server", "// retain this field", "\"editor.fontSize\": 14"} { + if !strings.Contains(string(data), keep) { + t.Fatalf("lost %s in %s", keep, data) + } + } + readTestConfig(t, path) + }) + } +} + +func TestInstallRejectsBadConfigWithoutChangingIt(t *testing.T) { + for _, target := range AllTargets() { + if target == TargetGoose { + continue + } + t.Run(string(target), func(t *testing.T) { + testHome(t) + path, err := GetConfigPath(target) + if err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { + t.Fatal(err) + } + spec, _ := specFor(target) + bad := []string{ + "", + "{", + "null", + "{\"" + spec.section + "\": []}", + "{\"" + spec.section + "\": {\"kernel\": []}}", + "{\"" + spec.section + "\": {}, \"" + spec.section + "\": {}}", + } + for _, seed := range bad { + if err := os.WriteFile(path, []byte(seed), 0600); err != nil { + t.Fatal(err) + } + if err := Install(target); err == nil { + t.Fatalf("accepted malformed config %q", seed) + } + got, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if string(got) != seed { + t.Fatalf("modified malformed config %q", seed) + } + matches, err := filepath.Glob(filepath.Join(filepath.Dir(path), ".kernel-mcp-*")) + if err != nil || len(matches) != 0 { + t.Fatalf("temporary files after failure: %v, %v", matches, err) + } + } + }) + } +} + +func TestInstallWriteFailureLeavesExistingConfig(t *testing.T) { + testHome(t) + path, err := GetConfigPath(TargetCursor) + if err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { + t.Fatal(err) + } + seed := []byte("{\"mcpServers\": {}}") + if err := os.WriteFile(path, seed, 0600); err != nil { + t.Fatal(err) + } + // An invalid parent path fails before a temporary file can replace the original. + badPath := filepath.Join(path, "nested.json") + spec, _ := specFor(TargetCursor) + if err := installConfig(badPath, spec); err == nil { + t.Fatal("expected write failure") + } + got, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(got, seed) { + t.Fatal("existing config changed after failure") + } + failurePath := filepath.Join(t.TempDir(), "config-dir") + if err := os.Mkdir(failurePath, 0700); err != nil { + t.Fatal(err) + } + if err := writeConfigAtomic(failurePath, seed, 0600); err == nil { + t.Fatal("expected replacement failure") + } + matches, err := filepath.Glob(filepath.Join(filepath.Dir(failurePath), ".kernel-mcp-*")) + if err != nil || len(matches) != 0 { + t.Fatalf("temporary files after failed replacement: %v, %v", matches, err) + } +} + +func TestInstallSecuresUnchangedConfig(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("POSIX permissions") + } + testHome(t) + path, err := GetConfigPath(TargetCursor) + if err != nil { + t.Fatal(err) + } + if err := Install(TargetCursor); err != nil { + t.Fatal(err) + } + if err := os.Chmod(path, 0644); err != nil { + t.Fatal(err) + } + before, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if err := Install(TargetCursor); err != nil { + t.Fatal(err) + } + after, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(before, after) { + t.Fatal("unchanged config was reformatted") + } + info, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + if info.Mode().Perm() != 0600 { + t.Fatalf("config mode = %o, want 600", info.Mode().Perm()) + } +} + +func TestInstallDoesNotRewriteMatchingConfig(t *testing.T) { + testHome(t) + path, err := GetConfigPath(TargetCursor) + if err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { + t.Fatal(err) + } + seed := []byte("{\n \"mcpServers\": {\n \"kernel\": { \"url\": \"" + KernelMCPURL + "\" }\n }\n}\n") + if err := os.WriteFile(path, seed, 0600); err != nil { + t.Fatal(err) + } + before, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + if err := Install(TargetCursor); err != nil { + t.Fatal(err) + } + after, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + if !os.SameFile(before, after) { + t.Fatal("matching config was replaced") + } + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(data, seed) { + t.Fatal("matching config was reformatted") + } +} + +func TestInstallVSCodeMigratesLegacyKernelFields(t *testing.T) { + testHome(t) + path, err := GetConfigPath(TargetVSCode) + if err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { + t.Fatal(err) + } + legacyPath := filepath.Join(filepath.Dir(path), "settings.json") + legacy := []byte("{\n // leave this file untouched\n \"editor.fontSize\": 14,\n \"mcp.servers\": {\"kernel\": {\"url\": \"https://old.example\", \"headers\": {\"Authorization\": \"placeholder\"}, \"custom\": \"old\"}}\n}\n") + if err := os.WriteFile(legacyPath, legacy, 0644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte("{\"servers\":{\"other\":{\"command\":\"other\"},\"kernel\":{\"custom\":\"new\"}}}"), 0644); err != nil { + t.Fatal(err) + } + if err := Install(TargetVSCode); err != nil { + t.Fatal(err) + } + config := readTestConfig(t, path) + servers := config["servers"].(map[string]any) + kernel := servers["kernel"].(map[string]any) + if kernel["custom"] != "new" || kernel["url"] != KernelMCPURL { + t.Fatalf("kernel = %#v", kernel) + } + if kernel["headers"].(map[string]any)["Authorization"] != "placeholder" { + t.Fatalf("headers = %#v", kernel["headers"]) + } + if _, ok := servers["other"]; !ok { + t.Fatal("other server removed") + } + gotLegacy, err := os.ReadFile(legacyPath) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(gotLegacy, legacy) { + t.Fatal("legacy settings content changed") + } + if runtime.GOOS != "windows" { + for _, file := range []string{path, legacyPath} { + info, err := os.Stat(file) + if err != nil { + t.Fatal(err) + } + if info.Mode().Perm() != 0600 { + t.Fatalf("%s mode = %o", file, info.Mode().Perm()) + } + } + } +} + +func TestInstallVSCodeRejectsMalformedLegacyConfig(t *testing.T) { + testHome(t) + path, err := GetConfigPath(TargetVSCode) + if err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { + t.Fatal(err) + } + legacyPath := filepath.Join(filepath.Dir(path), "settings.json") + if err := os.WriteFile(legacyPath, []byte("{"), 0600); err != nil { + t.Fatal(err) + } + if err := Install(TargetVSCode); err == nil { + t.Fatal("expected invalid legacy settings error") + } + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Fatalf("new config changed after failure: %v", err) + } +} + +func TestInstallPreservesMCPRemoteOptions(t *testing.T) { + for _, target := range []Target{TargetClaude, TargetAntigravity, TargetWindsurf, TargetZed} { + t.Run(string(target), func(t *testing.T) { + testHome(t) + path, err := GetConfigPath(target) + if err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { + t.Fatal(err) + } + spec, _ := specFor(target) + seed := map[string]any{spec.section: map[string]any{"kernel": map[string]any{ + "command": "npx", + "args": []string{"-y", "mcp-remote@latest", "https://old.example", "--header-file", "/private/headers.txt"}, + }}} + data, err := json.Marshal(seed) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, data, 0600); err != nil { + t.Fatal(err) + } + if err := Install(target); err != nil { + t.Fatal(err) + } + config := readTestConfig(t, path) + args := config[spec.section].(map[string]any)["kernel"].(map[string]any)["args"].([]any) + if args[1] != "mcp-remote@latest" || args[2] != KernelMCPURL || args[3] != "--header-file" || args[4] != "/private/headers.txt" { + t.Fatalf("custom args lost: %#v", args) + } + if target == TargetAntigravity && (len(args) != 7 || args[5] != "--static-oauth-client-metadata") { + t.Fatalf("Antigravity metadata missing: %#v", args) + } + }) + } +} + +func TestInstallFxKeepsBearerTokenAuth(t *testing.T) { + testHome(t) + path, err := GetConfigPath(TargetFx) + if err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { + t.Fatal(err) + } + seed := []byte("{\"mcp\":{\"kernel\":{\"type\":\"http\",\"url\":\"https://old.example\",\"oauth\":{},\"bearer_token_env\":\"KERNEL_API_KEY\"}}}") + if err := os.WriteFile(path, seed, 0600); err != nil { + t.Fatal(err) + } + if err := Install(TargetFx); err != nil { + t.Fatal(err) + } + kernel := readTestConfig(t, path)["mcp"].(map[string]any)["kernel"].(map[string]any) + if kernel["bearer_token_env"] != "KERNEL_API_KEY" || kernel["url"] != KernelMCPURL { + t.Fatalf("fx auth changed: %#v", kernel) + } + if _, exists := kernel["oauth"]; exists { + t.Fatal("OAuth was retained alongside bearer token auth") + } +} + +func TestInstallRejectsSymlink(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("symlink permissions vary on Windows") + } + testHome(t) + path, err := GetConfigPath(TargetCursor) + if err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { + t.Fatal(err) + } + other := filepath.Join(t.TempDir(), "other.json") + seed := []byte("{}") + if err := os.WriteFile(other, seed, 0600); err != nil { + t.Fatal(err) + } + if err := os.Symlink(other, path); err != nil { + t.Fatal(err) + } + if err := Install(TargetCursor); err == nil { + t.Fatal("expected symlink rejection") + } + got, err := os.ReadFile(other) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(got, seed) { + t.Fatal("symlink target changed") + } +} From 6e76589d482ecefc35cf513273a52bc299868b11 Mon Sep 17 00:00:00 2001 From: Sayan- <1415138+Sayan-@users.noreply.github.com> Date: Wed, 23 Sep 2026 19:44:40 +0000 Subject: [PATCH 3/5] Clear stale Cursor transport type on MCP install --- cmd/mcp/config.go | 2 +- cmd/mcp/config_test.go | 8 ++++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/cmd/mcp/config.go b/cmd/mcp/config.go index 71f02970..7dfa0048 100644 --- a/cmd/mcp/config.go +++ b/cmd/mcp/config.go @@ -78,7 +78,7 @@ func appDataPath(home string) string { var targetSpecs = []targetSpec{ {target: TargetCursor, description: "Cursor editor", path: homePath(".cursor", "mcp.json"), section: "mcpServers", transport: http, - fields: []configField{{name: "url", value: KernelMCPURL}}}, + fields: []configField{{name: "url", value: KernelMCPURL}}, remove: []string{"type"}}, {target: TargetClaude, description: "Claude Desktop app", path: claudePath, section: "mcpServers", transport: stdio}, {target: TargetClaudeCode, description: "Claude Code CLI", path: homePath(".claude.json"), section: "mcpServers", transport: http, fields: []configField{{name: "type", value: "http"}, {name: "url", value: KernelMCPURL}}}, diff --git a/cmd/mcp/config_test.go b/cmd/mcp/config_test.go index 34b72640..f951ef04 100644 --- a/cmd/mcp/config_test.go +++ b/cmd/mcp/config_test.go @@ -78,6 +78,7 @@ func TestInstallPreservesTargetConfigs(t *testing.T) { "httpUrl": "https://old.example", "command": "old", "args": []any{"old"}, + "type": "stdio", }, }, } @@ -137,6 +138,13 @@ func TestInstallPreservesTargetConfigs(t *testing.T) { if kernel["url"] != KernelMCPURL || kernel["command"] != nil || kernel["args"] != nil { t.Fatalf("HTTP entry = %#v", kernel) } + if tc.target == TargetCursor { + if _, exists := kernel["type"]; exists { + t.Fatal("stale Cursor transport type retained") + } + } else if kernel["type"] != "http" { + t.Fatalf("HTTP transport type = %#v", kernel["type"]) + } if tc.target == TargetFx { if !reflect.DeepEqual(kernel["oauth"], map[string]any{"clientId": "custom"}) { t.Fatalf("fx oauth = %#v", kernel["oauth"]) From f0b550d7c90f0abd4d5a53dfd304adb9610941e1 Mon Sep 17 00:00:00 2001 From: Sayan- <1415138+Sayan-@users.noreply.github.com> Date: Thu, 24 Sep 2026 19:33:16 +0000 Subject: [PATCH 4/5] Validate config keys and defer legacy chmod --- cmd/mcp/config.go | 50 +++++++++++++++++++++++++++++++++++------------ 1 file changed, 38 insertions(+), 12 deletions(-) diff --git a/cmd/mcp/config.go b/cmd/mcp/config.go index 7dfa0048..589e3d3f 100644 --- a/cmd/mcp/config.go +++ b/cmd/mcp/config.go @@ -8,6 +8,7 @@ import ( "path/filepath" "runtime" "sort" + "strconv" "strings" "github.com/tailscale/hujson" @@ -163,16 +164,18 @@ func installConfig(path string, spec targetSpec) error { if err != nil { return err } + privateMode := mode & 0600 + if !bytes.Equal(data, updated) || mode != privateMode { + if err := writeConfigAtomic(path, updated, privateMode); err != nil { + return err + } + } if legacy != nil && legacyMode != legacyMode&0600 { if err := os.Chmod(legacyPath, legacyMode&0600); err != nil { return fmt.Errorf("failed to secure legacy config: %w", err) } } - privateMode := mode & 0600 - if bytes.Equal(data, updated) && mode == privateMode { - return nil - } - return writeConfigAtomic(path, updated, privateMode) + return nil } func readLegacyKernel(path, sectionKey string) (map[string]json.RawMessage, os.FileMode, error) { @@ -197,6 +200,9 @@ func readLegacyKernel(path, sectionKey string) (map[string]json.RawMessage, os.F if err != nil { return nil, 0, fmt.Errorf("failed to parse legacy config: %w", err) } + if err := validateConfigKeys(&root, ""); err != nil { + return nil, 0, err + } obj, err := objectAt(&root, "") if err != nil { return nil, 0, err @@ -253,6 +259,9 @@ func mergeConfig(data []byte, spec targetSpec, legacy map[string]json.RawMessage if err != nil { return nil, fmt.Errorf("failed to parse config: %w", err) } + if err := validateConfigKeys(&root, ""); err != nil { + return nil, err + } section, err := objectAt(&root, "") if err != nil { return nil, err @@ -408,15 +417,32 @@ func objectAt(root *hujson.Value, path string) (*hujson.Object, error) { if !ok { return nil, fmt.Errorf("expected config object at %s", path) } - seen := make(map[string]bool, len(obj.Members)) - for _, item := range obj.Members { - name := item.Name.Value.(hujson.Literal).String() - if seen[name] { - return nil, fmt.Errorf("duplicate config key %q at %s", name, path) + return obj, nil +} + +func validateConfigKeys(value *hujson.Value, path string) error { + switch node := value.Value.(type) { + case *hujson.Object: + seen := make(map[string]bool, len(node.Members)) + for i := range node.Members { + item := &node.Members[i] + name := item.Name.Value.(hujson.Literal).String() + if seen[name] { + return fmt.Errorf("duplicate config key %q at %s", name, path) + } + seen[name] = true + if err := validateConfigKeys(&item.Value, path+"/"+pointerName(name)); err != nil { + return err + } + } + case *hujson.Array: + for i := range node.Elements { + if err := validateConfigKeys(&node.Elements[i], path+"/"+strconv.Itoa(i)); err != nil { + return err + } } - seen[name] = true } - return obj, nil + return nil } func member(obj *hujson.Object, name string) (*hujson.Value, bool) { From 4d111d32b408e49e465ba980cc3d58cf2656ad72 Mon Sep 17 00:00:00 2001 From: Sayan- <1415138+Sayan-@users.noreply.github.com> Date: Thu, 24 Sep 2026 19:33:16 +0000 Subject: [PATCH 5/5] Cover duplicate keys and failed legacy migration --- cmd/mcp/config_test.go | 117 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 117 insertions(+) diff --git a/cmd/mcp/config_test.go b/cmd/mcp/config_test.go index f951ef04..694933d0 100644 --- a/cmd/mcp/config_test.go +++ b/cmd/mcp/config_test.go @@ -315,6 +315,37 @@ func TestInstallRejectsBadConfigWithoutChangingIt(t *testing.T) { } } +func TestInstallRejectsNestedDuplicateKeys(t *testing.T) { + for _, tc := range []struct{ name, config string }{ + {"kernel headers", `{"mcpServers":{"kernel":{"headers":{"Authorization":"first","Authorization":"second"}}}}`}, + {"object in array", `{"other":[{"Authorization":"first","Authorization":"second"}]}`}, + } { + t.Run(tc.name, func(t *testing.T) { + testHome(t) + path, err := GetConfigPath(TargetCursor) + if err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(tc.config), 0600); err != nil { + t.Fatal(err) + } + if err := Install(TargetCursor); err == nil || !strings.Contains(err.Error(), `duplicate config key "Authorization"`) { + t.Fatalf("install error = %v, want nested duplicate key", err) + } + got, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if string(got) != tc.config { + t.Fatal("duplicate-key config changed") + } + }) + } +} + func TestInstallWriteFailureLeavesExistingConfig(t *testing.T) { testHome(t) path, err := GetConfigPath(TargetCursor) @@ -501,6 +532,92 @@ func TestInstallVSCodeRejectsMalformedLegacyConfig(t *testing.T) { } } +func TestInstallVSCodeRejectsNestedDuplicateLegacyKeys(t *testing.T) { + testHome(t) + path, err := GetConfigPath(TargetVSCode) + if err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { + t.Fatal(err) + } + legacyPath := filepath.Join(filepath.Dir(path), "settings.json") + legacy := []byte(`{"mcp.servers":{"kernel":{"headers":{"Authorization":"first","Authorization":"second"}}}}`) + if err := os.WriteFile(legacyPath, legacy, 0600); err != nil { + t.Fatal(err) + } + if err := Install(TargetVSCode); err == nil || !strings.Contains(err.Error(), `duplicate config key "Authorization"`) { + t.Fatalf("install error = %v, want nested duplicate key", err) + } + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Fatalf("destination changed after failure: %v", err) + } + got, err := os.ReadFile(legacyPath) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(got, legacy) { + t.Fatal("legacy config changed after failure") + } +} + +func TestInstallVSCodeWriteFailureDoesNotChmodLegacy(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("directory permissions vary on Windows") + } + testHome(t) + path, err := GetConfigPath(TargetVSCode) + if err != nil { + t.Fatal(err) + } + dir := filepath.Dir(path) + if err := os.MkdirAll(dir, 0700); err != nil { + t.Fatal(err) + } + legacyPath := filepath.Join(dir, "settings.json") + legacy := []byte(`{"mcp.servers":{"kernel":{"headers":{"Authorization":"placeholder"}}}}`) + if err := os.WriteFile(legacyPath, legacy, 0644); err != nil { + t.Fatal(err) + } + if err := os.Chmod(dir, 0500); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if err := os.Chmod(dir, 0700); err != nil { + t.Error(err) + } + }) + probe, err := os.CreateTemp(dir, "probe-*") + if err == nil { + probe.Close() + os.Remove(probe.Name()) + t.Skip("directory permissions do not prevent writes") + } + if !os.IsPermission(err) { + t.Fatalf("permission probe failed: %v", err) + } + if err := Install(TargetVSCode); err == nil { + t.Fatal("expected destination write failure") + } + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Fatalf("destination changed after failure: %v", err) + } + got, err := os.ReadFile(legacyPath) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(got, legacy) { + t.Fatal("legacy config content changed") + } + info, err := os.Stat(legacyPath) + if err != nil { + t.Fatal(err) + } + if info.Mode().Perm() != 0644 { + t.Fatalf("legacy config mode = %o, want 644", info.Mode().Perm()) + } +} + func TestInstallPreservesMCPRemoteOptions(t *testing.T) { for _, target := range []Target{TargetClaude, TargetAntigravity, TargetWindsurf, TargetZed} { t.Run(string(target), func(t *testing.T) {