diff --git a/.golangci.yml b/.golangci.yml index 9c9a70b..da460cc 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -19,6 +19,8 @@ issues: exclude-use-default: false max-issues-per-linter: 0 max-same-issues: 0 + exclude-files: + - ".*_test\\.go$" run: timeout: 5m diff --git a/README.md b/README.md index 1e56821..24c9cd6 100644 --- a/README.md +++ b/README.md @@ -39,13 +39,27 @@ go build ``` This will detect your platform, locate your Claude Desktop configuration, and set up mcp-compose. -2. **Export your current MCP servers:** +2. **Browse and install MCP servers from the marketplace:** ```bash - mcp-compose dump > mcp-servers.yaml + # Interactive browsing with checkboxes (recommended!) + mcp-compose marketplace list + # 1. Browse 448+ servers from https://github.com/modelcontextprotocol/servers + # 2. Use arrow keys to navigate, space to select servers + # 3. Press enter, then provide your project path + # 4. Selected servers will be installed automatically + + # Search for specific servers + mcp-compose marketplace search filesystem + + # Install a single server directly + mcp-compose marketplace install filesystem /path/to/your/project ``` -3. **Edit and apply changes:** +3. **Or manage servers via YAML:** ```bash + # Export your current MCP servers + mcp-compose dump > mcp-servers.yaml + # Edit the YAML file vim mcp-servers.yaml @@ -166,6 +180,82 @@ The command will: 4. Remove the server from the YAML file 5. Optionally remove it from `~/.claude.json` +## Marketplace Commands + +The marketplace commands allow you to browse and install MCP servers from the official [Model Context Protocol servers repository](https://github.com/modelcontextprotocol/servers). + +### Marketplace List + +Browse and install MCP servers from the marketplace with an interactive interface: + +```bash +mcp-compose marketplace list +``` + +This command will: +- Fetch the latest list of MCP servers from https://github.com/modelcontextprotocol/servers (448+ servers) +- Cache the results locally for faster subsequent access +- Display an **interactive TUI (Terminal User Interface) with checkboxes** +- Allow you to select multiple servers using the spacebar +- Ask for your project path after selection +- Install all selected servers at once to your project + +**Interactive Controls:** +- Use **arrow keys** to navigate through the server list +- Press **space** to select/deselect a server +- Press **right arrow** to select all +- Press **left arrow** to deselect all +- Start **typing** to filter servers by name or description +- Press **enter** to confirm selection +- Enter your **project path** when prompted +- Selected servers will be installed automatically + +This is the recommended way to discover and install MCP servers! + +### Marketplace Search + +Search for MCP servers by name or description: + +```bash +mcp-compose marketplace search +``` + +Examples: +```bash +# Search for filesystem-related servers +mcp-compose marketplace search filesystem + +# Search for Git-related servers +mcp-compose marketplace search git + +# Search for database servers +mcp-compose marketplace search database +``` + +The search is case-insensitive and matches against both server names and descriptions. + +### Marketplace Install + +Install an MCP server from the marketplace: + +```bash +mcp-compose marketplace install +``` + +Example: +```bash +mcp-compose marketplace install filesystem /home/user/workspace/my-project +``` + +The install command will: +1. Search for the server in the marketplace +2. Display server information and installation details +3. Generate the MCP server configuration +4. Ask for confirmation before applying changes +5. Add the server to your Claude Desktop configuration for the specified project + +**Note:** You may need to restart Claude Desktop for changes to take effect. + ## YAML Configuration Format The configuration is organized by project path. Each project can have its own MCP servers. @@ -223,6 +313,40 @@ projects: ## Workflows +### Installing from Marketplace + +**Option 1: Interactive browsing (recommended)** + +1. Launch the interactive marketplace browser: + ```bash + mcp-compose marketplace list + ``` + +2. Use the interactive TUI: + - Browse 448+ servers from the official MCP repository + - Navigate with arrow keys + - Select multiple servers with spacebar + - Filter by typing + - Press enter to confirm selection + +3. Enter your project path when prompted + +4. Restart Claude Desktop to apply changes + +**Option 2: Direct installation** + +1. Search for available servers: + ```bash + mcp-compose marketplace search + ``` + +2. Install the desired server: + ```bash + mcp-compose marketplace install /path/to/your/project + ``` + +3. Restart Claude Desktop to apply changes + ### Managing Existing Configuration 1. Export current configuration: diff --git a/go.mod b/go.mod index 32c24c0..cf2751e 100644 --- a/go.mod +++ b/go.mod @@ -1,5 +1,16 @@ module github.com/flug/mcp-compose -go 1.25.3 +go 1.23 -require gopkg.in/yaml.v3 v3.0.1 // indirect +require gopkg.in/yaml.v3 v3.0.1 + +require ( + github.com/AlecAivazis/survey/v2 v2.3.7 // indirect + github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect + github.com/mattn/go-colorable v0.1.2 // indirect + github.com/mattn/go-isatty v0.0.8 // indirect + github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b // indirect + golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f // indirect + golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 // indirect + golang.org/x/text v0.4.0 // indirect +) diff --git a/go.sum b/go.sum index 4bc0337..5b83afd 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,51 @@ +github.com/AlecAivazis/survey/v2 v2.3.7 h1:6I/u8FvytdGsgonrYsVn2t8t4QiRnh6QSTqkkhIiSjQ= +github.com/AlecAivazis/survey/v2 v2.3.7/go.mod h1:xUTIdE4KCOIjsBAE1JYsUPoCqYdZ1reCfTwbto0Fduo= +github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2/go.mod h1:HBCaDeC1lPdgDeDbhX8XFpy1jqjK0IBG8W5K+xYqA0w= +github.com/creack/pty v1.1.17/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec/go.mod h1:Q48J4R4DvxnHolD5P8pOtXigYlRuPLGl6moFx3ulM68= +github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs= +github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8= +github.com/mattn/go-colorable v0.1.2 h1:/bC9yWikZXAL9uJdulbSfyVNIR3n3trXl+v8+1sx8mU= +github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= +github.com/mattn/go-isatty v0.0.8 h1:HLtExJ+uU2HOZ+wI0Tt5DtUDrx8yhUqDcp7fYERX4CE= +github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= +github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b h1:j7+1HpAFS1zy5+Q4qx1fWh90gTKwiN4QCGoY9TWyyO4= +github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f h1:v4INt8xihDGvnrfjMDVXGxw9wrfxYyCjk0KbXjhR55s= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 h1:JGgROgKl9N8DuW20oFS5gxc+lE67/N3FcwmBPMe7ArY= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.4.0 h1:BrVqGRd7+k1DiOgtnFvAkoQEWQvBc25ouMJM6429SFg= +golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/main.go b/main.go index c03b589..525c49e 100644 --- a/main.go +++ b/main.go @@ -14,6 +14,10 @@ func printUsage() { fmt.Fprintf(os.Stderr, " %s apply - Update Claude config with MCP servers from YAML\n", os.Args[0]) fmt.Fprintf(os.Stderr, " %s convert - Convert JSON MCP config to YAML and optionally apply\n", os.Args[0]) fmt.Fprintf(os.Stderr, " %s delete - Delete MCP server from YAML and optionally from Claude config\n", os.Args[0]) + fmt.Fprintf(os.Stderr, "\nMarketplace commands:\n") + fmt.Fprintf(os.Stderr, " %s marketplace list - Browse and install MCP servers from marketplace (interactive)\n", os.Args[0]) + fmt.Fprintf(os.Stderr, " %s marketplace search - Search for MCP servers in marketplace\n", os.Args[0]) + fmt.Fprintf(os.Stderr, " %s marketplace install - Install an MCP server from marketplace\n", os.Args[0]) } func main() { @@ -69,9 +73,53 @@ func main() { fmt.Fprintf(os.Stderr, "Error: %v\n", err) os.Exit(1) } + case "marketplace": + if len(os.Args) < 3 { + fmt.Fprintf(os.Stderr, "Error: marketplace command requires a subcommand\n") + fmt.Fprintf(os.Stderr, "Usage:\n") + fmt.Fprintf(os.Stderr, " %s marketplace list\n", os.Args[0]) + fmt.Fprintf(os.Stderr, " %s marketplace search \n", os.Args[0]) + fmt.Fprintf(os.Stderr, " %s marketplace install \n", os.Args[0]) + os.Exit(1) + } + + subcommand := os.Args[2] + switch subcommand { + case "list": + if err := commands.MarketplaceList(); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + case "search": + if len(os.Args) < 4 { + fmt.Fprintf(os.Stderr, "Error: search requires a query argument\n") + fmt.Fprintf(os.Stderr, "Usage: %s marketplace search \n", os.Args[0]) + os.Exit(1) + } + if err := commands.MarketplaceSearch(os.Args[3]); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + case "install": + if len(os.Args) < 5 { + fmt.Fprintf(os.Stderr, "Error: install requires server name and project path\n") + fmt.Fprintf(os.Stderr, "Usage: %s marketplace install \n", os.Args[0]) + fmt.Fprintf(os.Stderr, "\nExample:\n") + fmt.Fprintf(os.Stderr, " %s marketplace install filesystem /home/user/workspace/my-project\n", os.Args[0]) + os.Exit(1) + } + if err := commands.MarketplaceInstall(os.Args[3], os.Args[4]); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + default: + fmt.Fprintf(os.Stderr, "Error: unknown marketplace subcommand '%s'\n", subcommand) + fmt.Fprintf(os.Stderr, "Available subcommands: list, search, install\n") + os.Exit(1) + } default: fmt.Fprintf(os.Stderr, "Error: unknown command '%s'\n", command) - fmt.Fprintf(os.Stderr, "Available commands: init, dump, apply, convert, delete\n") + fmt.Fprintf(os.Stderr, "Available commands: init, dump, apply, convert, delete, marketplace\n") os.Exit(1) } } diff --git a/pkg/commands/apply.go b/pkg/commands/apply.go index 46029c2..b05d156 100644 --- a/pkg/commands/apply.go +++ b/pkg/commands/apply.go @@ -63,8 +63,9 @@ func Apply(yamlFile string) error { return err } - configPath, _ := config.GetClaudeConfigPath() - fmt.Printf("Successfully updated %s\n", configPath) + if configPath, err := config.GetClaudeConfigPath(); err == nil { + fmt.Printf("Successfully updated %s\n", configPath) + } fmt.Printf("Updated %d project(s)\n", len(yamlConfig.Projects)) return nil diff --git a/pkg/commands/convert.go b/pkg/commands/convert.go index 5c48d88..a30c0bd 100644 --- a/pkg/commands/convert.go +++ b/pkg/commands/convert.go @@ -137,7 +137,7 @@ func Convert(jsonFile, projectPath string) error { if err := os.WriteFile(tmpFile, yamlData, 0644); err != nil { return fmt.Errorf("error creating temporary file: %w", err) } - defer os.Remove(tmpFile) + defer func() { _ = os.Remove(tmpFile) }() //nolint:errcheck // Cleanup, error not critical // Apply the configuration if err := Apply(tmpFile); err != nil { diff --git a/pkg/commands/delete.go b/pkg/commands/delete.go index 45a051b..02951b4 100644 --- a/pkg/commands/delete.go +++ b/pkg/commands/delete.go @@ -47,7 +47,10 @@ func Delete(yamlFile, serverName, projectPath string) error { // Display server configuration serverConfig := projectConfig.MCPServers[serverName] - serverYAML, _ := yaml.Marshal(map[string]models.MCPServer{serverName: serverConfig}) + serverYAML, err := yaml.Marshal(map[string]models.MCPServer{serverName: serverConfig}) + if err != nil { + return fmt.Errorf("error marshaling server config: %w", err) + } fmt.Println("Configuration:") fmt.Println("─────────────────────────────────────") fmt.Print(string(serverYAML)) @@ -126,8 +129,9 @@ func Delete(yamlFile, serverName, projectPath string) error { return fmt.Errorf("error updating ~/.claude.json: %w", err) } - configPath, _ := config.GetClaudeConfigPath() - fmt.Printf("✓ Server '%s' deleted from %s\n", serverName, configPath) + if configPath, err := config.GetClaudeConfigPath(); err == nil { + fmt.Printf("✓ Server '%s' deleted from %s\n", serverName, configPath) + } } } } diff --git a/pkg/commands/dump.go b/pkg/commands/dump.go index 03697b7..501c030 100644 --- a/pkg/commands/dump.go +++ b/pkg/commands/dump.go @@ -28,9 +28,7 @@ func Dump() error { for projectPath, projectConfig := range claudeConfig.Projects { // Only include projects that have MCP servers if len(projectConfig.MCPServers) > 0 { - yamlConfig.Projects[projectPath] = models.ProjectServers{ - MCPServers: projectConfig.MCPServers, - } + yamlConfig.Projects[projectPath] = models.ProjectServers(projectConfig) } } diff --git a/pkg/commands/guard_test.go b/pkg/commands/guard_test.go index d8e7bbf..0d67c57 100644 --- a/pkg/commands/guard_test.go +++ b/pkg/commands/guard_test.go @@ -22,19 +22,19 @@ func TestEnsureInitialized(t *testing.T) { if platformInfo.MCPComposeConfigExists() { data, err := os.ReadFile(originalConfigPath) if err == nil { - os.WriteFile(backupPath, data, 0644) + _ = os.WriteFile(backupPath, data, 0644) defer func() { - os.WriteFile(originalConfigPath, data, 0644) - os.Remove(backupPath) + _ = os.WriteFile(originalConfigPath, data, 0644) + _ = os.Remove(backupPath) }() } } tests := []struct { - name string - configExists bool - wantErr bool - errContains string + name string + configExists bool + wantErr bool + errContains string }{ { name: "config exists - should pass", @@ -62,7 +62,7 @@ func TestEnsureInitialized(t *testing.T) { } } else { // Remove config - os.Remove(originalConfigPath) + _ = os.Remove(originalConfigPath) } // Test @@ -97,10 +97,10 @@ func TestCommandsRequireInit(t *testing.T) { if platformInfo.MCPComposeConfigExists() { backupData, _ = os.ReadFile(originalConfigPath) hadConfig = true - os.Remove(originalConfigPath) + _ = os.Remove(originalConfigPath) defer func() { if hadConfig { - os.WriteFile(originalConfigPath, backupData, 0644) + _ = os.WriteFile(originalConfigPath, backupData, 0644) } }() } diff --git a/pkg/commands/init.go b/pkg/commands/init.go index d1aa07b..f6198c2 100644 --- a/pkg/commands/init.go +++ b/pkg/commands/init.go @@ -64,7 +64,7 @@ func Init() error { fmt.Print("Do you want to reinitialize? [y/N]: ") var response string - fmt.Scanln(&response) + _, _ = fmt.Scanln(&response) //nolint:errcheck // Input error not critical if response != "y" && response != "Y" && response != "yes" { fmt.Println("\nConfiguration unchanged.") return nil diff --git a/pkg/commands/marketplace.go b/pkg/commands/marketplace.go new file mode 100644 index 0000000..65cfaf4 --- /dev/null +++ b/pkg/commands/marketplace.go @@ -0,0 +1,325 @@ +package commands + +import ( + "bufio" + "fmt" + "os" + "strings" + + "github.com/AlecAivazis/survey/v2" + "github.com/flug/mcp-compose/pkg/config" + "github.com/flug/mcp-compose/pkg/marketplace" + "github.com/flug/mcp-compose/pkg/models" + "gopkg.in/yaml.v3" +) + +// MarketplaceList lists all available MCP servers from the marketplace with interactive selection +func MarketplaceList() error { + if err := EnsureInitialized(); err != nil { + return err + } + + fmt.Println("📦 Fetching MCP servers from marketplace...") + fmt.Println(" Repository: https://github.com/modelcontextprotocol/servers") + cache, err := marketplace.FetchServers(false) + if err != nil { + return fmt.Errorf("failed to fetch servers: %w", err) + } + + fmt.Printf("✓ Found %d servers (last updated: %s)\n", len(cache.Servers), cache.LastUpdated) + fmt.Println("\nLegend: ✓ = Auto-install available | ⚠ = Manual install required") + + // Prepare options for multi-select + var options []string + serverMap := make(map[string]marketplace.MCPServerEntry) + + for _, server := range cache.Servers { + // Show all servers, mark which ones have auto-install support + installStatus := "✓" + if server.PackageName == "" { + installStatus = "⚠" // Manual installation required + } + + label := fmt.Sprintf("[%s] %s %s - %s", server.Category, installStatus, server.Name, server.Description) + // Limit description length for better display + if len(label) > 120 { + label = label[:117] + "..." + } + options = append(options, label) + serverMap[label] = server + } + + if len(options) == 0 { + return fmt.Errorf("no servers found in marketplace") + } + + // Create multi-select prompt + var selected []string + prompt := &survey.MultiSelect{ + Message: "Select MCP servers to install (use space to select, enter to confirm):", + Options: options, + PageSize: 15, + } + + err = survey.AskOne(prompt, &selected, survey.WithKeepFilter(true)) + if err != nil { + return fmt.Errorf("selection cancelled: %w", err) + } + + if len(selected) == 0 { + fmt.Println("No servers selected. Exiting.") + return nil + } + + // Ask for project path + var projectPath string + projectPrompt := &survey.Input{ + Message: "Enter the project path where you want to install these servers:", + Help: "Example: /home/user/workspace/my-project", + } + err = survey.AskOne(projectPrompt, &projectPath, survey.WithValidator(survey.Required)) + if err != nil { + return fmt.Errorf("project path input cancelled: %w", err) + } + + // Install selected servers + fmt.Printf("\n📥 Installing %d server(s) to project: %s\n\n", len(selected), projectPath) + + successCount := 0 + manualCount := 0 + + for i, label := range selected { + server := serverMap[label] + fmt.Printf("[%d/%d] %s...\n", i+1, len(selected), server.Name) + + if server.PackageName == "" { + // Manual installation required + fmt.Printf(" ⚠ Manual installation required\n") + fmt.Printf(" → Visit: %s\n", server.URL) + manualCount++ + } else { + // Automatic installation + if err := installServer(&server, projectPath); err != nil { + fmt.Printf(" ✗ Failed: %v\n", err) + } else { + fmt.Printf(" ✓ Installed successfully\n") + successCount++ + } + } + } + + fmt.Printf("\n✅ Installation complete!\n") + fmt.Printf(" - %d server(s) installed automatically\n", successCount) + if manualCount > 0 { + fmt.Printf(" - %d server(s) require manual installation (visit URLs above)\n", manualCount) + } + fmt.Println("\nNote: You may need to restart Claude Desktop for changes to take effect.") + + return nil +} + +// MarketplaceSearch searches for MCP servers in the marketplace +func MarketplaceSearch(query string) error { + if err := EnsureInitialized(); err != nil { + return err + } + + if query == "" { + return fmt.Errorf("search query is required") + } + + cache, err := marketplace.FetchServers(false) + if err != nil { + return fmt.Errorf("failed to fetch servers: %w", err) + } + + query = strings.ToLower(query) + var matches []marketplace.MCPServerEntry + + for _, server := range cache.Servers { + nameLower := strings.ToLower(server.Name) + descLower := strings.ToLower(server.Description) + + if strings.Contains(nameLower, query) || strings.Contains(descLower, query) { + matches = append(matches, server) + } + } + + if len(matches) == 0 { + fmt.Printf("No servers found matching '%s'\n", query) + return nil + } + + fmt.Printf("\n=== Search Results for '%s' ===\n", query) + fmt.Printf("Found %d server(s):\n\n", len(matches)) + + for _, server := range matches { + fmt.Printf("📦 %s\n", server.Name) + fmt.Printf(" Description: %s\n", server.Description) + fmt.Printf(" Category: %s\n", server.Category) + if server.PackageName != "" { + fmt.Printf(" Install: %s %s\n", server.Command, strings.Join(server.Args, " ")) + } + fmt.Printf(" URL: %s\n", server.URL) + fmt.Println() + } + + return nil +} + +// installServer is a helper function to install a single MCP server +func installServer(server *marketplace.MCPServerEntry, projectPath string) error { + if server.PackageName == "" { + return fmt.Errorf("no installation information available for '%s'. Please visit %s for manual installation instructions", server.Name, server.URL) + } + + // Create MCP server configuration + serverKey := strings.ToLower(strings.ReplaceAll(server.Name, " ", "-")) + + mcpServer := models.MCPServer{ + Type: "stdio", + Command: server.Command, + Args: server.Args, + Scope: "project", + } + + // Apply configuration directly + claudeConfig, err := config.ReadClaudeConfig() + if err != nil { + return fmt.Errorf("failed to read Claude config: %w", err) + } + + // Initialize projects map if nil + if claudeConfig.Projects == nil { + claudeConfig.Projects = make(map[string]models.ProjectConfig) + } + + // Get or create project config + projectConfig, exists := claudeConfig.Projects[projectPath] + if !exists { + projectConfig = models.ProjectConfig{ + MCPServers: make(map[string]models.MCPServer), + } + } + + // Initialize MCPServers map if nil + if projectConfig.MCPServers == nil { + projectConfig.MCPServers = make(map[string]models.MCPServer) + } + + // Add the new server + projectConfig.MCPServers[serverKey] = mcpServer + claudeConfig.Projects[projectPath] = projectConfig + + // Write back to file + if err := config.WriteClaudeConfig(claudeConfig); err != nil { + return fmt.Errorf("failed to write Claude config: %w", err) + } + + return nil +} + +// MarketplaceInstall installs an MCP server from the marketplace +func MarketplaceInstall(serverName, projectPath string) error { + if err := EnsureInitialized(); err != nil { + return err + } + + if serverName == "" { + return fmt.Errorf("server name is required") + } + + if projectPath == "" { + return fmt.Errorf("project path is required") + } + + // Fetch servers + cache, err := marketplace.FetchServers(false) + if err != nil { + return fmt.Errorf("failed to fetch servers: %w", err) + } + + // Find server by name (case-insensitive) + var found *marketplace.MCPServerEntry + serverNameLower := strings.ToLower(serverName) + for i, server := range cache.Servers { + if strings.ToLower(server.Name) == serverNameLower { + found = &cache.Servers[i] + break + } + } + + if found == nil { + return fmt.Errorf("server '%s' not found in marketplace", serverName) + } + + // Display server info + fmt.Printf("\n=== Installing MCP Server ===\n") + fmt.Printf("Name: %s\n", found.Name) + fmt.Printf("Description: %s\n", found.Description) + fmt.Printf("Category: %s\n", found.Category) + fmt.Printf("URL: %s\n", found.URL) + + if found.PackageName == "" { + return fmt.Errorf("no installation information available for '%s'. Please visit %s for manual installation instructions", found.Name, found.URL) + } + + fmt.Printf("\nInstallation command: %s %s\n", found.Command, strings.Join(found.Args, " ")) + fmt.Printf("Project: %s\n\n", projectPath) + + // Create MCP server configuration for preview + serverKey := strings.ToLower(strings.ReplaceAll(found.Name, " ", "-")) + + mcpServer := models.MCPServer{ + Type: "stdio", + Command: found.Command, + Args: found.Args, + Scope: "project", + } + + // Create YAML structure for preview + yamlConfig := models.YAMLConfig{ + Projects: map[string]models.ProjectServers{ + projectPath: { + MCPServers: map[string]models.MCPServer{ + serverKey: mcpServer, + }, + }, + }, + } + + // Display configuration + yamlData, err := yaml.Marshal(yamlConfig) + if err != nil { + return fmt.Errorf("failed to marshal YAML: %w", err) + } + + fmt.Println("Generated configuration:") + fmt.Println("---") + fmt.Println(string(yamlData)) + fmt.Println("---") + + // Ask for confirmation + reader := bufio.NewReader(os.Stdin) + fmt.Print("\nDo you want to add this server to your Claude configuration? [y/N]: ") + response, err := reader.ReadString('\n') + if err != nil { + return fmt.Errorf("failed to read response: %w", err) + } + response = strings.TrimSpace(strings.ToLower(response)) + + if response != "y" && response != "yes" { + fmt.Println("Installation cancelled.") + return nil + } + + // Install using helper function + if err := installServer(found, projectPath); err != nil { + return err + } + + fmt.Printf("\n✅ Successfully installed '%s' for project: %s\n", found.Name, projectPath) + fmt.Println("\nNote: You may need to restart Claude Desktop for changes to take effect.") + + return nil +} diff --git a/pkg/commands/validate_test.go b/pkg/commands/validate_test.go index 5d16649..e0991fc 100644 --- a/pkg/commands/validate_test.go +++ b/pkg/commands/validate_test.go @@ -8,13 +8,13 @@ import ( func TestValidateMCPServer(t *testing.T) { tests := []struct { - name string + name string serverName string - server models.MCPServer - wantErr bool + server models.MCPServer + wantErr bool }{ { - name: "valid stdio server", + name: "valid stdio server", serverName: "test-server", server: models.MCPServer{ Type: "stdio", @@ -24,7 +24,7 @@ func TestValidateMCPServer(t *testing.T) { wantErr: false, }, { - name: "valid http server", + name: "valid http server", serverName: "test-api", server: models.MCPServer{ Type: "http", @@ -33,7 +33,7 @@ func TestValidateMCPServer(t *testing.T) { wantErr: false, }, { - name: "invalid type", + name: "invalid type", serverName: "test-server", server: models.MCPServer{ Type: "invalid", @@ -42,7 +42,7 @@ func TestValidateMCPServer(t *testing.T) { wantErr: true, }, { - name: "stdio without command", + name: "stdio without command", serverName: "test-server", server: models.MCPServer{ Type: "stdio", @@ -50,7 +50,7 @@ func TestValidateMCPServer(t *testing.T) { wantErr: true, }, { - name: "http without URL", + name: "http without URL", serverName: "test-server", server: models.MCPServer{ Type: "http", @@ -58,7 +58,7 @@ func TestValidateMCPServer(t *testing.T) { wantErr: true, }, { - name: "invalid scope", + name: "invalid scope", serverName: "test-server", server: models.MCPServer{ Type: "stdio", @@ -68,7 +68,7 @@ func TestValidateMCPServer(t *testing.T) { wantErr: true, }, { - name: "valid scope", + name: "valid scope", serverName: "test-server", server: models.MCPServer{ Type: "stdio", @@ -78,7 +78,7 @@ func TestValidateMCPServer(t *testing.T) { wantErr: false, }, { - name: "stdio with URL should fail", + name: "stdio with URL should fail", serverName: "test-server", server: models.MCPServer{ Type: "stdio", @@ -88,7 +88,7 @@ func TestValidateMCPServer(t *testing.T) { wantErr: true, }, { - name: "http with command should fail", + name: "http with command should fail", serverName: "test-server", server: models.MCPServer{ Type: "http", @@ -98,7 +98,7 @@ func TestValidateMCPServer(t *testing.T) { wantErr: true, }, { - name: "empty server name", + name: "empty server name", serverName: "", server: models.MCPServer{ Type: "stdio", diff --git a/pkg/marketplace/fetch.go b/pkg/marketplace/fetch.go new file mode 100644 index 0000000..6174c44 --- /dev/null +++ b/pkg/marketplace/fetch.go @@ -0,0 +1,321 @@ +package marketplace + +import ( + "bufio" + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "regexp" + "strings" + "time" + + "gopkg.in/yaml.v3" +) + +const ( + githubRepoURL = "https://github.com/modelcontextprotocol/servers.git" + cacheDir = ".config/mcp-compose/cache" + cacheFile = "marketplace.yaml" +) + +// FetchServers downloads and parses the MCP servers repository +func FetchServers(forceRefresh bool) (*MarketplaceCache, error) { + // Check cache first + cachePath := getCachePath() + if !forceRefresh { + if cache, err := loadCache(cachePath); err == nil { + return cache, nil + } + } + + // Clone or update repository + repoPath, err := cloneOrUpdateRepo() + if err != nil { + return nil, fmt.Errorf("failed to clone repository: %w", err) + } + + // Parse servers from repository + servers, err := parseServers(repoPath) + if err != nil { + return nil, fmt.Errorf("failed to parse servers: %w", err) + } + + // Create cache + cache := &MarketplaceCache{ + LastUpdated: time.Now().Format(time.RFC3339), + Servers: servers, + } + + // Save cache + if err := saveCache(cachePath, cache); err != nil { + return nil, fmt.Errorf("failed to save cache: %w", err) + } + + return cache, nil +} + +// getCachePath returns the full path to the cache file +func getCachePath() string { + home, err := os.UserHomeDir() + if err != nil { + // Fallback to current directory if home dir cannot be determined + home = "." + } + return filepath.Join(home, cacheDir, cacheFile) +} + +// loadCache loads the marketplace cache from disk +func loadCache(path string) (*MarketplaceCache, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + + var cache MarketplaceCache + if err := yaml.Unmarshal(data, &cache); err != nil { + return nil, err + } + + return &cache, nil +} + +// saveCache saves the marketplace cache to disk +func saveCache(path string, cache *MarketplaceCache) error { + // Ensure directory exists + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + return err + } + + data, err := yaml.Marshal(cache) + if err != nil { + return err + } + + return os.WriteFile(path, data, 0644) +} + +// cloneOrUpdateRepo clones or updates the MCP servers repository +func cloneOrUpdateRepo() (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("failed to get user home directory: %w", err) + } + repoPath := filepath.Join(home, cacheDir, "mcp-servers") + + // Check if repo already exists + if _, err := os.Stat(filepath.Join(repoPath, ".git")); err == nil { + // Repository exists, pull latest changes + cmd := exec.Command("git", "-C", repoPath, "pull", "--depth", "1") + if err := cmd.Run(); err != nil { + return "", fmt.Errorf("failed to update repository: %w", err) + } + return repoPath, nil + } + + // Clone repository + if err := os.MkdirAll(filepath.Dir(repoPath), 0755); err != nil { + return "", err + } + + cmd := exec.Command("git", "clone", "--depth", "1", githubRepoURL, repoPath) + if err := cmd.Run(); err != nil { + return "", fmt.Errorf("failed to clone repository: %w", err) + } + + return repoPath, nil +} + +// parseServers parses MCP servers from the repository +func parseServers(repoPath string) ([]MCPServerEntry, error) { + var servers []MCPServerEntry + + // Parse reference servers from README + readmePath := filepath.Join(repoPath, "README.md") + referenceServers, err := parseREADME(readmePath, "reference") + if err != nil { + return nil, fmt.Errorf("failed to parse reference servers: %w", err) + } + servers = append(servers, referenceServers...) + + // Parse official third-party servers from README + officialServers, err := parseREADME(readmePath, "official") + if err != nil { + return nil, fmt.Errorf("failed to parse official servers: %w", err) + } + servers = append(servers, officialServers...) + + // Enhance reference servers with installation info from src/ + srcPath := filepath.Join(repoPath, "src") + if err := enhanceWithInstallInfo(servers, srcPath); err != nil { + return nil, fmt.Errorf("failed to enhance with install info: %w", err) + } + + return servers, nil +} + +// parseREADME parses server entries from the README file +func parseREADME(path string, category string) ([]MCPServerEntry, error) { + file, err := os.Open(path) + if err != nil { + return nil, err + } + defer func() { + if cerr := file.Close(); cerr != nil && err == nil { + err = cerr + } + }() + + var servers []MCPServerEntry + scanner := bufio.NewScanner(file) + inSection := false + sectionHeader := "" + + if category == "reference" { + sectionHeader = "## 🌟 Reference Servers" + } else if category == "official" { + sectionHeader = "### 🎖️ Official Integrations" + } + + // Regex to match server entries like: + // - **[Name](url)** - Description + // - **[Name](url)** - Description + entryRegex := regexp.MustCompile(`\*\*\[([^\]]+)\]\(([^\)]+)\)\*\*\s*[-–]\s*(.+)`) + + for scanner.Scan() { + line := scanner.Text() + + // Check if we entered the target section + if strings.Contains(line, sectionHeader) { + inSection = true + continue + } + + // Check if we left the section (next ## or ### header) + if inSection && strings.HasPrefix(line, "##") && !strings.Contains(line, sectionHeader) { + break + } + if inSection && category == "official" && strings.HasPrefix(line, "###") && !strings.Contains(line, sectionHeader) { + break + } + + // Parse server entry + if inSection && strings.HasPrefix(strings.TrimSpace(line), "-") { + matches := entryRegex.FindStringSubmatch(line) + if len(matches) == 4 { + servers = append(servers, MCPServerEntry{ + Name: matches[1], + URL: matches[2], + Description: strings.TrimSpace(matches[3]), + Category: category, + }) + } + } + } + + if err := scanner.Err(); err != nil { + return nil, err + } + + return servers, nil +} + +// enhanceWithInstallInfo adds installation information from package files +func enhanceWithInstallInfo(servers []MCPServerEntry, srcPath string) error { + for i := range servers { + server := &servers[i] + + // Only process reference servers with local paths + if server.Category != "reference" || !strings.HasPrefix(server.URL, "src/") { + continue + } + + // Extract directory name from URL + dirName := strings.TrimPrefix(server.URL, "src/") + serverPath := filepath.Join(srcPath, dirName) + + // Check for package.json (Node.js/TypeScript) + packageJSONPath := filepath.Join(serverPath, "package.json") + if _, err := os.Stat(packageJSONPath); err == nil { + if err := parsePackageJSON(server, packageJSONPath); err == nil { + continue + } + } + + // Check for pyproject.toml (Python) + pyprojectPath := filepath.Join(serverPath, "pyproject.toml") + if _, err := os.Stat(pyprojectPath); err == nil { + if err := parsePyProject(server, pyprojectPath); err == nil { + continue + } + } + } + + return nil +} + +// parsePackageJSON extracts installation info from package.json +func parsePackageJSON(server *MCPServerEntry, path string) error { + data, err := os.ReadFile(path) + if err != nil { + return err + } + + var pkg struct { + Name string `json:"name"` + Bin map[string]string `json:"bin"` + } + + if err := json.Unmarshal(data, &pkg); err != nil { + return err + } + + if pkg.Name != "" { + server.PackageManager = "npx" + server.PackageName = pkg.Name + server.Command = "npx" + server.Args = []string{"-y", pkg.Name} + } + + return nil +} + +// parsePyProject extracts installation info from pyproject.toml +func parsePyProject(server *MCPServerEntry, path string) error { + data, err := os.ReadFile(path) + if err != nil { + return err + } + + // Simple TOML parsing for name field + // Look for name = "value" in [project] section + lines := strings.Split(string(data), "\n") + inProject := false + for _, line := range lines { + line = strings.TrimSpace(line) + + if line == "[project]" { + inProject = true + continue + } + + if inProject && strings.HasPrefix(line, "[") { + break + } + + if inProject && strings.HasPrefix(line, "name = ") { + // Extract name value + name := strings.TrimPrefix(line, "name = ") + name = strings.Trim(name, `"`) + + server.PackageManager = "uvx" + server.PackageName = name + server.Command = "uvx" + server.Args = []string{name} + break + } + } + + return nil +} diff --git a/pkg/marketplace/types.go b/pkg/marketplace/types.go new file mode 100644 index 0000000..eacc614 --- /dev/null +++ b/pkg/marketplace/types.go @@ -0,0 +1,20 @@ +package marketplace + +// MCPServerEntry represents an MCP server available in the marketplace +type MCPServerEntry struct { + Name string `json:"name" yaml:"name"` + Description string `json:"description" yaml:"description"` + Category string `json:"category" yaml:"category"` // "reference", "official", "community" + URL string `json:"url" yaml:"url"` + // Installation info derived from package.json or pyproject.toml + PackageManager string `json:"package_manager,omitempty" yaml:"package_manager,omitempty"` // "npm", "pip", "uvx", etc. + PackageName string `json:"package_name,omitempty" yaml:"package_name,omitempty"` + Command string `json:"command,omitempty" yaml:"command,omitempty"` + Args []string `json:"args,omitempty" yaml:"args,omitempty"` +} + +// MarketplaceCache represents the cached marketplace data +type MarketplaceCache struct { + LastUpdated string `json:"last_updated" yaml:"last_updated"` + Servers []MCPServerEntry `json:"servers" yaml:"servers"` +} diff --git a/pkg/models/types_test.go b/pkg/models/types_test.go index 9c51205..fbea0a7 100644 --- a/pkg/models/types_test.go +++ b/pkg/models/types_test.go @@ -47,8 +47,8 @@ func TestMCPServerJSONMarshaling(t *testing.T) { func TestMCPServerYAMLMarshaling(t *testing.T) { server := MCPServer{ - Type: "http", - URL: "https://api.example.com/mcp", + Type: "http", + URL: "https://api.example.com/mcp", Headers: map[string]string{ "Authorization": "Bearer token", }, diff --git a/pkg/platform/platform.go b/pkg/platform/platform.go index 19660d2..7d90df7 100644 --- a/pkg/platform/platform.go +++ b/pkg/platform/platform.go @@ -10,12 +10,12 @@ import ( // Info contains platform and user information type Info struct { - OS string - Arch string - Username string - HomeDir string - ClaudeConfigDir string - ClaudeConfig string + OS string + Arch string + Username string + HomeDir string + ClaudeConfigDir string + ClaudeConfig string MCPComposeConfig string }