package mcp import ( "encoding/json" "fmt" "os" "os/exec" "path/filepath" "strings" "time" "github.com/muyue/muyue/internal/config" ) type MCPServer struct { Name string `json:"name"` Command string `json:"command"` Args []string `json:"args"` Env map[string]string `json:"env,omitempty"` Installed bool `json:"installed"` Category string `json:"category"` Description string `json:"description,omitempty"` Version string `json:"version,omitempty"` Status string `json:"status,omitempty"` } type mcpEntry struct { name string cmd string args []string env map[string]string } var knownMCPServers = []MCPServer{ {Name: "filesystem", Command: "npx", Args: []string{"-y", "@modelcontextprotocol/server-filesystem"}, Category: "core"}, {Name: "github", Command: "npx", Args: []string{"-y", "@modelcontextprotocol/server-github"}, Env: map[string]string{"GITHUB_PERSONAL_ACCESS_TOKEN": ""}, Category: "vcs"}, {Name: "git", Command: "npx", Args: []string{"-y", "@modelcontextprotocol/server-git"}, Category: "vcs"}, {Name: "fetch", Command: "npx", Args: []string{"-y", "@modelcontextprotocol/server-fetch"}, Category: "web"}, {Name: "memory", Command: "npx", Args: []string{"-y", "@modelcontextprotocol/server-memory"}, Category: "core"}, {Name: "sequential-thinking", Command: "npx", Args: []string{"-y", "@modelcontextprotocol/server-sequential-thinking"}, Category: "ai"}, {Name: "brave-search", Command: "npx", Args: []string{"-y", "@modelcontextprotocol/server-brave-search"}, Env: map[string]string{"BRAVE_API_KEY": ""}, Category: "web"}, {Name: "sqlite", Command: "npx", Args: []string{"-y", "@modelcontextprotocol/server-sqlite"}, Category: "database"}, {Name: "postgres", Command: "npx", Args: []string{"-y", "@modelcontextprotocol/server-postgres"}, Category: "database"}, {Name: "docker", Command: "npx", Args: []string{"-y", "@modelcontextprotocol/server-docker"}, Category: "devops"}, {Name: "minimax-web-search", Command: "npx", Args: []string{"-y", "@minimax/mcp-web-search"}, Env: map[string]string{"MINIMAX_API_KEY": ""}, Category: "ai"}, {Name: "minimax-image", Command: "npx", Args: []string{"-y", "@minimax/mcp-image-understanding"}, Env: map[string]string{"MINIMAX_API_KEY": ""}, Category: "ai"}, } func ScanServers() []MCPServer { servers := make([]MCPServer, len(knownMCPServers)) for i, s := range knownMCPServers { servers[i] = s _, err := exec.LookPath(s.Command) servers[i].Installed = err == nil servers[i].Version = GetInstalledVersion(s.Name) } regServers, err := scanRegistryServers() if err == nil { servers = append(servers, regServers...) } return servers } func scanRegistryServers() ([]MCPServer, error) { reg, err := LoadRegistry() if err != nil { return nil, err } knownNames := map[string]bool{} for _, s := range knownMCPServers { knownNames[s.Name] = true } var servers []MCPServer for _, rs := range reg.Servers { if knownNames[rs.Name] { continue } servers = append(servers, MCPServer{ Name: rs.Name, Command: rs.Command, Args: rs.Args, Env: rs.Env, Category: rs.Category, Description: rs.Description, Installed: isCommandAvailable(rs.Command), Version: GetInstalledVersion(rs.Name), }) } return servers, nil } func isCommandAvailable(cmd string) bool { _, err := exec.LookPath(cmd) return err == nil } func getCoreEntries(homeDir string) []mcpEntry { return []mcpEntry{ {"filesystem", "npx", []string{"-y", "@modelcontextprotocol/server-filesystem", filepath.Join(homeDir, "projects")}, nil}, {"fetch", "npx", []string{"-y", "@modelcontextprotocol/server-fetch"}, nil}, {"memory", "npx", []string{"-y", "@modelcontextprotocol/server-memory"}, nil}, } } func withProviderEntries(base []mcpEntry, cfg *config.MuyueConfig, extraEntries []mcpEntry) []mcpEntry { entries := make([]mcpEntry, len(base)) copy(entries, base) entries = append(entries, extraEntries...) if cfg != nil { for _, p := range cfg.AI.Providers { if p.Name == "minimax" && p.APIKey != "" { entries = append(entries, mcpEntry{"minimax-web-search", "npx", []string{"-y", "@minimax/mcp-web-search"}, map[string]string{"MINIMAX_API_KEY": p.APIKey}}, mcpEntry{"minimax-image", "npx", []string{"-y", "@minimax/mcp-image-understanding"}, map[string]string{"MINIMAX_API_KEY": p.APIKey}}, ) } } } return entries } func writeMCPConfig(configPath string, mcpKey string, entries []mcpEntry) error { configDir := filepath.Dir(configPath) if err := os.MkdirAll(configDir, 0700); err != nil { return fmt.Errorf("create config dir: %w", err) } existing := map[string]interface{}{} data, err := os.ReadFile(configPath) if err == nil { if err := json.Unmarshal(data, &existing); err != nil { return fmt.Errorf("parse existing config: %w", err) } } mcpMap := map[string]interface{}{} for _, e := range entries { entry := map[string]interface{}{ "command": e.cmd, "args": e.args, } if len(e.env) > 0 { resolved := ResolveEnv(e.env, nil) entry["env"] = resolved } mcpMap[e.name] = entry } existing[mcpKey] = mcpMap out, err := json.MarshalIndent(existing, "", " ") if err != nil { return err } if err := os.WriteFile(configPath, out, 0600); err != nil { return err } return ValidateConfig(configPath) } func writeMCPConfigForEditor(editor EditorConfig, entries []mcpEntry) error { configDir := filepath.Dir(editor.ConfigPath) if err := os.MkdirAll(configDir, 0700); err != nil { return fmt.Errorf("create config dir %s: %w", editor.Name, err) } existing := map[string]interface{}{} data, err := os.ReadFile(editor.ConfigPath) if err == nil { _ = json.Unmarshal(data, &existing) } mcpMap := map[string]interface{}{} for _, e := range entries { if editor.TransformCommand != nil { mcpMap[e.name] = editor.TransformCommand(e) } else { entry := map[string]interface{}{ "command": e.cmd, "args": e.args, } if len(e.env) > 0 { entry["env"] = e.env } mcpMap[e.name] = entry } } existing[editor.ConfigKey] = mcpMap out, err := json.MarshalIndent(existing, "", " ") if err != nil { return err } return os.WriteFile(editor.ConfigPath, out, 0600) } func GenerateCrushMCPConfig(cfg *config.MuyueConfig, homeDir string) error { if homeDir == "" { home, _ := os.UserHomeDir() homeDir = home } core := getCoreEntries(homeDir) entries := withProviderEntries(core, cfg, nil) configPath := filepath.Join(homeDir, ".config", "crush", "crush.json") return writeMCPConfig(configPath, "mcps", entries) } func GenerateClaudeMCPConfig(cfg *config.MuyueConfig, homeDir string) error { if homeDir == "" { home, _ := os.UserHomeDir() homeDir = home } core := getCoreEntries(homeDir) extra := []mcpEntry{ {"sequential-thinking", "npx", []string{"-y", "@modelcontextprotocol/server-sequential-thinking"}, nil}, } entries := withProviderEntries(core, cfg, extra) configPath := filepath.Join(homeDir, ".claude.json") return writeMCPConfig(configPath, "mcpServers", entries) } func GenerateCursorMCPConfig(cfg *config.MuyueConfig, homeDir string) error { if homeDir == "" { home, _ := os.UserHomeDir() homeDir = home } core := getCoreEntries(homeDir) entries := withProviderEntries(core, cfg, nil) editor := EditorConfig{ Name: "cursor", ConfigPath: filepath.Join(homeDir, ".cursor", "mcp.json"), ConfigKey: "mcpServers", Format: "json", TransformCommand: func(e mcpEntry) interface{} { m := map[string]interface{}{ "type": "stdio", "command": e.cmd, "args": e.args, } if len(e.env) > 0 { m["env"] = e.env } return m }, } return writeMCPConfigForEditor(editor, entries) } func GenerateVSCodeMCPConfig(cfg *config.MuyueConfig, homeDir string) error { if homeDir == "" { home, _ := os.UserHomeDir() homeDir = home } core := getCoreEntries(homeDir) entries := withProviderEntries(core, cfg, nil) editor := EditorConfig{ Name: "vscode", ConfigPath: filepath.Join(homeDir, ".vscode", "mcp.json"), ConfigKey: "servers", Format: "json", } return writeMCPConfigForEditor(editor, entries) } func GenerateWindsurfMCPConfig(cfg *config.MuyueConfig, homeDir string) error { if homeDir == "" { home, _ := os.UserHomeDir() homeDir = home } core := getCoreEntries(homeDir) entries := withProviderEntries(core, cfg, nil) editor := EditorConfig{ Name: "windsurf", ConfigPath: filepath.Join(homeDir, ".windsurf", "mcp.json"), ConfigKey: "mcpServers", Format: "json", } return writeMCPConfigForEditor(editor, entries) } func ConfigureAll(cfg *config.MuyueConfig) error { home, err := os.UserHomeDir() if err != nil { return fmt.Errorf("get home dir: %w", err) } editors := []struct { name string fn func(*config.MuyueConfig, string) error }{ {"crush", GenerateCrushMCPConfig}, {"claude", GenerateClaudeMCPConfig}, {"cursor", GenerateCursorMCPConfig}, {"vscode", GenerateVSCodeMCPConfig}, {"windsurf", GenerateWindsurfMCPConfig}, } var errs []string for _, e := range editors { if err := e.fn(cfg, home); err != nil { errs = append(errs, fmt.Sprintf("%s: %s", e.name, err)) } } SaveReceipt("all", time.Now().Format("2006-01-02")) if len(errs) > 0 { return fmt.Errorf("MCP config errors: %s", strings.Join(errs, "; ")) } return nil } func ConfigureForEditor(cfg *config.MuyueConfig, editorName string) error { home, err := os.UserHomeDir() if err != nil { return fmt.Errorf("get home dir: %w", err) } switch editorName { case "crush": return GenerateCrushMCPConfig(cfg, home) case "claude", "claude-code": return GenerateClaudeMCPConfig(cfg, home) case "cursor": return GenerateCursorMCPConfig(cfg, home) case "vscode", "code": return GenerateVSCodeMCPConfig(cfg, home) case "windsurf": return GenerateWindsurfMCPConfig(cfg, home) default: return fmt.Errorf("unknown editor: %s (supported: crush, claude-code, cursor, vscode, windsurf)", editorName) } } func DetectInstalledEditors(homeDir string) []string { if homeDir == "" { home, _ := os.UserHomeDir() homeDir = home } editors := []struct { name string path string }{ {"crush", filepath.Join(homeDir, ".config", "crush", "crush.json")}, {"claude-code", filepath.Join(homeDir, ".claude.json")}, {"cursor", filepath.Join(homeDir, ".cursor")}, {"vscode", filepath.Join(homeDir, ".vscode")}, {"windsurf", filepath.Join(homeDir, ".windsurf")}, } var detected []string for _, e := range editors { if _, err := os.Stat(e.path); err == nil { detected = append(detected, e.name) } } return detected } func GetAllStatuses() []MCPStatus { servers := ScanServers() statuses := make([]MCPStatus, len(servers)) for i, s := range servers { statuses[i] = CheckServerStatus(s.Name) } return statuses }