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>
521 lines
15 KiB
Go
521 lines
15 KiB
Go
package mcp
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"gopkg.in/yaml.v3"
|
|
)
|
|
|
|
type RegistryServer struct {
|
|
Name string `yaml:"name" json:"name"`
|
|
Description string `yaml:"description" json:"description"`
|
|
Category string `yaml:"category" json:"category"`
|
|
Package string `yaml:"package" json:"package"`
|
|
Command string `yaml:"command" json:"command"`
|
|
Args []string `yaml:"args" json:"args"`
|
|
Env map[string]string `yaml:"env,omitempty" json:"env,omitempty"`
|
|
RequiredEnv []string `yaml:"required_env,omitempty" json:"required_env,omitempty"`
|
|
HomePage string `yaml:"homepage,omitempty" json:"homepage,omitempty"`
|
|
Tags []string `yaml:"tags,omitempty" json:"tags,omitempty"`
|
|
Version string `yaml:"version,omitempty" json:"version,omitempty"`
|
|
InstallType string `yaml:"install_type" json:"install_type"`
|
|
}
|
|
|
|
type Registry struct {
|
|
SchemaVersion string `yaml:"schema_version"`
|
|
UpdatedAt time.Time `yaml:"updated_at"`
|
|
Servers []RegistryServer `yaml:"servers"`
|
|
}
|
|
|
|
type MCPStatus struct {
|
|
Name string `json:"name"`
|
|
Installed bool `json:"installed"`
|
|
Running bool `json:"running"`
|
|
Healthy bool `json:"healthy"`
|
|
Version string `json:"version"`
|
|
Error string `json:"error,omitempty"`
|
|
}
|
|
|
|
type EditorConfig struct {
|
|
Name string
|
|
ConfigPath string
|
|
ConfigKey string
|
|
LocalConfigPath string
|
|
Format string
|
|
TransformCommand func(entry mcpEntry) interface{}
|
|
}
|
|
|
|
var (
|
|
registryMu sync.RWMutex
|
|
registryCache *Registry
|
|
registryPath string
|
|
)
|
|
|
|
func init() {
|
|
home, _ := os.UserHomeDir()
|
|
if home != "" {
|
|
registryPath = filepath.Join(home, ".muyue", "mcp-registry.yaml")
|
|
}
|
|
}
|
|
|
|
func SetRegistryPath(p string) {
|
|
registryMu.Lock()
|
|
defer registryMu.Unlock()
|
|
registryPath = p
|
|
registryCache = nil
|
|
}
|
|
|
|
func DefaultRegistry() *Registry {
|
|
return &Registry{
|
|
SchemaVersion: "v1",
|
|
UpdatedAt: time.Now(),
|
|
Servers: []RegistryServer{
|
|
{
|
|
Name: "filesystem", Description: "File system operations for AI tools",
|
|
Category: "core", Package: "@modelcontextprotocol/server-filesystem",
|
|
Command: "npx", Args: []string{"-y", "@modelcontextprotocol/server-filesystem"},
|
|
InstallType: "npm", Tags: []string{"files", "core"},
|
|
},
|
|
{
|
|
Name: "github", Description: "GitHub API integration",
|
|
Category: "vcs", Package: "@modelcontextprotocol/server-github",
|
|
Command: "npx", Args: []string{"-y", "@modelcontextprotocol/server-github"},
|
|
Env: map[string]string{"GITHUB_PERSONAL_ACCESS_TOKEN": ""},
|
|
RequiredEnv: []string{"GITHUB_PERSONAL_ACCESS_TOKEN"},
|
|
InstallType: "npm", Tags: []string{"github", "git"},
|
|
},
|
|
{
|
|
Name: "git", Description: "Git repository operations",
|
|
Category: "vcs", Package: "@modelcontextprotocol/server-git",
|
|
Command: "npx", Args: []string{"-y", "@modelcontextprotocol/server-git"},
|
|
InstallType: "npm", Tags: []string{"git"},
|
|
},
|
|
{
|
|
Name: "fetch", Description: "Web fetching and HTTP requests",
|
|
Category: "web", Package: "@modelcontextprotocol/server-fetch",
|
|
Command: "npx", Args: []string{"-y", "@modelcontextprotocol/server-fetch"},
|
|
InstallType: "npm", Tags: []string{"web", "http"},
|
|
},
|
|
{
|
|
Name: "memory", Description: "Persistent memory/knowledge graph",
|
|
Category: "core", Package: "@modelcontextprotocol/server-memory",
|
|
Command: "npx", Args: []string{"-y", "@modelcontextprotocol/server-memory"},
|
|
InstallType: "npm", Tags: []string{"memory", "core"},
|
|
},
|
|
{
|
|
Name: "sequential-thinking", Description: "Structured reasoning and chain-of-thought",
|
|
Category: "ai", Package: "@modelcontextprotocol/server-sequential-thinking",
|
|
Command: "npx", Args: []string{"-y", "@modelcontextprotocol/server-sequential-thinking"},
|
|
InstallType: "npm", Tags: []string{"ai", "reasoning"},
|
|
},
|
|
{
|
|
Name: "brave-search", Description: "Web search via Brave Search API",
|
|
Category: "web", Package: "@modelcontextprotocol/server-brave-search",
|
|
Command: "npx", Args: []string{"-y", "@modelcontextprotocol/server-brave-search"},
|
|
Env: map[string]string{"BRAVE_API_KEY": ""},
|
|
RequiredEnv: []string{"BRAVE_API_KEY"},
|
|
InstallType: "npm", Tags: []string{"search", "web"},
|
|
},
|
|
{
|
|
Name: "sqlite", Description: "SQLite database operations",
|
|
Category: "database", Package: "@modelcontextprotocol/server-sqlite",
|
|
Command: "npx", Args: []string{"-y", "@modelcontextprotocol/server-sqlite"},
|
|
InstallType: "npm", Tags: []string{"database", "sqlite"},
|
|
},
|
|
{
|
|
Name: "postgres", Description: "PostgreSQL database operations",
|
|
Category: "database", Package: "@modelcontextprotocol/server-postgres",
|
|
Command: "npx", Args: []string{"-y", "@modelcontextprotocol/server-postgres"},
|
|
InstallType: "npm", Tags: []string{"database", "postgres"},
|
|
},
|
|
{
|
|
Name: "docker", Description: "Docker container management",
|
|
Category: "devops", Package: "@modelcontextprotocol/server-docker",
|
|
Command: "npx", Args: []string{"-y", "@modelcontextprotocol/server-docker"},
|
|
InstallType: "npm", Tags: []string{"docker", "devops"},
|
|
},
|
|
{
|
|
Name: "minimax-web-search", Description: "Web search via MiniMax API",
|
|
Category: "ai", Package: "@minimax/mcp-web-search",
|
|
Command: "npx", Args: []string{"-y", "@minimax/mcp-web-search"},
|
|
Env: map[string]string{"MINIMAX_API_KEY": ""},
|
|
RequiredEnv: []string{"MINIMAX_API_KEY"},
|
|
InstallType: "npm", Tags: []string{"ai", "search"},
|
|
},
|
|
{
|
|
Name: "minimax-image", Description: "Image understanding via MiniMax API",
|
|
Category: "ai", Package: "@minimax/mcp-image-understanding",
|
|
Command: "npx", Args: []string{"-y", "@minimax/mcp-image-understanding"},
|
|
Env: map[string]string{"MINIMAX_API_KEY": ""},
|
|
RequiredEnv: []string{"MINIMAX_API_KEY"},
|
|
InstallType: "npm", Tags: []string{"ai", "image"},
|
|
},
|
|
{
|
|
Name: "puppeteer", Description: "Browser automation with Puppeteer",
|
|
Category: "web", Package: "@modelcontextprotocol/server-puppeteer",
|
|
Command: "npx", Args: []string{"-y", "@modelcontextprotocol/server-puppeteer"},
|
|
InstallType: "npm", Tags: []string{"browser", "automation"},
|
|
},
|
|
{
|
|
Name: "everything", Description: "Test/debug MCP server with all features",
|
|
Category: "testing", Package: "@modelcontextprotocol/server-everything",
|
|
Command: "npx", Args: []string{"-y", "@modelcontextprotocol/server-everything"},
|
|
InstallType: "npm", Tags: []string{"testing", "debug"},
|
|
},
|
|
{
|
|
Name: "slack", Description: "Slack workspace integration",
|
|
Category: "communication", Package: "@modelcontextprotocol/server-slack",
|
|
Command: "npx", Args: []string{"-y", "@modelcontextprotocol/server-slack"},
|
|
Env: map[string]string{"SLACK_BOT_TOKEN": ""},
|
|
RequiredEnv: []string{"SLACK_BOT_TOKEN"},
|
|
InstallType: "npm", Tags: []string{"slack", "communication"},
|
|
},
|
|
{
|
|
Name: "google-maps", Description: "Google Maps integration",
|
|
Category: "web", Package: "@modelcontextprotocol/server-google-maps",
|
|
Command: "npx", Args: []string{"-y", "@modelcontextprotocol/server-google-maps"},
|
|
Env: map[string]string{"GOOGLE_MAPS_API_KEY": ""},
|
|
RequiredEnv: []string{"GOOGLE_MAPS_API_KEY"},
|
|
InstallType: "npm", Tags: []string{"maps", "location"},
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
func LoadRegistry() (*Registry, error) {
|
|
registryMu.RLock()
|
|
if registryCache != nil {
|
|
defer registryMu.RUnlock()
|
|
return registryCache, nil
|
|
}
|
|
registryMu.RUnlock()
|
|
|
|
reg, err := loadRegistryFromDisk()
|
|
if err != nil {
|
|
defaultReg := DefaultRegistry()
|
|
registryMu.Lock()
|
|
registryCache = defaultReg
|
|
registryMu.Unlock()
|
|
return defaultReg, nil
|
|
}
|
|
|
|
registryMu.Lock()
|
|
registryCache = reg
|
|
registryMu.Unlock()
|
|
return reg, nil
|
|
}
|
|
|
|
func loadRegistryFromDisk() (*Registry, error) {
|
|
if registryPath == "" {
|
|
return nil, fmt.Errorf("registry path not set")
|
|
}
|
|
|
|
data, err := os.ReadFile(registryPath)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var reg Registry
|
|
if err := yaml.Unmarshal(data, ®); err != nil {
|
|
return nil, fmt.Errorf("parse registry: %w", err)
|
|
}
|
|
|
|
return ®, nil
|
|
}
|
|
|
|
func SaveRegistry(reg *Registry) error {
|
|
if registryPath == "" {
|
|
return fmt.Errorf("registry path not set")
|
|
}
|
|
|
|
reg.UpdatedAt = time.Now()
|
|
data, err := yaml.Marshal(reg)
|
|
if err != nil {
|
|
return fmt.Errorf("marshal registry: %w", err)
|
|
}
|
|
|
|
if err := os.MkdirAll(filepath.Dir(registryPath), 0755); err != nil {
|
|
return err
|
|
}
|
|
|
|
if err := os.WriteFile(registryPath, data, 0644); err != nil {
|
|
return err
|
|
}
|
|
|
|
registryMu.Lock()
|
|
registryCache = reg
|
|
registryMu.Unlock()
|
|
return nil
|
|
}
|
|
|
|
func AddToRegistry(server RegistryServer) error {
|
|
reg, err := LoadRegistry()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
for _, s := range reg.Servers {
|
|
if s.Name == server.Name {
|
|
return fmt.Errorf("server %q already exists in registry", server.Name)
|
|
}
|
|
}
|
|
|
|
reg.Servers = append(reg.Servers, server)
|
|
return SaveRegistry(reg)
|
|
}
|
|
|
|
func RemoveFromRegistry(name string) error {
|
|
reg, err := LoadRegistry()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
for i, s := range reg.Servers {
|
|
if s.Name == name {
|
|
reg.Servers = append(reg.Servers[:i], reg.Servers[i+1:]...)
|
|
return SaveRegistry(reg)
|
|
}
|
|
}
|
|
|
|
return fmt.Errorf("server %q not found in registry", name)
|
|
}
|
|
|
|
func InitRegistry() error {
|
|
if _, err := os.Stat(registryPath); err == nil {
|
|
return nil
|
|
}
|
|
return SaveRegistry(DefaultRegistry())
|
|
}
|
|
|
|
func ResolveEnv(env map[string]string, providerKeys map[string]string) map[string]string {
|
|
resolved := make(map[string]string)
|
|
for k, v := range env {
|
|
if v != "" {
|
|
resolved[k] = v
|
|
continue
|
|
}
|
|
|
|
if providerKeys != nil {
|
|
for providerKey, apiKey := range providerKeys {
|
|
if strings.EqualFold(k, providerKey) || strings.Contains(strings.ToUpper(k), strings.ToUpper(providerKey)) {
|
|
if apiKey != "" {
|
|
resolved[k] = apiKey
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if resolved[k] == "" {
|
|
if envVal := os.Getenv(k); envVal != "" {
|
|
resolved[k] = envVal
|
|
}
|
|
}
|
|
}
|
|
return resolved
|
|
}
|
|
|
|
func ValidateConfig(configPath string) error {
|
|
data, err := os.ReadFile(configPath)
|
|
if err != nil {
|
|
return fmt.Errorf("read config: %w", err)
|
|
}
|
|
|
|
var cfg map[string]interface{}
|
|
if err := json.Unmarshal(data, &cfg); err != nil {
|
|
return fmt.Errorf("parse config: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func DiscoverNpmServers() ([]RegistryServer, error) {
|
|
var servers []RegistryServer
|
|
|
|
packages := []struct {
|
|
pkg string
|
|
name string
|
|
desc string
|
|
cat string
|
|
args []string
|
|
}{
|
|
{"@modelcontextprotocol/server-filesystem", "filesystem", "File system operations", "core", []string{"-y", "@modelcontextprotocol/server-filesystem"}},
|
|
{"@modelcontextprotocol/server-github", "github", "GitHub API integration", "vcs", []string{"-y", "@modelcontextprotocol/server-github"}},
|
|
{"@modelcontextprotocol/server-fetch", "fetch", "Web fetching", "web", []string{"-y", "@modelcontextprotocol/server-fetch"}},
|
|
{"@modelcontextprotocol/server-memory", "memory", "Persistent memory", "core", []string{"-y", "@modelcontextprotocol/server-memory"}},
|
|
}
|
|
|
|
for _, p := range packages {
|
|
servers = append(servers, RegistryServer{
|
|
Name: p.name,
|
|
Description: p.desc,
|
|
Category: p.cat,
|
|
Package: p.pkg,
|
|
Command: "npx",
|
|
Args: p.args,
|
|
InstallType: "npm",
|
|
})
|
|
}
|
|
|
|
return servers, nil
|
|
}
|
|
|
|
func GetInstalledVersion(name string) string {
|
|
home, _ := os.UserHomeDir()
|
|
if home == "" {
|
|
return ""
|
|
}
|
|
receiptPath := filepath.Join(home, ".muyue", "receipts", "mcp", name+".json")
|
|
data, err := os.ReadFile(receiptPath)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
var receipt struct {
|
|
Version string `json:"version"`
|
|
}
|
|
if json.Unmarshal(data, &receipt) == nil {
|
|
return receipt.Version
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func SaveReceipt(name, version string) error {
|
|
home, _ := os.UserHomeDir()
|
|
if home == "" {
|
|
return nil
|
|
}
|
|
receiptDir := filepath.Join(home, ".muyue", "receipts", "mcp")
|
|
os.MkdirAll(receiptDir, 0755)
|
|
|
|
receipt := struct {
|
|
Name string `json:"name"`
|
|
Version string `json:"version"`
|
|
UpdatedAt string `json:"updated_at"`
|
|
}{
|
|
Name: name,
|
|
Version: version,
|
|
UpdatedAt: time.Now().Format(time.RFC3339),
|
|
}
|
|
|
|
data, _ := json.MarshalIndent(receipt, "", " ")
|
|
return os.WriteFile(filepath.Join(receiptDir, name+".json"), data, 0644)
|
|
}
|
|
|
|
func BuildProviderKeyMap(cfg interface{ GetAPIKeys() map[string]string }) map[string]string {
|
|
if cfg == nil {
|
|
return nil
|
|
}
|
|
return cfg.GetAPIKeys()
|
|
}
|
|
|
|
func EditorConfigs(homeDir string) []EditorConfig {
|
|
if homeDir == "" {
|
|
home, _ := os.UserHomeDir()
|
|
homeDir = home
|
|
}
|
|
|
|
transformStdio := func(e mcpEntry) interface{} {
|
|
m := map[string]interface{}{
|
|
"command": e.cmd,
|
|
"args": e.args,
|
|
}
|
|
if len(e.env) > 0 {
|
|
m["env"] = e.env
|
|
}
|
|
return m
|
|
}
|
|
|
|
transformCursor := 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 []EditorConfig{
|
|
{
|
|
Name: "crush", ConfigPath: filepath.Join(homeDir, ".config", "crush", "crush.json"),
|
|
ConfigKey: "mcps", Format: "json", TransformCommand: transformStdio,
|
|
},
|
|
{
|
|
Name: "claude-code", ConfigPath: filepath.Join(homeDir, ".claude.json"),
|
|
ConfigKey: "mcpServers", Format: "json", TransformCommand: transformStdio,
|
|
},
|
|
{
|
|
Name: "cursor", ConfigPath: filepath.Join(homeDir, ".cursor", "mcp.json"),
|
|
LocalConfigPath: ".cursor/mcp.json", ConfigKey: "mcpServers",
|
|
Format: "json", TransformCommand: transformCursor,
|
|
},
|
|
{
|
|
Name: "vscode", ConfigPath: filepath.Join(homeDir, ".vscode", "mcp.json"),
|
|
LocalConfigPath: ".vscode/mcp.json", ConfigKey: "servers",
|
|
Format: "json", TransformCommand: transformStdio,
|
|
},
|
|
{
|
|
Name: "windsurf", ConfigPath: filepath.Join(homeDir, ".windsurf", "mcp.json"),
|
|
ConfigKey: "mcpServers", Format: "json", TransformCommand: transformStdio,
|
|
},
|
|
}
|
|
}
|
|
|
|
func CheckServerStatus(name string) MCPStatus {
|
|
status := MCPStatus{Name: name}
|
|
|
|
reg, err := LoadRegistry()
|
|
if err != nil {
|
|
status.Error = "registry unavailable"
|
|
return status
|
|
}
|
|
|
|
var server *RegistryServer
|
|
for i := range reg.Servers {
|
|
if reg.Servers[i].Name == name {
|
|
server = ®.Servers[i]
|
|
break
|
|
}
|
|
}
|
|
if server == nil {
|
|
status.Error = "not in registry"
|
|
return status
|
|
}
|
|
|
|
_, err = exec.LookPath(server.Command)
|
|
if err != nil {
|
|
status.Error = fmt.Sprintf("command %q not found", server.Command)
|
|
return status
|
|
}
|
|
status.Installed = true
|
|
|
|
status.Version = GetInstalledVersion(name)
|
|
|
|
home, _ := os.UserHomeDir()
|
|
if home != "" {
|
|
crushingPath := filepath.Join(home, ".config", "crush", "crush.json")
|
|
data, err := os.ReadFile(crushingPath)
|
|
if err == nil {
|
|
var cfg map[string]interface{}
|
|
if json.Unmarshal(data, &cfg) == nil {
|
|
if mcps, ok := cfg["mcps"].(map[string]interface{}); ok {
|
|
if _, exists := mcps[name]; exists {
|
|
status.Running = true
|
|
status.Healthy = true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return status
|
|
}
|