diff --git a/cmd/mcp/install.go b/cmd/mcp/install.go new file mode 100644 index 0000000..2812962 --- /dev/null +++ b/cmd/mcp/install.go @@ -0,0 +1,139 @@ +package mcp + +import ( + "fmt" + "strings" + + "github.com/pterm/pterm" + "github.com/spf13/cobra" +) + +var installCmd = &cobra.Command{ + Use: "install", + Short: "Install Kernel MCP server configuration for an AI tool", + 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 + windsurf - Windsurf editor + vscode - Visual Studio Code + goose - Goose AI + zed - Zed editor + +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`, + RunE: runInstall, +} + +func init() { + MCPCmd.AddCommand(installCmd) + + // Build target list for help text + targets := AllTargets() + targetStrs := make([]string, len(targets)) + for i, t := range targets { + targetStrs[i] = string(t) + } + + installCmd.Flags().String("target", "", fmt.Sprintf("Target AI tool (%s)", strings.Join(targetStrs, ", "))) + _ = installCmd.MarkFlagRequired("target") +} + +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 { + targets := AllTargets() + targetStrs := make([]string, len(targets)) + for i, t := range targets { + targetStrs[i] = string(t) + } + return fmt.Errorf("invalid target '%s'. Supported targets: %s", targetStr, strings.Join(targetStrs, ", ")) + } + + // Get the config path for display + configPath, err := GetConfigPath(target) + if err != nil { + return fmt.Errorf("failed to determine config path: %w", err) + } + + // Install the MCP configuration + if err := Install(target); err != nil { + return fmt.Errorf("failed to install MCP configuration: %w", err) + } + + // For Goose, the install function already printed instructions + if target == TargetGoose { + return nil + } + + pterm.Success.Printf("MCP server successfully configured for %s at %s\n", target, configPath) + + // Print post-install instructions based on target + printPostInstallInstructions(target) + + return nil +} + +func printPostInstallInstructions(target Target) { + pterm.Println() + + switch target { + case TargetCursor: + pterm.Info.Println("Next steps:") + pterm.Println(" 1. Restart Cursor or reload the window") + pterm.Println(" 2. The Kernel MCP server will appear in your tools") + pterm.Println(" 3. You'll be prompted to authenticate when first using Kernel tools") + + case TargetClaude: + pterm.Info.Println("Next steps:") + pterm.Println(" 1. Restart Claude Desktop") + pterm.Println(" 2. The Kernel tools will be available in your conversations") + pterm.Println(" 3. You'll be prompted to authenticate when first using Kernel tools") + + case TargetClaudeCode: + pterm.Info.Println("Next steps:") + pterm.Println(" 1. Run '/mcp' in the Claude Code REPL to authenticate") + pterm.Println(" 2. The Kernel tools will then be available") + + case TargetWindsurf: + pterm.Info.Println("Next steps:") + pterm.Println(" 1. Open Windsurf settings and navigate to MCP servers") + pterm.Println(" 2. Click 'Refresh' to load the Kernel MCP server") + pterm.Println(" 3. You'll be prompted to authenticate when first using Kernel tools") + + case TargetVSCode: + pterm.Info.Println("Next steps:") + pterm.Println(" 1. Restart VS Code or reload the window") + pterm.Println(" 2. The Kernel MCP server will be available") + pterm.Println(" 3. You'll be prompted to authenticate when first using Kernel tools") + + case TargetZed: + pterm.Info.Println("Next steps:") + 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") + } +} diff --git a/cmd/mcp/mcp.go b/cmd/mcp/mcp.go new file mode 100644 index 0000000..c698309 --- /dev/null +++ b/cmd/mcp/mcp.go @@ -0,0 +1,427 @@ +package mcp + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "runtime" + "strings" + + "github.com/pterm/pterm" + "github.com/spf13/cobra" +) + +// MCPCmd is the parent command for MCP operations +var MCPCmd = &cobra.Command{ + Use: "mcp", + Short: "Configure Kernel MCP server for AI tools", + Long: "Commands for configuring the Kernel MCP server in AI development tools like Cursor, Claude, VS Code, and more.", + Run: func(cmd *cobra.Command, args []string) { + // If called without subcommands, show help + _ = cmd.Help() + }, +} + +// Target represents a supported MCP client target +type Target string + +const ( + TargetCursor Target = "cursor" + TargetClaude Target = "claude" + TargetClaudeCode Target = "claude-code" + TargetWindsurf Target = "windsurf" + TargetVSCode Target = "vscode" + TargetGoose Target = "goose" + TargetZed Target = "zed" +) + +// 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, + TargetWindsurf, + TargetVSCode, + TargetGoose, + TargetZed, + } +} + +// 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 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 + default: + return "", fmt.Errorf("unsupported target: %s", target) + } +} + +// MCPServerConfig represents the configuration for an MCP server +type MCPServerConfig struct { + URL string `json:"url,omitempty"` + Command string `json:"command,omitempty"` + Args []string `json:"args,omitempty"` + 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 +} + +// 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) +} + +// 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 + // and we don't want to add a YAML dependency + pterm.Info.Println("Goose uses YAML configuration. Add the following to your Goose config:") + pterm.Println() + fmt.Println(`extensions: + kernel: + name: Kernel + type: stdio + cmd: npx + args: + - -y + - mcp-remote + - ` + KernelMCPURL) + pterm.Println() + 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) +} + +// 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 TargetWindsurf: + return installForWindsurf(configPath) + case TargetVSCode: + return installForVSCode(configPath) + case TargetGoose: + return installForGoose(configPath) + case TargetZed: + return installForZed(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/cmd/mcp/server.go b/cmd/mcp/server.go new file mode 100644 index 0000000..e1885d3 --- /dev/null +++ b/cmd/mcp/server.go @@ -0,0 +1,55 @@ +package mcp + +import ( + "github.com/pterm/pterm" + "github.com/spf13/cobra" +) + +var serverCmd = &cobra.Command{ + Use: "server", + Short: "Display information about the Kernel MCP server", + Long: `Display information about the Kernel MCP server. + +The Kernel MCP server is hosted remotely and does not need to be started locally. +This command provides connection details and documentation links. + +For local development or debugging, you can connect to the MCP server at: + ` + KernelMCPURL + ` + +The server supports both HTTP transport (recommended) and stdio via mcp-remote.`, + Run: runServer, +} + +func init() { + MCPCmd.AddCommand(serverCmd) +} + +func runServer(cmd *cobra.Command, args []string) { + pterm.DefaultHeader.Println("Kernel MCP Server") + pterm.Println() + + pterm.Info.Println("The Kernel MCP server is hosted remotely and does not need to be started locally.") + pterm.Println() + + pterm.DefaultSection.Println("Connection Details") + + rows := pterm.TableData{ + {"Transport", "URL / Command"}, + {"HTTP (recommended)", KernelMCPURL}, + {"stdio (via mcp-remote)", "npx -y mcp-remote " + KernelMCPURL}, + } + _ = pterm.DefaultTable.WithHasHeader().WithData(rows).Render() + + pterm.Println() + pterm.DefaultSection.Println("Quick Install") + pterm.Println(" kernel mcp install --target cursor") + pterm.Println(" kernel mcp install --target claude") + pterm.Println(" kernel mcp install --target vscode") + + pterm.Println() + pterm.DefaultSection.Println("Documentation") + pterm.Println(" https://onkernel.com/docs/reference/mcp-server") + + pterm.Println() + pterm.Info.Println("Use 'kernel mcp install --target ' to configure your AI tool automatically.") +} diff --git a/cmd/root.go b/cmd/root.go index e6b44a4..1b4a6b3 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -11,6 +11,7 @@ import ( "github.com/charmbracelet/fang" "github.com/charmbracelet/lipgloss/v2" + "github.com/onkernel/cli/cmd/mcp" "github.com/onkernel/cli/cmd/proxies" "github.com/onkernel/cli/pkg/auth" "github.com/onkernel/cli/pkg/update" @@ -89,7 +90,7 @@ func isAuthExempt(cmd *cobra.Command) bool { // Check if the top-level command is in the exempt list switch topLevel.Name() { - case "login", "logout", "auth", "help", "completion", "create": + case "login", "logout", "auth", "help", "completion", "create", "mcp": return true } @@ -139,6 +140,7 @@ func init() { rootCmd.AddCommand(proxies.ProxiesCmd) rootCmd.AddCommand(extensionsCmd) rootCmd.AddCommand(createCmd) + rootCmd.AddCommand(mcp.MCPCmd) rootCmd.PersistentPostRunE = func(cmd *cobra.Command, args []string) error { // running synchronously so we never slow the command