All checks were successful
Beta Release / beta (push) Successful in 2m24s
Major changes: - Refactor CLI entry point to Cobra commands (root, setup, scan, doctor, install, update, lsp, mcp, skills, config, version) - Add LSP registry with health checks, auto-install, and editor config generation - Add MCP registry with editor detection, status tracking, and per-editor configuration - Add workflow engine with planner and step execution for automated task chains - Add conversation search, export (Markdown/JSON), and detailed token counting - Add streaming shell chat handler with tool call/result events - Add skill validation, dry-run testing, and export endpoints - Enrich dashboard with Tools/Activity/Status tabs and tool cards grid - Add PRD documentation - Complete i18n for both EN and FR 💘 Generated with Crush Assisted-by: GLM-5.1 via Crush <crush@charm.land>
384 lines
11 KiB
Go
384 lines
11 KiB
Go
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
|
|
}
|