From efe8fd632d2bb81b713d79bd1245a2265c496e9f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fatih=20Kadir=20Ak=C4=B1n?= Date: Tue, 8 Apr 2025 21:01:03 +0300 Subject: [PATCH 1/7] init configs --- cmd/mcptools/commands/configs.go | 803 +++++++++++++++++++++++++++++++ cmd/mcptools/main.go | 1 + 2 files changed, 804 insertions(+) create mode 100644 cmd/mcptools/commands/configs.go diff --git a/cmd/mcptools/commands/configs.go b/cmd/mcptools/commands/configs.go new file mode 100644 index 0000000..d45d6d9 --- /dev/null +++ b/cmd/mcptools/commands/configs.go @@ -0,0 +1,803 @@ +package commands + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/spf13/cobra" +) + +// ConfigFileOption stores the path to the configuration file +var ConfigFileOption string + +// HeadersOption stores the headers for URL-based servers +var HeadersOption string + +// EnvOption stores the environment variables +var EnvOption string + +// URLOption stores the URL for URL-based servers +var URLOption string + +// ConfigAlias represents a configuration alias +type ConfigAlias struct { + Path string `json:"path"` + JSONPath string `json:"jsonPath"` + Source string `json:"source,omitempty"` +} + +// ConfigsFile represents the structure of the configs file +type ConfigsFile struct { + Aliases map[string]ConfigAlias `json:"aliases"` +} + +// getConfigsFilePath returns the path to the configs file +func getConfigsFilePath() (string, error) { + homeDir, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("failed to get user home directory: %w", err) + } + + configDir := filepath.Join(homeDir, ".mcpt") + if err := os.MkdirAll(configDir, 0755); err != nil { + return "", fmt.Errorf("failed to create config directory: %w", err) + } + + return filepath.Join(configDir, "configs.json"), nil +} + +// loadConfigsFile loads the configs file, creating it if it doesn't exist +func loadConfigsFile() (*ConfigsFile, error) { + configsPath, err := getConfigsFilePath() + if err != nil { + return nil, err + } + + // Create default config if file doesn't exist + if _, err := os.Stat(configsPath); os.IsNotExist(err) { + defaultConfig := &ConfigsFile{ + Aliases: map[string]ConfigAlias{ + "vscode": { + Path: "~/Library/Application Support/Code/User/settings.json", + JSONPath: "$.mcp.servers", + Source: "VS Code", + }, + "vscode-insiders": { + Path: "~/Library/Application Support/Code - Insiders/User/settings.json", + JSONPath: "$.mcp.servers", + Source: "VS Code Insiders", + }, + "windsurf": { + Path: "~/.codeium/windsurf/mcp_config.json", + JSONPath: "$.mcpServers", + Source: "Windsurf", + }, + "cursor": { + Path: "~/.cursor/mcp.json", + JSONPath: "$.mcpServers", + Source: "Cursor", + }, + "claude-desktop": { + Path: "~/Library/Application Support/Claude/claude_desktop_config.json", + JSONPath: "$.mcpServers", + Source: "Claude Desktop", + }, + "claude-code": { + Path: "~/.claude.json", + JSONPath: "$.mcpServers", + Source: "Claude Code", + }, + }, + } + + data, err := json.MarshalIndent(defaultConfig, "", " ") + if err != nil { + return nil, fmt.Errorf("failed to marshal default config: %w", err) + } + + if err := os.WriteFile(configsPath, data, 0644); err != nil { + return nil, fmt.Errorf("failed to write default config: %w", err) + } + + return defaultConfig, nil + } + + // Read existing config + data, err := os.ReadFile(configsPath) + if err != nil { + return nil, fmt.Errorf("failed to read config file: %w", err) + } + + var config ConfigsFile + if err := json.Unmarshal(data, &config); err != nil { + return nil, fmt.Errorf("failed to parse config file: %w", err) + } + + // Initialize map if nil + if config.Aliases == nil { + config.Aliases = make(map[string]ConfigAlias) + } + + return &config, nil +} + +// saveConfigsFile saves the configs file +func saveConfigsFile(config *ConfigsFile) error { + configsPath, err := getConfigsFilePath() + if err != nil { + return err + } + + data, err := json.MarshalIndent(config, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal config: %w", err) + } + + return os.WriteFile(configsPath, data, 0644) +} + +// expandPath expands the ~ in the path +func expandPath(path string) string { + if !strings.HasPrefix(path, "~") { + return path + } + + homeDir, err := os.UserHomeDir() + if err != nil { + return path + } + + return filepath.Join(homeDir, path[1:]) +} + +// parseKeyValueOption parses a comma-separated list of key=value pairs +func parseKeyValueOption(option string) (map[string]string, error) { + result := make(map[string]string) + if option == "" { + return result, nil + } + + pairs := strings.Split(option, ",") + for _, pair := range pairs { + kv := strings.SplitN(pair, "=", 2) + if len(kv) != 2 { + return nil, fmt.Errorf("invalid key-value pair: %s", pair) + } + result[strings.TrimSpace(kv[0])] = strings.TrimSpace(kv[1]) + } + + return result, nil +} + +// getConfigFileAndPath gets the config file path and json path from an alias or direct file path +func getConfigFileAndPath(configs *ConfigsFile, aliasName, configFile string) (string, string, error) { + var jsonPath string + if configFile == "" { + // Check if the name is an alias + aliasConfig, ok := configs.Aliases[strings.ToLower(aliasName)] + if !ok { + return "", "", fmt.Errorf("alias '%s' not found and no config file specified", aliasName) + } + configFile = aliasConfig.Path + jsonPath = aliasConfig.JSONPath + } else { + // Default JSON path if using direct file path + jsonPath = "$.mcpServers" + } + + // Expand the path if needed + configFile = expandPath(configFile) + return configFile, jsonPath, nil +} + +// readConfigFile reads and parses a config file +func readConfigFile(configFile string) (map[string]interface{}, error) { + var configData map[string]interface{} + if _, err := os.Stat(configFile); err == nil { + // File exists, read and parse it + data, err := os.ReadFile(configFile) + if err != nil { + return nil, fmt.Errorf("failed to read config file: %w", err) + } + + if err := json.Unmarshal(data, &configData); err != nil { + // Create new empty config if file exists but isn't valid JSON + configData = make(map[string]interface{}) + } + } else { + // Create new empty config if file doesn't exist + configData = make(map[string]interface{}) + } + return configData, nil +} + +// addServerToConfig adds a server configuration to the config data +func addServerToConfig(configData map[string]interface{}, jsonPath, serverName string, serverConfig map[string]interface{}) { + if strings.Contains(jsonPath, "mcp.servers") { + // VS Code format + if _, ok := configData["mcp"]; !ok { + configData["mcp"] = map[string]interface{}{} + } + + mcpMap, ok := configData["mcp"].(map[string]interface{}) + if !ok { + mcpMap = map[string]interface{}{} + configData["mcp"] = mcpMap + } + + if _, ok := mcpMap["servers"]; !ok { + mcpMap["servers"] = map[string]interface{}{} + } + + serversMap, ok := mcpMap["servers"].(map[string]interface{}) + if !ok { + serversMap = map[string]interface{}{} + mcpMap["servers"] = serversMap + } + + serversMap[serverName] = serverConfig + } else { + // Other formats with mcpServers + if _, ok := configData["mcpServers"]; !ok { + configData["mcpServers"] = map[string]interface{}{} + } + + serversMap, ok := configData["mcpServers"].(map[string]interface{}) + if !ok { + serversMap = map[string]interface{}{} + configData["mcpServers"] = serversMap + } + + serversMap[serverName] = serverConfig + } +} + +// getServerFromConfig gets a server configuration from the config data +func getServerFromConfig(configData map[string]interface{}, jsonPath, serverName string) (map[string]interface{}, bool) { + if strings.Contains(jsonPath, "mcp.servers") { + // VS Code format + mcpMap, ok := configData["mcp"].(map[string]interface{}) + if !ok { + return nil, false + } + + serversMap, ok := mcpMap["servers"].(map[string]interface{}) + if !ok { + return nil, false + } + + server, ok := serversMap[serverName].(map[string]interface{}) + return server, ok + } else { + // Other formats with mcpServers + serversMap, ok := configData["mcpServers"].(map[string]interface{}) + if !ok { + return nil, false + } + + server, ok := serversMap[serverName].(map[string]interface{}) + return server, ok + } +} + +// removeServerFromConfig removes a server configuration from the config data +func removeServerFromConfig(configData map[string]interface{}, jsonPath, serverName string) bool { + if strings.Contains(jsonPath, "mcp.servers") { + // VS Code format + mcpMap, ok := configData["mcp"].(map[string]interface{}) + if !ok { + return false + } + + serversMap, ok := mcpMap["servers"].(map[string]interface{}) + if !ok { + return false + } + + if _, exists := serversMap[serverName]; !exists { + return false + } + + delete(serversMap, serverName) + return true + } else { + // Other formats with mcpServers + serversMap, ok := configData["mcpServers"].(map[string]interface{}) + if !ok { + return false + } + + if _, exists := serversMap[serverName]; !exists { + return false + } + + delete(serversMap, serverName) + return true + } +} + +// ConfigsCmd creates the configs command +func ConfigsCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "configs", + Short: "Manage MCP server configurations", + Long: `Manage MCP server configurations including scanning, adding, and aliasing.`, + } + + // Add the view subcommand with AllOption flag + var AllOption bool + viewCmd := &cobra.Command{ + Use: "view [alias or path]", + Short: "View MCP servers in configurations", + Long: `View MCP servers in a specific configuration file or all configured aliases with --all flag.`, + Args: cobra.MaximumNArgs(1), + Run: func(cmd *cobra.Command, args []string) { + // Load configs + configs, err := loadConfigsFile() + if err != nil { + fmt.Fprintf(cmd.ErrOrStderr(), "Error loading configs: %v\n", err) + return + } + + var servers []ServerConfig + + // All mode - scan all aliases (same as previous scan command) + if AllOption { + if len(args) > 0 { + fmt.Fprintf(cmd.ErrOrStderr(), "Warning: ignoring specified alias/path when using --all flag\n") + } + + // Scan all configured aliases + for alias, config := range configs.Aliases { + // Skip if path is empty + if config.Path == "" { + continue + } + + // Determine the scanner function based on the JSONPath + expandedPath := expandPath(config.Path) + var configServers []ServerConfig + var scanErr error + + source := config.Source + if source == "" { + source = strings.Title(alias) // Use capitalized alias name if source not provided + } + + if strings.Contains(config.JSONPath, "mcp.servers") { + configServers, scanErr = scanVSCodeConfig(expandedPath, source) + } else if strings.Contains(config.JSONPath, "mcpServers") { + configServers, scanErr = scanMCPServersConfig(expandedPath, source) + } + + if scanErr == nil { + servers = append(servers, configServers...) + } + } + } else { + // Single config mode - require an argument + if len(args) == 0 { + fmt.Fprintf(cmd.ErrOrStderr(), "Error: must specify an alias or path, use --all flag, or use `configs ls` command\n") + return + } + + target := args[0] + var configPath string + var source string + var jsonPath string + + // Check if the argument is an alias + if aliasConfig, ok := configs.Aliases[strings.ToLower(target)]; ok { + configPath = aliasConfig.Path + source = aliasConfig.Source + if source == "" { + source = strings.Title(target) + } + jsonPath = aliasConfig.JSONPath + } else { + // Assume it's a direct path + configPath = target + source = filepath.Base(target) + jsonPath = "$.mcpServers" // Default JSON path + } + + // Expand the path if needed + expandedPath := expandPath(configPath) + + // Scan the config file + var configServers []ServerConfig + var scanErr error + + if strings.Contains(jsonPath, "mcp.servers") { + configServers, scanErr = scanVSCodeConfig(expandedPath, source) + } else { + configServers, scanErr = scanMCPServersConfig(expandedPath, source) + } + + if scanErr != nil { + fmt.Fprintf(cmd.ErrOrStderr(), "Error scanning configuration: %v\n", scanErr) + return + } + + servers = configServers + } + + // Handle empty results + if len(servers) == 0 { + fmt.Fprintln(cmd.OutOrStdout(), "No MCP servers found") + return + } + + // Output based on format + if strings.ToLower(FormatOption) == "table" || strings.ToLower(FormatOption) == "pretty" { + output := formatColoredGroupedServers(servers) + fmt.Fprintln(cmd.OutOrStdout(), output) + return + } + + if strings.ToLower(FormatOption) == "json" { + output := formatSourceGroupedJSON(servers) + fmt.Fprintln(cmd.OutOrStdout(), output) + return + } + + // For other formats, use the full server data + output, err := json.MarshalIndent(servers, "", " ") + if err != nil { + fmt.Fprintf(cmd.ErrOrStderr(), "Error formatting output: %v\n", err) + return + } + + fmt.Fprintln(cmd.OutOrStdout(), string(output)) + }, + } + + // Add --all flag to view command + viewCmd.Flags().BoolVar(&AllOption, "all", false, "View all configured aliases") + + // Add ls command as an alias for view --all + lsCmd := &cobra.Command{ + Use: "ls [alias or path]", + Short: "List all MCP servers in configurations (alias for view --all)", + Long: `List all MCP servers in configurations. If a path is specified, only show that configuration.`, + Args: cobra.MaximumNArgs(1), + Run: func(cmd *cobra.Command, args []string) { + // Set AllOption to true to make this default to --all + AllOption = true + + // Run the view command logic + viewCmd.Run(cmd, args) + }, + } + + // Add --all flag to ls command (though it's true by default) + lsCmd.Flags().BoolVar(&AllOption, "all", false, "View all configured aliases (default: false)") + + // Add the add subcommand + addCmd := &cobra.Command{ + Use: "add [alias] [server] [command/url] [args...]", + Short: "Add a new MCP server configuration", + Long: `Add a new MCP server configuration using either an alias or direct file path. For URL-based servers, use --url flag.`, + Args: cobra.MinimumNArgs(2), + Run: func(cmd *cobra.Command, args []string) { + // Load configs + configs, err := loadConfigsFile() + if err != nil { + fmt.Fprintf(cmd.ErrOrStderr(), "Error loading configs: %v\n", err) + return + } + + // Get the alias/config file and server name + aliasName := args[0] + serverName := args[1] + + // Get config file and JSON path from alias or direct path + configFile, jsonPath, err := getConfigFileAndPath(configs, aliasName, ConfigFileOption) + if err != nil { + fmt.Fprintf(cmd.ErrOrStderr(), "Error: %v\n", err) + return + } + + // Read the target config file + configData, err := readConfigFile(configFile) + if err != nil { + fmt.Fprintf(cmd.ErrOrStderr(), "Error: %v\n", err) + return + } + + // Create server config + serverConfig := make(map[string]interface{}) + + // Determine if this is a URL-based or command-based server + if URLOption != "" { + // URL-based server + serverConfig["url"] = URLOption + + // Parse headers + if HeadersOption != "" { + headers, err := parseKeyValueOption(HeadersOption) + if err != nil { + fmt.Fprintf(cmd.ErrOrStderr(), "Error parsing headers: %v\n", err) + return + } + if len(headers) > 0 { + serverConfig["headers"] = headers + } + } + } else if len(args) > 2 { + // Command-based server + command := args[2] + serverConfig["command"] = command + + // Add command args if provided + if len(args) > 3 { + serverConfig["args"] = args[3:] + } + } else { + fmt.Fprintf(cmd.ErrOrStderr(), "Error: either command or --url must be provided\n") + return + } + + // Parse environment variables for both URL and command servers + if EnvOption != "" { + env, err := parseKeyValueOption(EnvOption) + if err != nil { + fmt.Fprintf(cmd.ErrOrStderr(), "Error parsing environment variables: %v\n", err) + return + } + if len(env) > 0 { + serverConfig["env"] = env + } + } + + // Add the server to the config + addServerToConfig(configData, jsonPath, serverName, serverConfig) + + // Write the updated config back to the file + data, err := json.MarshalIndent(configData, "", " ") + if err != nil { + fmt.Fprintf(cmd.ErrOrStderr(), "Error marshaling config: %v\n", err) + return + } + + if err := os.WriteFile(configFile, data, 0644); err != nil { + fmt.Fprintf(cmd.ErrOrStderr(), "Error writing config file: %v\n", err) + return + } + + fmt.Fprintf(cmd.OutOrStdout(), "Server '%s' added to %s\n", serverName, configFile) + }, + } + + // Add the update subcommand + updateCmd := &cobra.Command{ + Use: "update [alias] [server] [command/url] [args...]", + Short: "Update an existing MCP server configuration", + Long: `Update an existing MCP server configuration. For URL-based servers, use --url flag.`, + Args: cobra.MinimumNArgs(2), + Run: func(cmd *cobra.Command, args []string) { + // Load configs + configs, err := loadConfigsFile() + if err != nil { + fmt.Fprintf(cmd.ErrOrStderr(), "Error loading configs: %v\n", err) + return + } + + // Get the alias/config file and server name + aliasName := args[0] + serverName := args[1] + + // Get config file and JSON path from alias or direct path + configFile, jsonPath, err := getConfigFileAndPath(configs, aliasName, ConfigFileOption) + if err != nil { + fmt.Fprintf(cmd.ErrOrStderr(), "Error: %v\n", err) + return + } + + // Read the target config file + configData, err := readConfigFile(configFile) + if err != nil { + fmt.Fprintf(cmd.ErrOrStderr(), "Error: %v\n", err) + return + } + + // Check if the server exists + existingServer, exists := getServerFromConfig(configData, jsonPath, serverName) + if !exists { + fmt.Fprintf(cmd.ErrOrStderr(), "Error: server '%s' not found in %s\n", serverName, configFile) + return + } + + // Create server config starting with existing values + serverConfig := existingServer + if serverConfig == nil { + serverConfig = make(map[string]interface{}) + } + + // Determine if this is a URL-based or command-based server update + if URLOption != "" { + // URL-based server - remove command and args if they exist + delete(serverConfig, "command") + delete(serverConfig, "args") + + // Set the URL + serverConfig["url"] = URLOption + + // Parse headers + if HeadersOption != "" { + headers, err := parseKeyValueOption(HeadersOption) + if err != nil { + fmt.Fprintf(cmd.ErrOrStderr(), "Error parsing headers: %v\n", err) + return + } + if len(headers) > 0 { + serverConfig["headers"] = headers + } + } + } else if len(args) > 2 { + // Command-based server - remove url and headers if they exist + delete(serverConfig, "url") + delete(serverConfig, "headers") + + // Set the command + command := args[2] + serverConfig["command"] = command + + // Add command args if provided + if len(args) > 3 { + serverConfig["args"] = args[3:] + } else { + delete(serverConfig, "args") + } + } else { + fmt.Fprintf(cmd.ErrOrStderr(), "Error: either command or --url must be provided\n") + return + } + + // Parse environment variables + if EnvOption != "" { + env, err := parseKeyValueOption(EnvOption) + if err != nil { + fmt.Fprintf(cmd.ErrOrStderr(), "Error parsing environment variables: %v\n", err) + return + } + if len(env) > 0 { + serverConfig["env"] = env + } + } + + // Update the server in the config + addServerToConfig(configData, jsonPath, serverName, serverConfig) + + // Write the updated config back to the file + data, err := json.MarshalIndent(configData, "", " ") + if err != nil { + fmt.Fprintf(cmd.ErrOrStderr(), "Error marshaling config: %v\n", err) + return + } + + if err := os.WriteFile(configFile, data, 0644); err != nil { + fmt.Fprintf(cmd.ErrOrStderr(), "Error writing config file: %v\n", err) + return + } + + fmt.Fprintf(cmd.OutOrStdout(), "Server '%s' updated in %s\n", serverName, configFile) + }, + } + + // Add the remove subcommand + removeCmd := &cobra.Command{ + Use: "remove [alias] [server]", + Short: "Remove an MCP server configuration", + Long: `Remove an MCP server configuration from a config file.`, + Args: cobra.ExactArgs(2), + Run: func(cmd *cobra.Command, args []string) { + // Load configs + configs, err := loadConfigsFile() + if err != nil { + fmt.Fprintf(cmd.ErrOrStderr(), "Error loading configs: %v\n", err) + return + } + + // Get the alias/config file and server name + aliasName := args[0] + serverName := args[1] + + // Get config file and JSON path from alias or direct path + configFile, jsonPath, err := getConfigFileAndPath(configs, aliasName, ConfigFileOption) + if err != nil { + fmt.Fprintf(cmd.ErrOrStderr(), "Error: %v\n", err) + return + } + + // Read the target config file + configData, err := readConfigFile(configFile) + if err != nil { + fmt.Fprintf(cmd.ErrOrStderr(), "Error: %v\n", err) + return + } + + // Remove the server + removed := removeServerFromConfig(configData, jsonPath, serverName) + if !removed { + fmt.Fprintf(cmd.ErrOrStderr(), "Error: server '%s' not found in %s\n", serverName, configFile) + return + } + + // Write the updated config back to the file + data, err := json.MarshalIndent(configData, "", " ") + if err != nil { + fmt.Fprintf(cmd.ErrOrStderr(), "Error marshaling config: %v\n", err) + return + } + + if err := os.WriteFile(configFile, data, 0644); err != nil { + fmt.Fprintf(cmd.ErrOrStderr(), "Error writing config file: %v\n", err) + return + } + + fmt.Fprintf(cmd.OutOrStdout(), "Server '%s' removed from %s\n", serverName, configFile) + }, + } + + // Add the alias subcommand + aliasCmd := &cobra.Command{ + Use: "alias [name] [path] [jsonPath]", + Short: "Create an alias for a config file", + Long: `Create an alias for a configuration file with the specified JSONPath (defaults to "$.mcpServers").`, + Args: cobra.MinimumNArgs(2), + Run: func(cmd *cobra.Command, args []string) { + name := args[0] + path := args[1] + jsonPath := "$.mcpServers" // Default value + + // Use custom jsonPath if provided + if len(args) > 2 { + jsonPath = args[2] + } + + // Load configs + configs, err := loadConfigsFile() + if err != nil { + fmt.Fprintf(cmd.ErrOrStderr(), "Error loading configs: %v\n", err) + return + } + + // Add or update the alias + configs.Aliases[strings.ToLower(name)] = ConfigAlias{ + Path: path, + JSONPath: jsonPath, + Source: name, // Use the provided name as the source + } + + // Save the updated configs + if err := saveConfigsFile(configs); err != nil { + fmt.Fprintf(cmd.ErrOrStderr(), "Error saving configs: %v\n", err) + return + } + + fmt.Fprintf(cmd.OutOrStdout(), "Alias '%s' created for %s with JSONPath '%s'\n", name, path, jsonPath) + }, + } + + // Add flags to the add and update commands + addCmd.Flags().StringVar(&ConfigFileOption, "config", "", "Path to the configuration file") + addCmd.Flags().StringVar(&URLOption, "url", "", "URL for SSE-based servers") + addCmd.Flags().StringVar(&HeadersOption, "headers", "", "Headers for URL-based servers (comma-separated key=value pairs)") + addCmd.Flags().StringVar(&EnvOption, "env", "", "Environment variables (comma-separated key=value pairs)") + + updateCmd.Flags().StringVar(&ConfigFileOption, "config", "", "Path to the configuration file") + updateCmd.Flags().StringVar(&URLOption, "url", "", "URL for SSE-based servers") + updateCmd.Flags().StringVar(&HeadersOption, "headers", "", "Headers for URL-based servers (comma-separated key=value pairs)") + updateCmd.Flags().StringVar(&EnvOption, "env", "", "Environment variables (comma-separated key=value pairs)") + + removeCmd.Flags().StringVar(&ConfigFileOption, "config", "", "Path to the configuration file") + + // Add subcommands to the configs command + cmd.AddCommand(addCmd, updateCmd, removeCmd, aliasCmd, viewCmd, lsCmd) + + return cmd +} diff --git a/cmd/mcptools/main.go b/cmd/mcptools/main.go index 7343792..8711bec 100644 --- a/cmd/mcptools/main.go +++ b/cmd/mcptools/main.go @@ -38,6 +38,7 @@ func main() { commands.ProxyCmd(), commands.AliasCmd(), commands.ScanCmd(), + commands.ConfigsCmd(), commands.NewCmd(), ) From 807629b23a2c6243a9b1c7e5b7a225f88f718000 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fatih=20Kadir=20Ak=C4=B1n?= Date: Tue, 8 Apr 2025 23:23:12 +0300 Subject: [PATCH 2/7] add mcp configs --- cmd/mcptools/commands/configs.go | 1392 ++++++++++++++++++++++++------ cmd/mcptools/commands/scan.go | 498 ----------- cmd/mcptools/main.go | 1 - 3 files changed, 1115 insertions(+), 776 deletions(-) delete mode 100644 cmd/mcptools/commands/scan.go diff --git a/cmd/mcptools/commands/configs.go b/cmd/mcptools/commands/configs.go index d45d6d9..2c2e987 100644 --- a/cmd/mcptools/commands/configs.go +++ b/cmd/mcptools/commands/configs.go @@ -1,40 +1,72 @@ package commands import ( + "bytes" "encoding/json" "fmt" "os" "path/filepath" + "sort" "strings" "github.com/spf13/cobra" + "golang.org/x/term" + "golang.org/x/text/cases" + "golang.org/x/text/language" ) -// ConfigFileOption stores the path to the configuration file +// ServerConfig represents a configuration for a server. +type ServerConfig struct { + Headers map[string]string `json:"headers,omitempty"` + Env map[string]string `json:"env,omitempty"` + Config map[string]interface{} `json:"config,omitempty"` + Source string `json:"source"` + Type string `json:"type,omitempty"` + Command string `json:"command,omitempty"` + URL string `json:"url,omitempty"` + Path string `json:"path,omitempty"` + Name string `json:"name,omitempty"` + Description string `json:"description,omitempty"` + Args []string `json:"args,omitempty"` +} + +// Constants for common strings. +const ( + defaultJSONPath = "$.mcpServers" + formatJSON = "json" + formatPretty = "pretty" + formatTable = "table" + + // File permissions. + dirPermissions = 0o750 + filePermissions = 0o600 +) + +// ConfigFileOption stores the path to the configuration file. var ConfigFileOption string -// HeadersOption stores the headers for URL-based servers +// HeadersOption stores the headers for URL-based servers. var HeadersOption string -// EnvOption stores the environment variables +// EnvOption stores the environment variables. var EnvOption string -// URLOption stores the URL for URL-based servers +// URLOption stores the URL for URL-based servers. var URLOption string -// ConfigAlias represents a configuration alias +// ConfigAlias represents a configuration alias. type ConfigAlias struct { Path string `json:"path"` JSONPath string `json:"jsonPath"` Source string `json:"source,omitempty"` } -// ConfigsFile represents the structure of the configs file +// ConfigsFile represents the structure of the configs file. type ConfigsFile struct { Aliases map[string]ConfigAlias `json:"aliases"` } -// getConfigsFilePath returns the path to the configs file +// getConfigsFilePath returns the path to the configs file. func getConfigsFilePath() (string, error) { homeDir, err := os.UserHomeDir() if err != nil { @@ -42,14 +74,14 @@ func getConfigsFilePath() (string, error) { } configDir := filepath.Join(homeDir, ".mcpt") - if err := os.MkdirAll(configDir, 0755); err != nil { + if err := os.MkdirAll(configDir, dirPermissions); err != nil { //nolint:gosec // We want the directory to be readable return "", fmt.Errorf("failed to create config directory: %w", err) } return filepath.Join(configDir, "configs.json"), nil } -// loadConfigsFile loads the configs file, creating it if it doesn't exist +// loadConfigsFile loads the configs file, creating it if it doesn't exist. func loadConfigsFile() (*ConfigsFile, error) { configsPath, err := getConfigsFilePath() if err != nil { @@ -57,7 +89,8 @@ func loadConfigsFile() (*ConfigsFile, error) { } // Create default config if file doesn't exist - if _, err := os.Stat(configsPath); os.IsNotExist(err) { + fileInfo, statErr := os.Stat(configsPath) //nolint:govet // Intentional shadow + if os.IsNotExist(statErr) { defaultConfig := &ConfigsFile{ Aliases: map[string]ConfigAlias{ "vscode": { @@ -72,41 +105,51 @@ func loadConfigsFile() (*ConfigsFile, error) { }, "windsurf": { Path: "~/.codeium/windsurf/mcp_config.json", - JSONPath: "$.mcpServers", + JSONPath: defaultJSONPath, Source: "Windsurf", }, "cursor": { Path: "~/.cursor/mcp.json", - JSONPath: "$.mcpServers", + JSONPath: defaultJSONPath, Source: "Cursor", }, "claude-desktop": { Path: "~/Library/Application Support/Claude/claude_desktop_config.json", - JSONPath: "$.mcpServers", + JSONPath: defaultJSONPath, Source: "Claude Desktop", }, "claude-code": { Path: "~/.claude.json", - JSONPath: "$.mcpServers", + JSONPath: defaultJSONPath, Source: "Claude Code", }, }, } - data, err := json.MarshalIndent(defaultConfig, "", " ") - if err != nil { - return nil, fmt.Errorf("failed to marshal default config: %w", err) + configData, marshalErr := json.MarshalIndent(defaultConfig, "", " ") + if marshalErr != nil { + return nil, fmt.Errorf("failed to marshal default config: %w", marshalErr) } - if err := os.WriteFile(configsPath, data, 0644); err != nil { - return nil, fmt.Errorf("failed to write default config: %w", err) + if writeErr := os.WriteFile(configsPath, configData, filePermissions); writeErr != nil { //nolint:gosec // User config file + return nil, fmt.Errorf("failed to write default config: %w", writeErr) } return defaultConfig, nil } + // Handle other errors from Stat + if statErr != nil { + return nil, fmt.Errorf("failed to check config file: %w", statErr) + } + + // Ensure it's a regular file + if !fileInfo.Mode().IsRegular() { + return nil, fmt.Errorf("config path exists but is not a regular file") + } + // Read existing config - data, err := os.ReadFile(configsPath) + data, err := os.ReadFile(configsPath) //nolint:gosec // File path from user home directory if err != nil { return nil, fmt.Errorf("failed to read config file: %w", err) } @@ -124,7 +167,7 @@ func loadConfigsFile() (*ConfigsFile, error) { return &config, nil } -// saveConfigsFile saves the configs file +// saveConfigsFile saves the configs file. func saveConfigsFile(config *ConfigsFile) error { configsPath, err := getConfigsFilePath() if err != nil { @@ -136,10 +179,10 @@ func saveConfigsFile(config *ConfigsFile) error { return fmt.Errorf("failed to marshal config: %w", err) } - return os.WriteFile(configsPath, data, 0644) + return os.WriteFile(configsPath, data, filePermissions) //nolint:gosec // User config file } -// expandPath expands the ~ in the path +// expandPath expands the ~ in the path. func expandPath(path string) string { if !strings.HasPrefix(path, "~") { return path @@ -153,7 +196,7 @@ func expandPath(path string) string { return filepath.Join(homeDir, path[1:]) } -// parseKeyValueOption parses a comma-separated list of key=value pairs +// parseKeyValueOption parses a comma-separated list of key=value pairs. func parseKeyValueOption(option string) (map[string]string, error) { result := make(map[string]string) if option == "" { @@ -172,7 +215,7 @@ func parseKeyValueOption(option string) (map[string]string, error) { return result, nil } -// getConfigFileAndPath gets the config file path and json path from an alias or direct file path +// getConfigFileAndPath gets the config file path and json path from an alias or direct file path. func getConfigFileAndPath(configs *ConfigsFile, aliasName, configFile string) (string, string, error) { var jsonPath string if configFile == "" { @@ -185,7 +228,7 @@ func getConfigFileAndPath(configs *ConfigsFile, aliasName, configFile string) (s jsonPath = aliasConfig.JSONPath } else { // Default JSON path if using direct file path - jsonPath = "$.mcpServers" + jsonPath = defaultJSONPath } // Expand the path if needed @@ -193,12 +236,12 @@ func getConfigFileAndPath(configs *ConfigsFile, aliasName, configFile string) (s return configFile, jsonPath, nil } -// readConfigFile reads and parses a config file +// readConfigFile reads and parses a config file. func readConfigFile(configFile string) (map[string]interface{}, error) { var configData map[string]interface{} if _, err := os.Stat(configFile); err == nil { // File exists, read and parse it - data, err := os.ReadFile(configFile) + data, err := os.ReadFile(configFile) //nolint:gosec // File path is validated earlier if err != nil { return nil, fmt.Errorf("failed to read config file: %w", err) } @@ -214,7 +257,7 @@ func readConfigFile(configFile string) (map[string]interface{}, error) { return configData, nil } -// addServerToConfig adds a server configuration to the config data +// addServerToConfig adds a server configuration to the config data. func addServerToConfig(configData map[string]interface{}, jsonPath, serverName string, serverConfig map[string]interface{}) { if strings.Contains(jsonPath, "mcp.servers") { // VS Code format @@ -228,34 +271,35 @@ func addServerToConfig(configData map[string]interface{}, jsonPath, serverName s configData["mcp"] = mcpMap } - if _, ok := mcpMap["servers"]; !ok { + if _, exists := mcpMap["servers"]; !exists { //nolint:govet // Intentional shadow mcpMap["servers"] = map[string]interface{}{} } - serversMap, ok := mcpMap["servers"].(map[string]interface{}) - if !ok { + serversMap, exists := mcpMap["servers"].(map[string]interface{}) //nolint:govet // Intentional shadow + if !exists { serversMap = map[string]interface{}{} mcpMap["servers"] = serversMap } serversMap[serverName] = serverConfig - } else { - // Other formats with mcpServers - if _, ok := configData["mcpServers"]; !ok { - configData["mcpServers"] = map[string]interface{}{} - } + return + } - serversMap, ok := configData["mcpServers"].(map[string]interface{}) - if !ok { - serversMap = map[string]interface{}{} - configData["mcpServers"] = serversMap - } + // Other formats with mcpServers + if _, ok := configData["mcpServers"]; !ok { + configData["mcpServers"] = map[string]interface{}{} + } - serversMap[serverName] = serverConfig + serversMap, ok := configData["mcpServers"].(map[string]interface{}) + if !ok { + serversMap = map[string]interface{}{} + configData["mcpServers"] = serversMap } + + serversMap[serverName] = serverConfig } -// getServerFromConfig gets a server configuration from the config data +// getServerFromConfig gets a server configuration from the config data. func getServerFromConfig(configData map[string]interface{}, jsonPath, serverName string) (map[string]interface{}, bool) { if strings.Contains(jsonPath, "mcp.servers") { // VS Code format @@ -271,19 +315,19 @@ func getServerFromConfig(configData map[string]interface{}, jsonPath, serverName server, ok := serversMap[serverName].(map[string]interface{}) return server, ok - } else { - // Other formats with mcpServers - serversMap, ok := configData["mcpServers"].(map[string]interface{}) - if !ok { - return nil, false - } + } - server, ok := serversMap[serverName].(map[string]interface{}) - return server, ok + // Other formats with mcpServers + serversMap, ok := configData["mcpServers"].(map[string]interface{}) + if !ok { + return nil, false } + + server, ok := serversMap[serverName].(map[string]interface{}) + return server, ok } -// removeServerFromConfig removes a server configuration from the config data +// removeServerFromConfig removes a server configuration from the config data. func removeServerFromConfig(configData map[string]interface{}, jsonPath, serverName string) bool { if strings.Contains(jsonPath, "mcp.servers") { // VS Code format @@ -303,30 +347,565 @@ func removeServerFromConfig(configData map[string]interface{}, jsonPath, serverN delete(serversMap, serverName) return true + } + + // Other formats with mcpServers + serversMap, ok := configData["mcpServers"].(map[string]interface{}) + if !ok { + return false + } + + if _, exists := serversMap[serverName]; !exists { + return false + } + + delete(serversMap, serverName) + return true +} + +// getServersFromConfig extracts all servers from a config file. +func getServersFromConfig(configFile string, jsonPath string, _ string) (map[string]map[string]interface{}, error) { + // Read the config file + data, err := os.ReadFile(configFile) //nolint:gosec // File path is validated earlier + if err != nil { + return nil, fmt.Errorf("failed to read config file: %w", err) + } + + var configData map[string]interface{} + if err := json.Unmarshal(data, &configData); err != nil { + return nil, fmt.Errorf("failed to parse config file: %w", err) + } + + // Extract servers based on JSONPath + var serversMap map[string]interface{} + if strings.Contains(jsonPath, "mcp.servers") { + // VS Code format + mcpMap, ok := configData["mcp"].(map[string]interface{}) + if !ok { + return nil, fmt.Errorf("no mcp key found in config") + } + serversMap, ok = mcpMap["servers"].(map[string]interface{}) + if !ok { + return nil, fmt.Errorf("no mcp.servers key found in config") + } } else { // Other formats with mcpServers - serversMap, ok := configData["mcpServers"].(map[string]interface{}) + var ok bool + serversMap, ok = configData["mcpServers"].(map[string]interface{}) if !ok { - return false + return nil, fmt.Errorf("no mcpServers key found in config") } + } - if _, exists := serversMap[serverName]; !exists { - return false + // Convert to map of maps for easier handling + result := make(map[string]map[string]interface{}) + for name, server := range serversMap { + if serverConfig, ok := server.(map[string]interface{}); ok { + result[name] = serverConfig } + } - delete(serversMap, serverName) - return true + return result, nil +} + +// formatJSONForComparison formats a server config as indented JSON for display. +func formatJSONForComparison(config map[string]interface{}) string { + data, err := json.MarshalIndent(config, "", " ") + if err != nil { + return fmt.Sprintf("Error formatting JSON: %v", err) + } + return string(data) +} + +// areConfigsIdentical checks if two server configurations are identical. +func areConfigsIdentical(config1, config2 map[string]interface{}) bool { + // Marshal both configs to JSON for deep comparison + json1, err1 := json.Marshal(config1) + json2, err2 := json.Marshal(config2) + + // If we can't marshal either one, consider them different + if err1 != nil || err2 != nil { + return false + } + + // Compare the JSON representations + return bytes.Equal(json1, json2) +} + +// formatSourceGroupedJSON formats servers grouped by source with raw JSON. +func formatSourceGroupedJSON(servers []ServerConfig) string { + if len(servers) == 0 { + return "No MCP servers found" + } + + // Group servers by source + serversBySource := make(map[string][]ServerConfig) + var sourceOrder []string + + for _, server := range servers { + if _, exists := serversBySource[server.Source]; !exists { + sourceOrder = append(sourceOrder, server.Source) + } + serversBySource[server.Source] = append(serversBySource[server.Source], server) + } + + // Sort sources + sort.Strings(sourceOrder) + + var buf bytes.Buffer + for _, source := range sourceOrder { + // Print source header + fmt.Fprintf(&buf, "%s:\n\n", source) + + // Reconstruct the original JSON structure + sourceServers := serversBySource[source] + + if strings.Contains(source, "VS Code") { + // VS Code format with mcp.servers + vsCodeJSON := map[string]interface{}{ + "mcp": map[string]interface{}{ + "servers": make(map[string]interface{}), + }, + } + + serversMap := vsCodeJSON["mcp"].(map[string]interface{})["servers"].(map[string]interface{}) + for _, server := range sourceServers { + serversMap[server.Name] = server.Config + } + + jsonData, _ := json.MarshalIndent(vsCodeJSON, "", " ") + fmt.Fprintf(&buf, "%s\n\n", string(jsonData)) + } else { + // Other formats with mcpServers + otherJSON := map[string]interface{}{ + "mcpServers": make(map[string]interface{}), + } + + serversMap := otherJSON["mcpServers"].(map[string]interface{}) + for _, server := range sourceServers { + serversMap[server.Name] = server.Config + } + + jsonData, _ := json.MarshalIndent(otherJSON, "", " ") + fmt.Fprintf(&buf, "%s\n\n", string(jsonData)) + } + } + + return buf.String() +} + +// formatColoredGroupedServers formats servers in a colored, grouped display by source. +func formatColoredGroupedServers(servers []ServerConfig) string { + if len(servers) == 0 { + return "No MCP servers found" + } + + // Group servers by source + serversBySource := make(map[string][]ServerConfig) + var sourceOrder []string + + for _, server := range servers { + if _, exists := serversBySource[server.Source]; !exists { + sourceOrder = append(sourceOrder, server.Source) + } + serversBySource[server.Source] = append(serversBySource[server.Source], server) + } + + // Sort sources + sort.Strings(sourceOrder) + + var buf bytes.Buffer + // Check if we're outputting to a terminal (for colors) + useColors := term.IsTerminal(int(os.Stdout.Fd())) + + for _, source := range sourceOrder { + // Print source header with bold blue + if useColors { + fmt.Fprintf(&buf, "\x1b[1m\x1b[34m%s\x1b[0m\n", source) + } else { + fmt.Fprintf(&buf, "%s\n", source) + } + + servers := serversBySource[source] + // Sort servers by name + sort.Slice(servers, func(i, j int) bool { + return servers[i].Name < servers[j].Name + }) + + for _, server := range servers { + // Determine server type + serverType := server.Type + if serverType == "" { + if server.URL != "" { + serverType = "sse" // nolint:goconst + } else { + serverType = "stdio" // nolint:goconst + } + } + + // Print server name and type + if useColors { + fmt.Fprintf(&buf, " \x1b[1m\x1b[35m%s\x1b[0m", server.Name) + fmt.Fprintf(&buf, " \x1b[1m\x1b[36m(%s)\x1b[0m:", serverType) + } else { + fmt.Fprintf(&buf, " %s (%s):", server.Name, serverType) + } + + // Print command or URL + if serverType == "sse" { + if useColors { + fmt.Fprintf(&buf, "\n \x1b[32m%s\x1b[0m\n", server.URL) + } else { + fmt.Fprintf(&buf, "\n %s\n", server.URL) + } + + // Print headers for SSE servers + if len(server.Headers) > 0 { + // Get sorted header keys + var headerKeys []string + for k := range server.Headers { + headerKeys = append(headerKeys, k) + } + sort.Strings(headerKeys) + + // Print each header + for _, k := range headerKeys { + if useColors { + fmt.Fprintf(&buf, " \x1b[33m%s\x1b[0m: %s\n", k, server.Headers[k]) + } else { + fmt.Fprintf(&buf, " %s: %s\n", k, server.Headers[k]) + } + } + } + } else { + // Print command and args + commandStr := server.Command + + // Add args with quotes for ones containing spaces + if len(server.Args) > 0 { + commandStr += " " + quotedArgs := make([]string, len(server.Args)) + for i, arg := range server.Args { + if strings.Contains(arg, " ") && !strings.HasPrefix(arg, "\"") && !strings.HasSuffix(arg, "\"") { + quotedArgs[i] = "\"" + arg + "\"" + } else { + quotedArgs[i] = arg + } + } + commandStr += strings.Join(quotedArgs, " ") + } + + if useColors { + fmt.Fprintf(&buf, "\n \x1b[32m%s\x1b[0m\n", commandStr) + } else { + fmt.Fprintf(&buf, "\n %s\n", commandStr) + } + + // Print env vars + if len(server.Env) > 0 { + // Get sorted env keys + var envKeys []string + for k := range server.Env { + envKeys = append(envKeys, k) + } + sort.Strings(envKeys) + + // Print each env var + for _, k := range envKeys { + if useColors { + fmt.Fprintf(&buf, " \x1b[33m%s\x1b[0m: %s\n", k, server.Env[k]) + } else { + fmt.Fprintf(&buf, " %s: %s\n", k, server.Env[k]) + } + } + } + } + + // Add a newline after each server for readability + fmt.Fprintln(&buf) + } + } + + return buf.String() +} + +// scanForServers scans various configuration files for MCP servers. +func scanForServers() ([]ServerConfig, error) { + homeDir, err := os.UserHomeDir() + if err != nil { + return nil, fmt.Errorf("failed to get user home directory: %w", err) + } + + var servers []ServerConfig + + // Scan VS Code Insiders + vscodeInsidersPath := filepath.Join(homeDir, "Library", "Application Support", "Code - Insiders", "User", "settings.json") + vscodeServers, err := scanVSCodeConfig(vscodeInsidersPath, "VS Code Insiders") + if err == nil { + servers = append(servers, vscodeServers...) + } + + // Scan VS Code + vscodePath := filepath.Join(homeDir, "Library", "Application Support", "Code", "User", "settings.json") + vscodeServers, err = scanVSCodeConfig(vscodePath, "VS Code") + if err == nil { + servers = append(servers, vscodeServers...) + } + + // Scan Windsurf + windsurfPath := filepath.Join(homeDir, ".codeium", "windsurf", "mcp_config.json") + windsurfServers, err := scanMCPServersConfig(windsurfPath, "Windsurf") + if err == nil { + servers = append(servers, windsurfServers...) + } + + // Scan Cursor + cursorPath := filepath.Join(homeDir, ".cursor", "mcp.json") + cursorServers, err := scanMCPServersConfig(cursorPath, "Cursor") + if err == nil { + servers = append(servers, cursorServers...) + } + + // Scan Claude Desktop + claudeDesktopPath := filepath.Join(homeDir, "Library", "Application Support", "Claude", "claude_desktop_config.json") + claudeServers, err := scanMCPServersConfig(claudeDesktopPath, "Claude Desktop") + if err == nil { + servers = append(servers, claudeServers...) + } + + // Scan Claude Code + claudeCodePath := filepath.Join(homeDir, ".claude.json") + claudeCodeServers, err := scanMCPServersConfig(claudeCodePath, "Claude Code") + if err == nil { + servers = append(servers, claudeCodeServers...) + } + + return servers, nil +} + +// scanVSCodeConfig scans a VS Code settings.json file for MCP servers. +func scanVSCodeConfig(path, source string) ([]ServerConfig, error) { + data, err := os.ReadFile(path) //nolint:gosec // File path from user home directory + if err != nil { + return nil, fmt.Errorf("failed to read %s config: %w", source, err) + } + + var settings map[string]interface{} + if err := json.Unmarshal(data, &settings); err != nil { + return nil, fmt.Errorf("failed to parse %s settings.json: %w", source, err) + } + + mcpObject, ok := settings["mcp"] + if !ok { + return nil, fmt.Errorf("no mcp configuration found in %s settings", source) + } + + mcpMap, ok := mcpObject.(map[string]interface{}) + if !ok { + return nil, fmt.Errorf("invalid mcp format in %s settings", source) + } + + mcpServers, ok := mcpMap["servers"] + if !ok { + return nil, fmt.Errorf("no mcp.servers found in %s settings", source) + } + + servers, ok := mcpServers.(map[string]interface{}) + if !ok { + return nil, fmt.Errorf("invalid mcp.servers format in %s settings", source) + } + + var result []ServerConfig + for name, config := range servers { + serverConfig, ok := config.(map[string]interface{}) + if !ok { + continue + } + + // Extract common properties + serverType, _ := serverConfig["type"].(string) + command, _ := serverConfig["command"].(string) + description, _ := serverConfig["description"].(string) + url, _ := serverConfig["url"].(string) + + // Extract args if available + var args []string + if argsInterface, ok := serverConfig["args"].([]interface{}); ok { + for _, arg := range argsInterface { + if argStr, ok := arg.(string); ok { + args = append(args, argStr) + } + } + } + + // Extract headers if available + headers := make(map[string]string) + if headersInterface, ok := serverConfig["headers"].(map[string]interface{}); ok { + for k, v := range headersInterface { + if valStr, ok := v.(string); ok { + headers[k] = valStr + } + } + } + + // Extract env if available + env := make(map[string]string) + if envInterface, ok := serverConfig["env"].(map[string]interface{}); ok { + for k, v := range envInterface { + if valStr, ok := v.(string); ok { + env[k] = valStr + } + } + } + + result = append(result, ServerConfig{ + Source: source, + Type: serverType, + Command: command, + Args: args, + URL: url, + Headers: headers, + Env: env, + Name: name, + Config: serverConfig, + Description: description, + }) + } + + return result, nil +} + +// scanMCPServersConfig scans a config file with mcpServers as the top-level key. +func scanMCPServersConfig(path, source string) ([]ServerConfig, error) { + data, err := os.ReadFile(path) //nolint:gosec // File path from user home directory + if err != nil { + return nil, fmt.Errorf("failed to read %s config: %w", source, err) + } + + var config map[string]interface{} + if err := json.Unmarshal(data, &config); err != nil { + return nil, fmt.Errorf("failed to parse %s config: %w", source, err) + } + + mcpServers, ok := config["mcpServers"] + if !ok { + return nil, fmt.Errorf("no mcpServers key found in %s config", source) + } + + servers, ok := mcpServers.(map[string]interface{}) + if !ok { + return nil, fmt.Errorf("invalid mcpServers format in %s config", source) + } + + var result []ServerConfig + for name, serverData := range servers { + serverConfig, ok := serverData.(map[string]interface{}) + if !ok { + continue + } + + // Extract common properties + command, _ := serverConfig["command"].(string) + url, _ := serverConfig["url"].(string) + + // Extract args if available + var args []string + if argsInterface, ok := serverConfig["args"].([]interface{}); ok { + for _, arg := range argsInterface { + if argStr, ok := arg.(string); ok { + args = append(args, argStr) + } + } + } + + // Extract headers if available + headers := make(map[string]string) + if headersInterface, ok := serverConfig["headers"].(map[string]interface{}); ok { + for k, v := range headersInterface { + if valStr, ok := v.(string); ok { + headers[k] = valStr + } + } + } + + // Extract env if available + env := make(map[string]string) + if envInterface, ok := serverConfig["env"].(map[string]interface{}); ok { + for k, v := range envInterface { + if valStr, ok := v.(string); ok { + env[k] = valStr + } + } + } + + // Determine type based on whether URL is present + serverType := "" + if url != "" { + serverType = "sse" + } + + result = append(result, ServerConfig{ + Source: source, + Type: serverType, + Command: command, + Args: args, + URL: url, + Headers: headers, + Env: env, + Name: name, + Config: serverConfig, + }) } + + return result, nil } -// ConfigsCmd creates the configs command -func ConfigsCmd() *cobra.Command { +// ConfigsCmd creates the configs command. +func ConfigsCmd() *cobra.Command { //nolint:gocyclo // This is a large command with many subcommands cmd := &cobra.Command{ Use: "configs", Short: "Manage MCP server configurations", Long: `Manage MCP server configurations including scanning, adding, and aliasing.`, } + // Add scan subcommand + scanCmd := &cobra.Command{ + Use: "scan", + Short: "Scan for available MCP servers in various configurations", + Long: `Scan for available MCP servers in various configuration files of these Applications on macOS: +VS Code, VS Code Insiders, Windsurf, Cursor, Claude Desktop, Claude Code`, + Run: func(cmd *cobra.Command, _ []string) { + servers, err := scanForServers() + if err != nil { + fmt.Fprintf(cmd.ErrOrStderr(), "Error scanning for servers: %v\n", err) + return + } + + // Table format (default) now uses the colored grouped display + if strings.ToLower(FormatOption) == "table" || strings.ToLower(FormatOption) == "pretty" { + output := formatColoredGroupedServers(servers) + fmt.Fprintln(cmd.OutOrStdout(), output) + return + } + + // For JSON format, use the grouped display + if strings.ToLower(FormatOption) == "json" { + output := formatSourceGroupedJSON(servers) + fmt.Fprintln(cmd.OutOrStdout(), output) + return + } + + // For other formats, use the full server data + output, err := json.MarshalIndent(servers, "", " ") + if err != nil { + fmt.Fprintf(cmd.ErrOrStderr(), "Error formatting output: %v\n", err) + return + } + + fmt.Fprintln(cmd.OutOrStdout(), string(output)) + }, + } + // Add the view subcommand with AllOption flag var AllOption bool viewCmd := &cobra.Command{ @@ -364,7 +943,8 @@ func ConfigsCmd() *cobra.Command { source := config.Source if source == "" { - source = strings.Title(alias) // Use capitalized alias name if source not provided + titleCase := cases.Title(language.English) + source = titleCase.String(alias) // Use capitalized alias name if source not provided } if strings.Contains(config.JSONPath, "mcp.servers") { @@ -394,7 +974,8 @@ func ConfigsCmd() *cobra.Command { configPath = aliasConfig.Path source = aliasConfig.Source if source == "" { - source = strings.Title(target) + titleCase := cases.Title(language.English) + source = titleCase.String(target) } jsonPath = aliasConfig.JSONPath } else { @@ -432,13 +1013,13 @@ func ConfigsCmd() *cobra.Command { } // Output based on format - if strings.ToLower(FormatOption) == "table" || strings.ToLower(FormatOption) == "pretty" { + if strings.ToLower(FormatOption) == formatTable || strings.ToLower(FormatOption) == formatPretty { output := formatColoredGroupedServers(servers) fmt.Fprintln(cmd.OutOrStdout(), output) return } - if strings.ToLower(FormatOption) == "json" { + if strings.ToLower(FormatOption) == formatJSON { output := formatSourceGroupedJSON(servers) fmt.Fprintln(cmd.OutOrStdout(), output) return @@ -476,109 +1057,75 @@ func ConfigsCmd() *cobra.Command { // Add --all flag to ls command (though it's true by default) lsCmd.Flags().BoolVar(&AllOption, "all", false, "View all configured aliases (default: false)") - // Add the add subcommand - addCmd := &cobra.Command{ - Use: "add [alias] [server] [command/url] [args...]", - Short: "Add a new MCP server configuration", - Long: `Add a new MCP server configuration using either an alias or direct file path. For URL-based servers, use --url flag.`, + // Create the set subcommand (merges add and update functionality) + setCmd := &cobra.Command{ + Use: "set [alias,alias2,...] [server] [command/url] [args...]", + Short: "Add or update an MCP server configuration", + Long: `Add or update an MCP server configuration. Creates a new server if it doesn't exist, or updates an existing one. Multiple aliases can be specified with commas.`, Args: cobra.MinimumNArgs(2), + // Disable flag parsing after the first arguments + DisableFlagParsing: true, Run: func(cmd *cobra.Command, args []string) { - // Load configs - configs, err := loadConfigsFile() - if err != nil { - fmt.Fprintf(cmd.ErrOrStderr(), "Error loading configs: %v\n", err) - return - } - - // Get the alias/config file and server name - aliasName := args[0] - serverName := args[1] - - // Get config file and JSON path from alias or direct path - configFile, jsonPath, err := getConfigFileAndPath(configs, aliasName, ConfigFileOption) - if err != nil { - fmt.Fprintf(cmd.ErrOrStderr(), "Error: %v\n", err) - return - } - - // Read the target config file - configData, err := readConfigFile(configFile) - if err != nil { - fmt.Fprintf(cmd.ErrOrStderr(), "Error: %v\n", err) - return - } - - // Create server config - serverConfig := make(map[string]interface{}) - - // Determine if this is a URL-based or command-based server - if URLOption != "" { - // URL-based server - serverConfig["url"] = URLOption - - // Parse headers - if HeadersOption != "" { - headers, err := parseKeyValueOption(HeadersOption) - if err != nil { - fmt.Fprintf(cmd.ErrOrStderr(), "Error parsing headers: %v\n", err) - return - } - if len(headers) > 0 { - serverConfig["headers"] = headers - } + // We need to manually extract the flags we care about + var configFile string + var headers string + var env string + + // Create cleaned arguments (without our flags) + cleanedArgs := make([]string, 0, len(args)) + + // Process all args to extract our flags and build clean args + i := 0 + for i < len(args) { + arg := args[i] + + // Handle both --flag=value and --flag value formats + if strings.HasPrefix(arg, "--config=") { + configFile = strings.TrimPrefix(arg, "--config=") + i++ + continue + } else if arg == "--config" && i+1 < len(args) { + configFile = args[i+1] + i += 2 + continue } - } else if len(args) > 2 { - // Command-based server - command := args[2] - serverConfig["command"] = command - // Add command args if provided - if len(args) > 3 { - serverConfig["args"] = args[3:] + if strings.HasPrefix(arg, "--headers=") { + headers = strings.TrimPrefix(arg, "--headers=") + i++ + continue + } else if arg == "--headers" && i+1 < len(args) { + headers = args[i+1] + i += 2 + continue } - } else { - fmt.Fprintf(cmd.ErrOrStderr(), "Error: either command or --url must be provided\n") - return - } - // Parse environment variables for both URL and command servers - if EnvOption != "" { - env, err := parseKeyValueOption(EnvOption) - if err != nil { - fmt.Fprintf(cmd.ErrOrStderr(), "Error parsing environment variables: %v\n", err) - return - } - if len(env) > 0 { - serverConfig["env"] = env + if strings.HasPrefix(arg, "--env=") { + env = strings.TrimPrefix(arg, "--env=") + i++ + continue + } else if arg == "--env" && i+1 < len(args) { + env = args[i+1] + i += 2 + continue } - } - - // Add the server to the config - addServerToConfig(configData, jsonPath, serverName, serverConfig) - // Write the updated config back to the file - data, err := json.MarshalIndent(configData, "", " ") - if err != nil { - fmt.Fprintf(cmd.ErrOrStderr(), "Error marshaling config: %v\n", err) - return + // If none of our flags, add to cleaned args + cleanedArgs = append(cleanedArgs, arg) + i++ } - if err := os.WriteFile(configFile, data, 0644); err != nil { - fmt.Fprintf(cmd.ErrOrStderr(), "Error writing config file: %v\n", err) + // Make sure we have enough arguments + if len(cleanedArgs) < 2 { + fmt.Fprintf(cmd.ErrOrStderr(), "Error: set command requires at least alias and server name arguments\n") return } - fmt.Fprintf(cmd.OutOrStdout(), "Server '%s' added to %s\n", serverName, configFile) - }, - } + // Set the values we normally would have through flags + ConfigFileOption = configFile + HeadersOption = headers + EnvOption = env - // Add the update subcommand - updateCmd := &cobra.Command{ - Use: "update [alias] [server] [command/url] [args...]", - Short: "Update an existing MCP server configuration", - Long: `Update an existing MCP server configuration. For URL-based servers, use --url flag.`, - Args: cobra.MinimumNArgs(2), - Run: func(cmd *cobra.Command, args []string) { // Load configs configs, err := loadConfigsFile() if err != nil { @@ -586,113 +1133,144 @@ func ConfigsCmd() *cobra.Command { return } - // Get the alias/config file and server name - aliasName := args[0] - serverName := args[1] + // Get the alias/config file and server name - allow for comma-separated aliases + aliasInput := cleanedArgs[0] + serverName := cleanedArgs[1] - // Get config file and JSON path from alias or direct path - configFile, jsonPath, err := getConfigFileAndPath(configs, aliasName, ConfigFileOption) - if err != nil { - fmt.Fprintf(cmd.ErrOrStderr(), "Error: %v\n", err) - return - } + // Split aliases by comma + aliasList := strings.Split(aliasInput, ",") + successCount := 0 - // Read the target config file - configData, err := readConfigFile(configFile) - if err != nil { - fmt.Fprintf(cmd.ErrOrStderr(), "Error: %v\n", err) - return - } - - // Check if the server exists - existingServer, exists := getServerFromConfig(configData, jsonPath, serverName) - if !exists { - fmt.Fprintf(cmd.ErrOrStderr(), "Error: server '%s' not found in %s\n", serverName, configFile) - return - } + // Process each alias + for _, aliasName := range aliasList { + aliasName = strings.TrimSpace(aliasName) + if aliasName == "" { + continue + } - // Create server config starting with existing values - serverConfig := existingServer - if serverConfig == nil { - serverConfig = make(map[string]interface{}) - } + // Get config file and JSON path from alias or direct path + configFile, jsonPath, err := getConfigFileAndPath(configs, aliasName, ConfigFileOption) + if err != nil { + fmt.Fprintf(cmd.ErrOrStderr(), "Error for alias '%s': %v\n", aliasName, err) + continue + } - // Determine if this is a URL-based or command-based server update - if URLOption != "" { - // URL-based server - remove command and args if they exist - delete(serverConfig, "command") - delete(serverConfig, "args") + // Read the target config file + configData, err := readConfigFile(configFile) + if err != nil { + fmt.Fprintf(cmd.ErrOrStderr(), "Error for alias '%s': %v\n", aliasName, err) + continue + } - // Set the URL - serverConfig["url"] = URLOption + // Check if the server already exists + existingServer, exists := getServerFromConfig(configData, jsonPath, serverName) - // Parse headers - if HeadersOption != "" { - headers, err := parseKeyValueOption(HeadersOption) - if err != nil { - fmt.Fprintf(cmd.ErrOrStderr(), "Error parsing headers: %v\n", err) - return - } - if len(headers) > 0 { - serverConfig["headers"] = headers + // Set up the server config - either new or existing + var serverConfig map[string]interface{} + if exists { + // Update existing server + serverConfig = existingServer + if serverConfig == nil { + serverConfig = make(map[string]interface{}) } + } else { + // Create new server + serverConfig = make(map[string]interface{}) } - } else if len(args) > 2 { - // Command-based server - remove url and headers if they exist - delete(serverConfig, "url") - delete(serverConfig, "headers") - - // Set the command - command := args[2] - serverConfig["command"] = command - // Add command args if provided - if len(args) > 3 { - serverConfig["args"] = args[3:] + // Determine command type - check if command is a URL + if len(cleanedArgs) > 2 { + command := cleanedArgs[2] + if strings.HasPrefix(command, "http://") || strings.HasPrefix(command, "https://") { + // URL-based server + serverConfig["url"] = command + // Remove command-related fields if they exist + delete(serverConfig, "command") + delete(serverConfig, "args") + + // Parse headers + if HeadersOption != "" { + headers, parseErr := parseKeyValueOption(HeadersOption) //nolint:govet,shadow // reusing variable name for clarity + if parseErr != nil { + fmt.Fprintf(cmd.ErrOrStderr(), "Error parsing headers for alias '%s': %v\n", aliasName, parseErr) + continue + } + if len(headers) > 0 { + serverConfig["headers"] = headers + } + } + } else { + // Command-based server + serverConfig["command"] = command + // Remove URL-related fields if they exist + delete(serverConfig, "url") + delete(serverConfig, "headers") + + // Add command args if provided + if len(cleanedArgs) > 3 { + serverConfig["args"] = cleanedArgs[3:] + } else if exists { + // Only delete args if explicitly not provided during update + delete(serverConfig, "args") + } + } } else { - delete(serverConfig, "args") + fmt.Fprintf(cmd.ErrOrStderr(), "Error for alias '%s': command or URL must be provided\n", aliasName) + continue } - } else { - fmt.Fprintf(cmd.ErrOrStderr(), "Error: either command or --url must be provided\n") - return - } - // Parse environment variables - if EnvOption != "" { - env, err := parseKeyValueOption(EnvOption) - if err != nil { - fmt.Fprintf(cmd.ErrOrStderr(), "Error parsing environment variables: %v\n", err) - return + // Parse environment variables + if EnvOption != "" { + env, parseErr := parseKeyValueOption(EnvOption) //nolint:govet,shadow // reusing variable name for clarity + if parseErr != nil { + fmt.Fprintf(cmd.ErrOrStderr(), "Error parsing environment variables for alias '%s': %v\n", aliasName, parseErr) + continue + } + if len(env) > 0 { + serverConfig["env"] = env + } } - if len(env) > 0 { - serverConfig["env"] = env + + // Add/update the server in the config + addServerToConfig(configData, jsonPath, serverName, serverConfig) + + // Write the updated config back to the file + data, err := json.MarshalIndent(configData, "", " ") + if err != nil { + fmt.Fprintf(cmd.ErrOrStderr(), "Error marshaling config for alias '%s': %v\n", aliasName, err) + continue } - } - // Update the server in the config - addServerToConfig(configData, jsonPath, serverName, serverConfig) + if writeErr := os.WriteFile(configFile, data, filePermissions); writeErr != nil { //nolint:gosec // User config file + fmt.Fprintf(cmd.ErrOrStderr(), "Error writing config file for alias '%s': %v\n", aliasName, writeErr) + continue + } - // Write the updated config back to the file - data, err := json.MarshalIndent(configData, "", " ") - if err != nil { - fmt.Fprintf(cmd.ErrOrStderr(), "Error marshaling config: %v\n", err) - return + successCount++ + if exists { + fmt.Fprintf(cmd.OutOrStdout(), "Server '%s' updated for alias '%s' in %s\n", serverName, aliasName, configFile) + } else { + fmt.Fprintf(cmd.OutOrStdout(), "Server '%s' added for alias '%s' to %s\n", serverName, aliasName, configFile) + } } - if err := os.WriteFile(configFile, data, 0644); err != nil { - fmt.Fprintf(cmd.ErrOrStderr(), "Error writing config file: %v\n", err) - return + // Report summary if multiple aliases were processed + if len(aliasList) > 1 { + fmt.Fprintf(cmd.OutOrStdout(), "\nSummary: Successfully processed %d of %d aliases\n", successCount, len(aliasList)) } - - fmt.Fprintf(cmd.OutOrStdout(), "Server '%s' updated in %s\n", serverName, configFile) }, } + // Add flags to the commands - these are just for documentation since we do manual parsing + setCmd.Flags().StringVar(&ConfigFileOption, "config", "", "Path to the configuration file") + setCmd.Flags().StringVar(&HeadersOption, "headers", "", "Headers for URL-based servers (comma-separated key=value pairs)") + setCmd.Flags().StringVar(&EnvOption, "env", "", "Environment variables (comma-separated key=value pairs)") + // Add the remove subcommand removeCmd := &cobra.Command{ - Use: "remove [alias] [server]", + Use: "remove [alias,alias2,...] [server]", Short: "Remove an MCP server configuration", - Long: `Remove an MCP server configuration from a config file.`, + Long: `Remove an MCP server configuration from a config file. Multiple aliases can be specified with commas.`, Args: cobra.ExactArgs(2), Run: func(cmd *cobra.Command, args []string) { // Load configs @@ -702,47 +1280,68 @@ func ConfigsCmd() *cobra.Command { return } - // Get the alias/config file and server name - aliasName := args[0] + // Get the alias/config file and server name - allow for comma-separated aliases + aliasInput := args[0] serverName := args[1] - // Get config file and JSON path from alias or direct path - configFile, jsonPath, err := getConfigFileAndPath(configs, aliasName, ConfigFileOption) - if err != nil { - fmt.Fprintf(cmd.ErrOrStderr(), "Error: %v\n", err) - return - } + // Split aliases by comma + aliasList := strings.Split(aliasInput, ",") + successCount := 0 - // Read the target config file - configData, err := readConfigFile(configFile) - if err != nil { - fmt.Fprintf(cmd.ErrOrStderr(), "Error: %v\n", err) - return - } + // Process each alias + for _, aliasName := range aliasList { + aliasName = strings.TrimSpace(aliasName) + if aliasName == "" { + continue + } - // Remove the server - removed := removeServerFromConfig(configData, jsonPath, serverName) - if !removed { - fmt.Fprintf(cmd.ErrOrStderr(), "Error: server '%s' not found in %s\n", serverName, configFile) - return - } + // Get config file and JSON path from alias or direct path + configFile, jsonPath, err := getConfigFileAndPath(configs, aliasName, ConfigFileOption) + if err != nil { + fmt.Fprintf(cmd.ErrOrStderr(), "Error for alias '%s': %v\n", aliasName, err) + continue + } - // Write the updated config back to the file - data, err := json.MarshalIndent(configData, "", " ") - if err != nil { - fmt.Fprintf(cmd.ErrOrStderr(), "Error marshaling config: %v\n", err) - return - } + // Read the target config file + configData, err := readConfigFile(configFile) + if err != nil { + fmt.Fprintf(cmd.ErrOrStderr(), "Error for alias '%s': %v\n", aliasName, err) + continue + } - if err := os.WriteFile(configFile, data, 0644); err != nil { - fmt.Fprintf(cmd.ErrOrStderr(), "Error writing config file: %v\n", err) - return + // Remove the server + removed := removeServerFromConfig(configData, jsonPath, serverName) + if !removed { + fmt.Fprintf(cmd.ErrOrStderr(), "Error: server '%s' not found for alias '%s' in %s\n", serverName, aliasName, configFile) + continue + } + + // Write the updated config back to the file + data, err := json.MarshalIndent(configData, "", " ") + if err != nil { + fmt.Fprintf(cmd.ErrOrStderr(), "Error marshaling config for alias '%s': %v\n", aliasName, err) + continue + } + + if writeErr := os.WriteFile(configFile, data, filePermissions); writeErr != nil { //nolint:gosec // User config file + fmt.Fprintf(cmd.ErrOrStderr(), "Error writing config file for alias '%s': %v\n", aliasName, writeErr) + continue + } + + successCount++ + fmt.Fprintf(cmd.OutOrStdout(), "Server '%s' removed for alias '%s' from %s\n", serverName, aliasName, configFile) } - fmt.Fprintf(cmd.OutOrStdout(), "Server '%s' removed from %s\n", serverName, configFile) + // Report summary if multiple aliases were processed + if len(aliasList) > 1 { + fmt.Fprintf(cmd.OutOrStdout(), "\nSummary: Successfully processed %d of %d aliases\n", successCount, len(aliasList)) + } }, } + // Add flag to remove command + removeCmd.Flags().StringVar(&ConfigFileOption, "config", "", "Path to the configuration file") + // Add the alias subcommand aliasCmd := &cobra.Command{ Use: "alias [name] [path] [jsonPath]", @@ -783,21 +1382,260 @@ func ConfigsCmd() *cobra.Command { }, } - // Add flags to the add and update commands - addCmd.Flags().StringVar(&ConfigFileOption, "config", "", "Path to the configuration file") - addCmd.Flags().StringVar(&URLOption, "url", "", "URL for SSE-based servers") - addCmd.Flags().StringVar(&HeadersOption, "headers", "", "Headers for URL-based servers (comma-separated key=value pairs)") - addCmd.Flags().StringVar(&EnvOption, "env", "", "Environment variables (comma-separated key=value pairs)") + // Add the sync command + var OutputAliasOption string + var DefaultChoiceOption string + syncCmd := &cobra.Command{ + Use: "sync [alias1] [alias2] [...]", + Short: "Synchronize and merge MCP server configurations", + Long: `Synchronize and merge MCP server configurations from multiple alias sources with interactive conflict resolution.`, + Args: cobra.MinimumNArgs(1), + Run: func(cmd *cobra.Command, args []string) { + // Load configs + configs, err := loadConfigsFile() + if err != nil { + fmt.Fprintf(cmd.ErrOrStderr(), "Error loading configs: %v\n", err) + return + } + + // Determine output alias and ensure it's valid + outputAlias := OutputAliasOption + if outputAlias == "" { + // Default to the first alias if no output alias specified + outputAlias = args[0] + } - updateCmd.Flags().StringVar(&ConfigFileOption, "config", "", "Path to the configuration file") - updateCmd.Flags().StringVar(&URLOption, "url", "", "URL for SSE-based servers") - updateCmd.Flags().StringVar(&HeadersOption, "headers", "", "Headers for URL-based servers (comma-separated key=value pairs)") - updateCmd.Flags().StringVar(&EnvOption, "env", "", "Environment variables (comma-separated key=value pairs)") + // Get the target alias config + outputAliasLower := strings.ToLower(outputAlias) + targetAliasConfig, ok := configs.Aliases[outputAliasLower] + if !ok { + fmt.Fprintf(cmd.ErrOrStderr(), "Error: output alias '%s' not found\n", outputAlias) + return + } - removeCmd.Flags().StringVar(&ConfigFileOption, "config", "", "Path to the configuration file") + // Collect all valid alias configurations + aliasConfigs := make(map[string]ConfigAlias) + aliasNames := make([]string, 0) + aliasFiles := make([]string, 0) + + // Always include the output alias + aliasConfigs[outputAliasLower] = targetAliasConfig + aliasNames = append(aliasNames, outputAlias) + aliasFiles = append(aliasFiles, expandPath(targetAliasConfig.Path)) + + // Process each alias to validate and collect configurations + for _, aliasName := range args { + aliasLower := strings.ToLower(aliasName) + + // Skip if this is the output alias (already added) + if aliasLower == outputAliasLower { + continue + } + + aliasConfig, ok := configs.Aliases[aliasLower] + if !ok { + fmt.Fprintf(cmd.ErrOrStderr(), "Warning: alias '%s' not found, skipping\n", aliasName) + continue + } + + expandedPath := expandPath(aliasConfig.Path) + + // Skip if file doesn't exist + if _, err := os.Stat(expandedPath); os.IsNotExist(err) { + fmt.Fprintf(cmd.ErrOrStderr(), "Warning: config file for alias '%s' not found at %s, skipping\n", aliasName, expandedPath) + continue + } + + // Add to our collection of valid aliases + aliasConfigs[aliasLower] = aliasConfig + aliasNames = append(aliasNames, aliasName) + aliasFiles = append(aliasFiles, expandedPath) + } + + if len(aliasNames) < 2 { + fmt.Fprintf(cmd.ErrOrStderr(), "Error: at least two valid aliases are required for syncing\n") + return + } + + // Determine default choice for conflicts + defaultChoice := strings.ToLower(DefaultChoiceOption) + if defaultChoice != "first" && defaultChoice != "second" && defaultChoice != "interactive" { + defaultChoice = "interactive" + } + + // Collect all servers + allServers := make(map[string]map[string]interface{}) + serverSources := make(map[string]string) // track where each server comes from + conflicts := make(map[string][]map[string]interface{}) + conflictSources := make(map[string][]string) + + // Process each alias to collect servers + for _, aliasName := range aliasNames { + aliasLower := strings.ToLower(aliasName) + aliasConfig := aliasConfigs[aliasLower] + expandedPath := expandPath(aliasConfig.Path) + + // Get servers from this config + servers, err := getServersFromConfig(expandedPath, aliasConfig.JSONPath, aliasConfig.Source) + if err != nil { + fmt.Fprintf(cmd.ErrOrStderr(), "Warning: failed to read servers from alias '%s': %v, skipping\n", aliasName, err) + continue + } + + // Check for conflicts + for name, server := range servers { + if existing, ok := allServers[name]; ok { + // Check if the configurations are identical + if areConfigsIdentical(existing, server) { + // Configs are identical - no need to mark as conflict + continue + } + + // This is a true conflict - store both versions + if conflicts[name] == nil { + conflicts[name] = []map[string]interface{}{existing, server} + conflictSources[name] = []string{serverSources[name], aliasName} + } else { + conflicts[name] = append(conflicts[name], server) + conflictSources[name] = append(conflictSources[name], aliasName) + } + } else { + // No conflict, just add it + allServers[name] = server + serverSources[name] = aliasName + } + } + } + + // Resolve conflicts + if len(conflicts) > 0 { + fmt.Fprintf(cmd.OutOrStdout(), "Found %d server name conflicts to resolve\n", len(conflicts)) + + for name, conflictingConfigs := range conflicts { + sources := conflictSources[name] + + // Skip interactive resolution if default choice is set + if defaultChoice == "first" { + allServers[name] = conflictingConfigs[0] + fmt.Fprintf(cmd.OutOrStdout(), "Conflict for '%s': automatically selected version from '%s'\n", name, sources[0]) + continue + } else if defaultChoice == "second" { + allServers[name] = conflictingConfigs[1] + fmt.Fprintf(cmd.OutOrStdout(), "Conflict for '%s': automatically selected version from '%s'\n", name, sources[1]) + continue + } + + // Interactive resolution + fmt.Fprintf(cmd.OutOrStdout(), "\nConflict found for server '%s'\n", name) + + // Display options + for i, config := range conflictingConfigs { + fmt.Fprintf(cmd.OutOrStdout(), "Option %d (from alias '%s'):\n", i+1, sources[i]) + fmt.Fprintf(cmd.OutOrStdout(), "%s\n\n", formatJSONForComparison(config)) + } + + // Ask user which to keep + var choice int + for { + fmt.Fprintf(cmd.OutOrStdout(), "Enter option number to keep (1-%d): ", len(conflictingConfigs)) + + var input string + if _, err := fmt.Scanln(&input); err != nil { + // Handle scan error (EOF, no input, etc.) + fmt.Fprintf(cmd.ErrOrStderr(), "Error reading input: %v\n", err) + input = "1" // Default to first option on error + } + + if n, err := fmt.Sscanf(input, "%d", &choice); err == nil && n == 1 && choice >= 1 && choice <= len(conflictingConfigs) { + break + } + + fmt.Fprintf(cmd.OutOrStdout(), "Invalid choice. Please enter a number between 1 and %d\n", len(conflictingConfigs)) + } + + // Save user's choice + allServers[name] = conflictingConfigs[choice-1] + fmt.Fprintf(cmd.OutOrStdout(), "Selected option %d for '%s'\n", choice, name) + } + } + + // Now update all configuration files + fmt.Fprintf(cmd.OutOrStdout(), "\nUpdating %d configuration files with %d merged servers\n", len(aliasNames), len(allServers)) + + // Track success/failure + successful := 0 + + // Update each alias configuration + for i, aliasName := range aliasNames { + aliasLower := strings.ToLower(aliasName) + aliasConfig := aliasConfigs[aliasLower] + configFile := aliasFiles[i] + jsonPath := aliasConfig.JSONPath + + // Read the existing file to preserve its structure + var configData map[string]interface{} + if _, err := os.Stat(configFile); err == nil { + // File exists, read and parse it + data, err := os.ReadFile(configFile) //nolint:gosec // File path is validated earlier + if err == nil { + if unmarshalErr := json.Unmarshal(data, &configData); unmarshalErr != nil { + // Handle unmarshaling error + configData = make(map[string]interface{}) + } + } + } + + // If we couldn't read the file or it was empty, create a new structure + if configData == nil { + configData = make(map[string]interface{}) + } + + // Structure the output based on the JSONPath + if strings.Contains(jsonPath, "mcp.servers") { + // VS Code format + if _, ok := configData["mcp"]; !ok { + configData["mcp"] = map[string]interface{}{} + } + + mcpMap, ok := configData["mcp"].(map[string]interface{}) + if !ok { + mcpMap = map[string]interface{}{} + configData["mcp"] = mcpMap + } + + mcpMap["servers"] = allServers + } else { + // Other formats with mcpServers (default) + configData["mcpServers"] = allServers + } + + // Write the merged config + data, err := json.MarshalIndent(configData, "", " ") + if err != nil { + fmt.Fprintf(cmd.ErrOrStderr(), "Error marshaling merged config for '%s': %v\n", aliasName, err) + continue + } + + if err := os.WriteFile(configFile, data, filePermissions); err != nil { + fmt.Fprintf(cmd.ErrOrStderr(), "Error writing merged config to %s: %v\n", configFile, err) + continue + } + + successful++ + fmt.Fprintf(cmd.OutOrStdout(), "Updated configuration for alias '%s' at %s\n", aliasName, configFile) + } + + fmt.Fprintf(cmd.OutOrStdout(), "\nSuccessfully synced %d servers across %d/%d alias configurations\n", + len(allServers), successful, len(aliasNames)) + }, + } + + // Add flags to the sync command + syncCmd.Flags().StringVar(&OutputAliasOption, "output", "", "Output alias (defaults to first alias)") + syncCmd.Flags().StringVar(&DefaultChoiceOption, "default", "interactive", "Default choice for conflicts: 'first', 'second', or 'interactive'") // Add subcommands to the configs command - cmd.AddCommand(addCmd, updateCmd, removeCmd, aliasCmd, viewCmd, lsCmd) + cmd.AddCommand(lsCmd, viewCmd, setCmd, removeCmd, aliasCmd, syncCmd, scanCmd) return cmd } diff --git a/cmd/mcptools/commands/scan.go b/cmd/mcptools/commands/scan.go deleted file mode 100644 index f533a4b..0000000 --- a/cmd/mcptools/commands/scan.go +++ /dev/null @@ -1,498 +0,0 @@ -package commands - -import ( - "bytes" - "encoding/json" - "fmt" - "os" - "path/filepath" - "sort" - "strings" - - "github.com/f/mcptools/pkg/jsonutils" - "github.com/spf13/cobra" - "golang.org/x/term" -) - -// ScanCmd creates the scan command. -func ScanCmd() *cobra.Command { - return &cobra.Command{ - Use: "scan", - Short: "Scan for available MCP servers in various configurations", - Long: `Scan for available MCP servers in various configuration files of these Applications on macOS: -VS Code, VS Code Insiders, Windsurf, Cursor, Claude Desktop, Claude Code`, - Run: func(cmd *cobra.Command, _ []string) { - servers, err := scanForServers() - if err != nil { - fmt.Fprintf(cmd.ErrOrStderr(), "Error scanning for servers: %v\n", err) - return - } - - // Table format (default) now uses the colored grouped display - if strings.ToLower(FormatOption) == "table" || strings.ToLower(FormatOption) == "pretty" { - output := formatColoredGroupedServers(servers) - fmt.Fprintln(cmd.OutOrStdout(), output) - return - } - - // For JSON format, use the grouped display - if strings.ToLower(FormatOption) == "json" { - output := formatSourceGroupedJSON(servers) - fmt.Fprintln(cmd.OutOrStdout(), output) - return - } - - // For other formats, use the full server data - output, err := jsonutils.Format(servers, FormatOption) - if err != nil { - fmt.Fprintf(cmd.ErrOrStderr(), "Error formatting output: %v\n", err) - return - } - - fmt.Fprintln(cmd.OutOrStdout(), output) - }, - } -} - -// formatSourceGroupedJSON formats servers grouped by source with raw JSON. -func formatSourceGroupedJSON(servers []ServerConfig) string { - if len(servers) == 0 { - return "No MCP servers found" - } - - // Group servers by source - serversBySource := make(map[string][]ServerConfig) - var sourceOrder []string - - for _, server := range servers { - if _, exists := serversBySource[server.Source]; !exists { - sourceOrder = append(sourceOrder, server.Source) - } - serversBySource[server.Source] = append(serversBySource[server.Source], server) - } - - // Sort sources - sort.Strings(sourceOrder) - - var buf bytes.Buffer - for _, source := range sourceOrder { - // Print source header - fmt.Fprintf(&buf, "%s:\n\n", source) - - // Reconstruct the original JSON structure - sourceServers := serversBySource[source] - - if strings.Contains(source, "VS Code") { - // VS Code format with mcp.servers - vsCodeJSON := map[string]interface{}{ - "mcp": map[string]interface{}{ - "servers": make(map[string]interface{}), - }, - } - - serversMap := vsCodeJSON["mcp"].(map[string]interface{})["servers"].(map[string]interface{}) - for _, server := range sourceServers { - serversMap[server.Name] = server.Config - } - - jsonData, _ := json.MarshalIndent(vsCodeJSON, "", " ") - fmt.Fprintf(&buf, "%s\n\n", string(jsonData)) - } else { - // Other formats with mcpServers - otherJSON := map[string]interface{}{ - "mcpServers": make(map[string]interface{}), - } - - serversMap := otherJSON["mcpServers"].(map[string]interface{}) - for _, server := range sourceServers { - serversMap[server.Name] = server.Config - } - - jsonData, _ := json.MarshalIndent(otherJSON, "", " ") - fmt.Fprintf(&buf, "%s\n\n", string(jsonData)) - } - } - - return buf.String() -} - -// formatColoredGroupedServers formats servers in a colored, grouped display by source. -func formatColoredGroupedServers(servers []ServerConfig) string { - if len(servers) == 0 { - return "No MCP servers found" - } - - // Group servers by source - serversBySource := make(map[string][]ServerConfig) - var sourceOrder []string - - for _, server := range servers { - if _, exists := serversBySource[server.Source]; !exists { - sourceOrder = append(sourceOrder, server.Source) - } - serversBySource[server.Source] = append(serversBySource[server.Source], server) - } - - // Sort sources - sort.Strings(sourceOrder) - - var buf bytes.Buffer - // Check if we're outputting to a terminal (for colors) - useColors := term.IsTerminal(int(os.Stdout.Fd())) - - for _, source := range sourceOrder { - // Print source header with bold blue - if useColors { - fmt.Fprintf(&buf, "%s%s%s\n", jsonutils.ColorBold+jsonutils.ColorBlue, source, jsonutils.ColorReset) - } else { - fmt.Fprintf(&buf, "%s\n", source) - } - - servers := serversBySource[source] - // Sort servers by name - sort.Slice(servers, func(i, j int) bool { - return servers[i].Name < servers[j].Name - }) - - for _, server := range servers { - // Determine server type - serverType := server.Type - if serverType == "" { - if server.URL != "" { - serverType = "sse" //nolint - } else { - serverType = "stdio" - } - } - - // Print server name and type - if useColors { - fmt.Fprintf(&buf, " %s%s%s", jsonutils.ColorBold+jsonutils.ColorPurple, server.Name, jsonutils.ColorReset) - fmt.Fprintf(&buf, " %s(%s)%s:", jsonutils.ColorBold+jsonutils.ColorCyan, serverType, jsonutils.ColorReset) - } else { - fmt.Fprintf(&buf, " %s (%s):", server.Name, serverType) - } - - // Print command or URL - if serverType == "sse" { - if useColors { - fmt.Fprintf(&buf, "\n %s%s%s\n", jsonutils.ColorGreen, server.URL, jsonutils.ColorReset) - } else { - fmt.Fprintf(&buf, "\n %s\n", server.URL) - } - - // Print headers for SSE servers - if len(server.Headers) > 0 { - // Get sorted header keys - var headerKeys []string - for k := range server.Headers { - headerKeys = append(headerKeys, k) - } - sort.Strings(headerKeys) - - // Print each header - for _, k := range headerKeys { - if useColors { - fmt.Fprintf(&buf, " %s%s%s: %s\n", jsonutils.ColorYellow, k, jsonutils.ColorReset, server.Headers[k]) - } else { - fmt.Fprintf(&buf, " %s: %s\n", k, server.Headers[k]) - } - } - } - } else { - // Print command and args - commandStr := server.Command - - // Add args with quotes for ones containing spaces - if len(server.Args) > 0 { - commandStr += " " - quotedArgs := make([]string, len(server.Args)) - for i, arg := range server.Args { - if strings.Contains(arg, " ") && !strings.HasPrefix(arg, "\"") && !strings.HasSuffix(arg, "\"") { - quotedArgs[i] = "\"" + arg + "\"" - } else { - quotedArgs[i] = arg - } - } - commandStr += strings.Join(quotedArgs, " ") - } - - if useColors { - fmt.Fprintf(&buf, "\n %s%s%s\n", jsonutils.ColorGreen, commandStr, jsonutils.ColorReset) - } else { - fmt.Fprintf(&buf, "\n %s\n", commandStr) - } - - // Print env vars - if len(server.Env) > 0 { - // Get sorted env keys - var envKeys []string - for k := range server.Env { - envKeys = append(envKeys, k) - } - sort.Strings(envKeys) - - // Print each env var - for _, k := range envKeys { - if useColors { - fmt.Fprintf(&buf, " %s%s%s: %s\n", jsonutils.ColorYellow, k, jsonutils.ColorReset, server.Env[k]) - } else { - fmt.Fprintf(&buf, " %s: %s\n", k, server.Env[k]) - } - } - } - } - - // Add a newline after each server for readability - fmt.Fprintln(&buf) - } - } - - return buf.String() -} - -// ServerConfig represents a configuration for a server. -type ServerConfig struct { - Headers map[string]string `json:"headers,omitempty"` - Env map[string]string `json:"env,omitempty"` - Config map[string]interface{} `json:"config,omitempty"` - Source string `json:"source"` - Type string `json:"type,omitempty"` - Command string `json:"command,omitempty"` - URL string `json:"url,omitempty"` - Path string `json:"path,omitempty"` - Name string `json:"name,omitempty"` - Description string `json:"description,omitempty"` - Args []string `json:"args,omitempty"` -} - -// scanForServers scans various configuration files for MCP servers. -func scanForServers() ([]ServerConfig, error) { - homeDir, err := os.UserHomeDir() - if err != nil { - return nil, fmt.Errorf("failed to get user home directory: %w", err) - } - - var servers []ServerConfig - - // Scan VS Code Insiders - vscodeInsidersPath := filepath.Join(homeDir, "Library", "Application Support", "Code - Insiders", "User", "settings.json") - vscodeServers, err := scanVSCodeConfig(vscodeInsidersPath, "VS Code Insiders") - if err == nil { - servers = append(servers, vscodeServers...) - } - - // Scan VS Code - vscodePath := filepath.Join(homeDir, "Library", "Application Support", "Code", "User", "settings.json") - vscodeServers, err = scanVSCodeConfig(vscodePath, "VS Code") - if err == nil { - servers = append(servers, vscodeServers...) - } - - // Scan Windsurf - windsurfPath := filepath.Join(homeDir, ".codeium", "windsurf", "mcp_config.json") - windsurfServers, err := scanMCPServersConfig(windsurfPath, "Windsurf") - if err == nil { - servers = append(servers, windsurfServers...) - } - - // Scan Cursor - cursorPath := filepath.Join(homeDir, ".cursor", "mcp.json") - cursorServers, err := scanMCPServersConfig(cursorPath, "Cursor") - if err == nil { - servers = append(servers, cursorServers...) - } - - // Scan Claude Desktop - claudeDesktopPath := filepath.Join(homeDir, "Library", "Application Support", "Claude", "claude_desktop_config.json") - claudeServers, err := scanMCPServersConfig(claudeDesktopPath, "Claude Desktop") - if err == nil { - servers = append(servers, claudeServers...) - } - - // Scan Claude Code - claudeCodePath := filepath.Join(homeDir, ".claude.json") - claudeCodeServers, err := scanMCPServersConfig(claudeCodePath, "Claude Code") - if err == nil { - servers = append(servers, claudeCodeServers...) - } - - return servers, nil -} - -// scanVSCodeConfig scans a VS Code settings.json file for MCP servers. -func scanVSCodeConfig(path, source string) ([]ServerConfig, error) { - data, err := os.ReadFile(path) //nolint - if err != nil { - return nil, fmt.Errorf("failed to read %s config: %w", source, err) - } - - var settings map[string]interface{} - if err := json.Unmarshal(data, &settings); err != nil { - return nil, fmt.Errorf("failed to parse %s settings.json: %w", source, err) - } - - mcpObject, ok := settings["mcp"] - if !ok { - return nil, fmt.Errorf("no mcp configuration found in %s settings", source) - } - - mcpMap, ok := mcpObject.(map[string]interface{}) - if !ok { - return nil, fmt.Errorf("invalid mcp format in %s settings", source) - } - - mcpServers, ok := mcpMap["servers"] - if !ok { - return nil, fmt.Errorf("no mcp.servers found in %s settings", source) - } - - servers, ok := mcpServers.(map[string]interface{}) - if !ok { - return nil, fmt.Errorf("invalid mcp.servers format in %s settings", source) - } - - var result []ServerConfig - for name, config := range servers { - serverConfig, ok := config.(map[string]interface{}) - if !ok { - continue - } - - // Extract common properties - serverType, _ := serverConfig["type"].(string) - command, _ := serverConfig["command"].(string) - description, _ := serverConfig["description"].(string) - url, _ := serverConfig["url"].(string) - - // Extract args if available - var args []string - if argsInterface, ok := serverConfig["args"].([]interface{}); ok { - for _, arg := range argsInterface { - if argStr, ok := arg.(string); ok { - args = append(args, argStr) - } - } - } - - // Extract headers if available - headers := make(map[string]string) - if headersInterface, ok := serverConfig["headers"].(map[string]interface{}); ok { - for k, v := range headersInterface { - if valStr, ok := v.(string); ok { - headers[k] = valStr - } - } - } - - // Extract env if available - env := make(map[string]string) - if envInterface, ok := serverConfig["env"].(map[string]interface{}); ok { - for k, v := range envInterface { - if valStr, ok := v.(string); ok { - env[k] = valStr - } - } - } - - result = append(result, ServerConfig{ - Source: source, - Type: serverType, - Command: command, - Args: args, - URL: url, - Headers: headers, - Env: env, - Name: name, - Config: serverConfig, - Description: description, - }) - } - - return result, nil -} - -// scanMCPServersConfig scans a config file with mcpServers as the top-level key. -func scanMCPServersConfig(path, source string) ([]ServerConfig, error) { - data, err := os.ReadFile(path) //nolint - if err != nil { - return nil, fmt.Errorf("failed to read %s config: %w", source, err) - } - - var config map[string]interface{} - if err := json.Unmarshal(data, &config); err != nil { - return nil, fmt.Errorf("failed to parse %s config: %w", source, err) - } - - mcpServers, ok := config["mcpServers"] - if !ok { - return nil, fmt.Errorf("no mcpServers key found in %s config", source) - } - - servers, ok := mcpServers.(map[string]interface{}) - if !ok { - return nil, fmt.Errorf("invalid mcpServers format in %s config", source) - } - - var result []ServerConfig - for name, serverData := range servers { - serverConfig, ok := serverData.(map[string]interface{}) - if !ok { - continue - } - - // Extract common properties - command, _ := serverConfig["command"].(string) - url, _ := serverConfig["url"].(string) - - // Extract args if available - var args []string - if argsInterface, ok := serverConfig["args"].([]interface{}); ok { - for _, arg := range argsInterface { - if argStr, ok := arg.(string); ok { - args = append(args, argStr) - } - } - } - - // Extract headers if available - headers := make(map[string]string) - if headersInterface, ok := serverConfig["headers"].(map[string]interface{}); ok { - for k, v := range headersInterface { - if valStr, ok := v.(string); ok { - headers[k] = valStr - } - } - } - - // Extract env if available - env := make(map[string]string) - if envInterface, ok := serverConfig["env"].(map[string]interface{}); ok { - for k, v := range envInterface { - if valStr, ok := v.(string); ok { - env[k] = valStr - } - } - } - - // Determine type based on whether URL is present - serverType := "" - if url != "" { - serverType = "sse" - } - - result = append(result, ServerConfig{ - Source: source, - Type: serverType, - Command: command, - Args: args, - URL: url, - Headers: headers, - Env: env, - Name: name, - Config: serverConfig, - }) - } - - return result, nil -} diff --git a/cmd/mcptools/main.go b/cmd/mcptools/main.go index 8711bec..0f3245a 100644 --- a/cmd/mcptools/main.go +++ b/cmd/mcptools/main.go @@ -37,7 +37,6 @@ func main() { commands.MockCmd(), commands.ProxyCmd(), commands.AliasCmd(), - commands.ScanCmd(), commands.ConfigsCmd(), commands.NewCmd(), ) From 0866d2a2b5110ec3a67aac956d76f9c326c02e0e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fatih=20Kadir=20Ak=C4=B1n?= Date: Tue, 8 Apr 2025 23:40:20 +0300 Subject: [PATCH 3/7] add config management --- README.md | 42 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/README.md b/README.md index 576bff9..a9f7e5e 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,7 @@ - [Interactive Shell](#interactive-shell) - [Project Scaffolding](#project-scaffolding) - [Server Aliases](#server-aliases) +- [Config Management](#config-management) - [Server Modes](#server-modes) - [Mock Server Mode](#mock-server-mode) - [Proxy Mode](#proxy-mode) @@ -397,6 +398,47 @@ mcp call read_file --params '{"path":"README.md"}' myfs Server aliases are stored in `$HOME/.mcpt/aliases.json` and provide a convenient way to work with commonly used MCP servers without typing long commands repeatedly. +## Config Management + +MCP Tools provides a powerful configuration management system that helps you work with MCP server configurations across multiple applications: + +> 🚧 This works only on macOS for now. + +```bash +# Scan for MCP server configurations across all supported applications +mcp configs scan + +# List all configurations (alias for configs view --all) +mcp configs ls + +# View specific configuration by alias +mcp configs view vscode + +# Add or update a server in a configuration +mcp configs set vscode my-server npm run mcp-server +mcp configs set cursor my-api https://api.example.com/mcp --headers "Authorization=Bearer token" + +# Add to multiple configurations at once +mcp configs set vscode,cursor,claude-desktop my-server npm run mcp-server + +# Remove a server from a configuration +mcp configs remove vscode my-server + +# Create an alias for a custom config file +mcp configs alias myapp ~/myapp/config.json + +# Synchronize and merge configurations from multiple sources +mcp configs sync vscode cursor --output vscode --default interactive +``` + +Configurations are managed through a central registry in `$HOME/.mcpt/configs.json` with predefined aliases for: +- VS Code and VS Code Insiders +- Windsurf +- Cursor +- Claude Desktop and Claude Code + +The system automatically displays server configurations in a colorized format grouped by source, showing command-line or URL information, headers, and environment variables. + ## Server Modes MCP Tools can operate as both a client and a server, with two server modes available: From f32e6007757a1e7515e084c86bf0685dfb0c04eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fatih=20Kadir=20Ak=C4=B1n?= Date: Tue, 8 Apr 2025 23:46:06 +0300 Subject: [PATCH 4/7] add as-json --- README.md | 8 +++ cmd/mcptools/commands/configs.go | 115 +++++++++++++++++++++++++++++++ 2 files changed, 123 insertions(+) diff --git a/README.md b/README.md index a9f7e5e..86d04ec 100644 --- a/README.md +++ b/README.md @@ -429,6 +429,14 @@ mcp configs alias myapp ~/myapp/config.json # Synchronize and merge configurations from multiple sources mcp configs sync vscode cursor --output vscode --default interactive + +# Convert a command line to MCP server JSON configuration format +mcp configs as-json mcp proxy start +# Output: {"command":"mcp","args":["proxy","start"]} + +# Convert a URL to MCP server JSON configuration format +mcp configs as-json https://api.example.com/mcp --headers "Authorization=Bearer token" +# Output: {"url":"https://api.example.com/mcp","headers":{"Authorization":"Bearer token"}} ``` Configurations are managed through a central registry in `$HOME/.mcpt/configs.json` with predefined aliases for: diff --git a/cmd/mcptools/commands/configs.go b/cmd/mcptools/commands/configs.go index 2c2e987..3051185 100644 --- a/cmd/mcptools/commands/configs.go +++ b/cmd/mcptools/commands/configs.go @@ -1637,5 +1637,120 @@ VS Code, VS Code Insiders, Windsurf, Cursor, Claude Desktop, Claude Code`, // Add subcommands to the configs command cmd.AddCommand(lsCmd, viewCmd, setCmd, removeCmd, aliasCmd, syncCmd, scanCmd) + // Add the as-json subcommand + asJSONCmd := &cobra.Command{ + Use: "as-json [command/url] [args...]", + Short: "Convert a command or URL to MCP server JSON configuration", + Long: `Convert a command line or URL to a JSON configuration that can be used for MCP servers.`, + Args: cobra.MinimumNArgs(1), + // Disable flag parsing after the first arguments to handle command args properly + DisableFlagParsing: true, + Run: func(cmd *cobra.Command, args []string) { + // We need to manually extract the flags we care about + var headers string + var env string + + // Create cleaned arguments (without our flags) + cleanedArgs := make([]string, 0, len(args)) + + // Process all args to extract our flags and build clean args + i := 0 + for i < len(args) { + arg := args[i] + + // Handle both --flag=value and --flag value formats + if strings.HasPrefix(arg, "--headers=") { + headers = strings.TrimPrefix(arg, "--headers=") + i++ + continue + } else if arg == "--headers" && i+1 < len(args) { + headers = args[i+1] + i += 2 + continue + } + + if strings.HasPrefix(arg, "--env=") { + env = strings.TrimPrefix(arg, "--env=") + i++ + continue + } else if arg == "--env" && i+1 < len(args) { + env = args[i+1] + i += 2 + continue + } + + // If none of our flags, add to cleaned args + cleanedArgs = append(cleanedArgs, arg) + i++ + } + + // Make sure we have at least one argument + if len(cleanedArgs) < 1 { + fmt.Fprintf(cmd.ErrOrStderr(), "Error: as-json command requires at least one argument (command or URL)\n") + return + } + + // Determine if first argument is a URL + firstArg := cleanedArgs[0] + isURL := strings.HasPrefix(firstArg, "http://") || strings.HasPrefix(firstArg, "https://") + + // Create the server configuration + serverConfig := make(map[string]interface{}) + + if isURL { + // URL-based server + serverConfig["url"] = firstArg + + // Parse headers if provided + if headers != "" { + headersMap, err := parseKeyValueOption(headers) + if err != nil { + fmt.Fprintf(cmd.ErrOrStderr(), "Error parsing headers: %v\n", err) + return + } + if len(headersMap) > 0 { + serverConfig["headers"] = headersMap + } + } + } else { + // Command-based server + serverConfig["command"] = firstArg + + // Add command args if provided + if len(cleanedArgs) > 1 { + serverConfig["args"] = cleanedArgs[1:] + } + } + + // Parse environment variables if provided (for both URL and command) + if env != "" { + envMap, err := parseKeyValueOption(env) + if err != nil { + fmt.Fprintf(cmd.ErrOrStderr(), "Error parsing environment variables: %v\n", err) + return + } + if len(envMap) > 0 { + serverConfig["env"] = envMap + } + } + + // Output the JSON configuration + output, err := json.Marshal(serverConfig) + if err != nil { + fmt.Fprintf(cmd.ErrOrStderr(), "Error generating JSON: %v\n", err) + return + } + + fmt.Fprintln(cmd.OutOrStdout(), string(output)) + }, + } + + // Add flags to the as-json command - these are just for documentation since we do manual parsing + asJSONCmd.Flags().StringVar(&HeadersOption, "headers", "", "Headers for URL-based servers (comma-separated key=value pairs)") + asJSONCmd.Flags().StringVar(&EnvOption, "env", "", "Environment variables (comma-separated key=value pairs)") + + // Add the as-json command to the main command + cmd.AddCommand(asJSONCmd) + return cmd } From 4b4bec3784ef1de639eabe5d08eb9b08a7c4534c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fatih=20Kadir=20Ak=C4=B1n?= Date: Wed, 9 Apr 2025 00:03:34 +0300 Subject: [PATCH 5/7] add as-json --- cmd/mcptools/commands/configs.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/mcptools/commands/configs.go b/cmd/mcptools/commands/configs.go index 3051185..7412ef4 100644 --- a/cmd/mcptools/commands/configs.go +++ b/cmd/mcptools/commands/configs.go @@ -1735,7 +1735,7 @@ VS Code, VS Code Insiders, Windsurf, Cursor, Claude Desktop, Claude Code`, } // Output the JSON configuration - output, err := json.Marshal(serverConfig) + output, err := json.MarshalIndent(serverConfig, "", " ") if err != nil { fmt.Fprintf(cmd.ErrOrStderr(), "Error generating JSON: %v\n", err) return From 805b12999656aa22cb6617e888f95efba5243c13 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fatih=20Kadir=20Ak=C4=B1n?= Date: Wed, 9 Apr 2025 00:04:31 +0300 Subject: [PATCH 6/7] update --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 86d04ec..6ae87a4 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,7 @@ - [Interactive Shell](#interactive-shell) - [Project Scaffolding](#project-scaffolding) - [Server Aliases](#server-aliases) -- [Config Management](#config-management) +- [LLM Apps Config Management](#llm-apps-config-management) - [Server Modes](#server-modes) - [Mock Server Mode](#mock-server-mode) - [Proxy Mode](#proxy-mode) @@ -398,7 +398,7 @@ mcp call read_file --params '{"path":"README.md"}' myfs Server aliases are stored in `$HOME/.mcpt/aliases.json` and provide a convenient way to work with commonly used MCP servers without typing long commands repeatedly. -## Config Management +## LLM Apps Config Management MCP Tools provides a powerful configuration management system that helps you work with MCP server configurations across multiple applications: From f5e76bf542a1d148d348ec55304bf62ec8ca1fc1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fatih=20Kadir=20Ak=C4=B1n?= Date: Wed, 9 Apr 2025 00:09:18 +0300 Subject: [PATCH 7/7] update readme --- README.md | 69 +++++++++++++++++++------------------------------------ 1 file changed, 24 insertions(+), 45 deletions(-) diff --git a/README.md b/README.md index 6ae87a4..c9fcbff 100644 --- a/README.md +++ b/README.md @@ -112,8 +112,8 @@ Available Commands: shell Start an interactive shell for MCP commands mock Create a mock MCP server with tools, prompts, and resources proxy Proxy MCP tool requests to shell scripts - scan Scan for available MCP servers in various applications alias Manage MCP server aliases + configs Manage MCP server configurations new Create a new MCP project component help Help about any command completion Generate the autocompletion script for the specified shell @@ -122,6 +122,8 @@ Flags: -f, --format string Output format (table, json, pretty) (default "table") -h, --help help for mcp -p, --params string JSON string of parameters to pass to the tool (for call command) (default "{}") + +Use "mcp [command] --help" for more information about a command. ``` ### Transport Options @@ -238,50 +240,6 @@ mcp call resource:my-resource npx -y @modelcontextprotocol/server-filesystem ~ mcp call prompt:my-prompt --params '{"name":"John"}' npx -y @modelcontextprotocol/server-filesystem ~ ``` -#### Scan for MCP Servers - -The scan command searches for MCP server configurations across multiple applications: - -```bash -# Scan for all MCP servers in supported applications -mcp scan - -# Display in JSON format grouped by application -mcp scan -f json - -# Display in colorized format (default) -mcp scan -f table -``` - -The scan command looks for MCP server configurations in: -- Visual Studio Code -- Visual Studio Code Insiders -- Windsurf -- Cursor -- Claude Desktop - -For each server, it displays: -- Server source (application) -- Server name -- Server type (stdio or sse) -- Command and arguments or URL -- Environment variables (for stdio servers) -- Headers (for sse servers) - -Example output: -``` -VS Code Insiders - GitHub (stdio): - docker run -i --rm -e GITHUB_PERSONAL_ACCESS_TOKEN ghcr.io/github/github-mcp-server - -Claude Desktop - Proxy (stdio): - mcp proxy start - - My Files (stdio): - npx -y @modelcontextprotocol/server-filesystem ~/ -``` - ### Interactive Shell The interactive shell mode allows you to run multiple MCP commands in a single session: @@ -447,6 +405,27 @@ Configurations are managed through a central registry in `$HOME/.mcpt/configs.js The system automatically displays server configurations in a colorized format grouped by source, showing command-line or URL information, headers, and environment variables. +`mcp configs scan` command looks for MCP server configurations in: +- Visual Studio Code +- Visual Studio Code Insiders +- Windsurf +- Cursor +- Claude Desktop + +Example Output: +``` +VS Code Insiders + GitHub (stdio): + docker run -i --rm -e GITHUB_PERSONAL_ACCESS_TOKEN ghcr.io/github/github-mcp-server + +Claude Desktop + Proxy (stdio): + mcp proxy start + + My Files (stdio): + npx -y @modelcontextprotocol/server-filesystem ~/ +``` + ## Server Modes MCP Tools can operate as both a client and a server, with two server modes available: