diff --git a/README.md b/README.md index fd00e03..9f5d844 100644 --- a/README.md +++ b/README.md @@ -123,6 +123,7 @@ Available Commands: alias Manage MCP server aliases configs Manage MCP server configurations new Create a new MCP project component + servers List the MCP servers installed in file $HOME/mcptools.config help Help about any command completion Generate the autocompletion script for the specified shell @@ -213,6 +214,12 @@ mcp tools --format pretty npx -y @modelcontextprotocol/server-filesystem ~ MCP Tools includes several core commands for interacting with MCP servers: +#### List Installed Servers +Configure MCP Servers in $HOME/mcptools.config. See how to below in section `Installing MCP Servers` +```bash +mcp servers +``` + #### List Available Tools ```bash @@ -277,6 +284,35 @@ read_multiple_files(paths:str[]) This can be helpful for debugging or understanding what's happening on the server side when executing these commands. +### Configuring MCP Servers +You can provide configure of MCP Servers available in file $HOME/mcptools.config in the following format: +``` +{ + "mcpServers": { + "memory": { + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-memory"] + }, + "filesystem": { + "command": "npx", + "args": [ + "-y", + "@modelcontextprotocol/server-filesystem", + "/path/to/allowed/files" + ] + }, + "github": { + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-github"], + "env": { + "GITHUB_PERSONAL_ACCESS_TOKEN": "" + } + } + } +} +``` + + ### Interactive Shell The interactive shell mode allows you to run multiple MCP commands in a single session: diff --git a/cmd/mcptools/commands/servers.go b/cmd/mcptools/commands/servers.go new file mode 100644 index 0000000..eaf007e --- /dev/null +++ b/cmd/mcptools/commands/servers.go @@ -0,0 +1,56 @@ +package commands + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + + "github.com/spf13/cobra" +) + +// ServersConfig represents the root configuration structure +type ServersConfig struct { + MCPServers map[string]ServerConfig `json:"mcpServers"` +} + +// ServersCmd creates the servers command. +func ServersCmd(configPath string, enableServers bool) *cobra.Command { + return &cobra.Command{ + Use: "servers", + Short: "List the MCP servers installed in file $HOME/mcptools.config", + Run: func(cmd *cobra.Command, args []string) { + if !enableServers { + fmt.Fprintf(os.Stderr, "Servers command is not enabled, please create a server config at %s\n", configPath) + return + } + + // Read the config file + configPath := filepath.Join(configPath) + data, err := os.ReadFile(configPath) + if err != nil { + fmt.Fprintf(os.Stderr, "Error reading config file: %v\n", err) + os.Exit(1) + } + + // Parse the JSON + var config ServersConfig + if err := json.Unmarshal(data, &config); err != nil { + fmt.Fprintf(os.Stderr, "Error parsing config file: %v\n", err) + os.Exit(1) + } + + // Print each server configuration + for name, server := range config.MCPServers { + // Print server name and command + fmt.Printf("%s: %s", name, server.Command) + + // Print arguments + for _, arg := range server.Args { + fmt.Printf(" %s", arg) + } + fmt.Println() + } + }, + } +} diff --git a/cmd/mcptools/commands/servers_test.go b/cmd/mcptools/commands/servers_test.go new file mode 100644 index 0000000..d9a7750 --- /dev/null +++ b/cmd/mcptools/commands/servers_test.go @@ -0,0 +1,131 @@ +package commands + +import ( + "encoding/json" + "io" + "os" + "path/filepath" + "testing" +) + +func TestServersCmd(t *testing.T) { + // Create a temporary directory for test files + tempDir, err := os.MkdirTemp("", "mcptools-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tempDir) + + // Create test config file + testConfig := ServersConfig{ + MCPServers: map[string]ServerConfig{ + "test-server": { + Command: "test-command", + Args: []string{"arg1", "arg2"}, + }, + "another-server": { + Command: "another-command", + Args: []string{"arg3"}, + }, + }, + } + + configPath := filepath.Join(tempDir, "mcp_servers.json") + configData, err := json.Marshal(testConfig) + if err != nil { + t.Fatalf("Failed to marshal test config: %v", err) + } + + if err := os.WriteFile(configPath, configData, 0644); err != nil { + t.Fatalf("Failed to write test config: %v", err) + } + + tests := []struct { + name string + configPath string + enableServers bool + expectedOutput string + expectError bool + }{ + { + name: "successful server list", + configPath: configPath, + enableServers: true, + expectedOutput: `another-server: another-command arg3 +test-server: test-command arg1 arg2 +`, + expectError: false, + }, + { + name: "servers disabled", + configPath: configPath, + enableServers: false, + expectedOutput: `Servers command is not enabled, please create a server config at ` + configPath + "\n", + expectError: false, + }, + { + name: "non-existent config file", + configPath: filepath.Join(tempDir, "nonexistent.json"), + enableServers: true, + expectError: true, + }, + { + name: "invalid json config", + configPath: filepath.Join(tempDir, "invalid.json"), + enableServers: true, + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create invalid JSON file for the invalid json test + if tt.name == "invalid json config" { + if err := os.WriteFile(tt.configPath, []byte("invalid json"), 0644); err != nil { + t.Fatalf("Failed to write invalid config: %v", err) + } + } + + // Create pipes for capturing output + oldStdout := os.Stdout + oldStderr := os.Stderr + r, w, _ := os.Pipe() + os.Stdout = w + os.Stderr = w + defer func() { + os.Stdout = oldStdout + os.Stderr = oldStderr + }() + + // Create a command with the test configuration + cmd := ServersCmd(tt.configPath, tt.enableServers) + + // Execute the command + err := cmd.Execute() + + // Close the write end of the pipe + w.Close() + + // Read the output + output, _ := io.ReadAll(r) + + // Check error conditions + if tt.expectError { + if err == nil { + t.Error("Expected error but got none") + } + return + } + + if err != nil { + t.Errorf("Unexpected error: %v", err) + return + } + + // Check output + if got := string(output); got != tt.expectedOutput { + t.Errorf("Expected output:\n%s\nGot:\n%s", tt.expectedOutput, got) + } + }) + } +} diff --git a/cmd/mcptools/main.go b/cmd/mcptools/main.go index 2d2c91a..1819cb6 100644 --- a/cmd/mcptools/main.go +++ b/cmd/mcptools/main.go @@ -4,7 +4,9 @@ Package main implements mcp functionality. package main import ( + "fmt" "os" + "path/filepath" "github.com/f/mcptools/cmd/mcptools/commands" "github.com/spf13/cobra" @@ -24,6 +26,16 @@ func init() { func main() { cobra.EnableCommandSorting = false + // check if mcptools.config exists + configPath := filepath.Join(os.Getenv("HOME"), "mcptools.config") + enableServers := false + if _, err := os.Stat(configPath); os.IsNotExist(err) { + fmt.Fprintf(os.Stderr, "No server config found at %s, servers command will not be available.\n", configPath) + } else { + fmt.Fprintf(os.Stderr, "Loading server config from %s\n", configPath) + enableServers = true + } + rootCmd := commands.RootCmd() rootCmd.AddCommand( commands.VersionCmd(), @@ -41,6 +53,7 @@ func main() { commands.ConfigsCmd(), commands.NewCmd(), commands.GuardCmd(), + commands.ServersCmd(configPath, enableServers), ) if err := rootCmd.Execute(); err != nil { diff --git a/cmd/mcptools/main_test.go b/cmd/mcptools/main_test.go index 1be87c8..71ad8a4 100644 --- a/cmd/mcptools/main_test.go +++ b/cmd/mcptools/main_test.go @@ -537,3 +537,133 @@ func TestShellCommands(t *testing.T) { }) } } + +func TestMain(t *testing.T) { + // Create a temporary directory for test files + tempDir, err := os.MkdirTemp("", "mcptools-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tempDir) + + // Create test config file + testConfig := commands.ServersConfig{ + MCPServers: map[string]commands.ServerConfig{ + "test-server": { + Command: "test-command", + Args: []string{"arg1", "arg2"}, + }, + }, + } + + configPath := filepath.Join(tempDir, "mcp_servers.json") + configData, err := json.Marshal(testConfig) + if err != nil { + t.Fatalf("Failed to marshal test config: %v", err) + } + + if err := os.WriteFile(configPath, configData, 0644); err != nil { + t.Fatalf("Failed to write test config: %v", err) + } + + tests := []struct { + name string + configPath string + args []string + expectedOutput string + expectError bool + }{ + { + name: "servers command with valid config", + configPath: configPath, + args: []string{"servers"}, + expectedOutput: `test-server: test-command arg1 arg2 +`, + expectError: false, + }, + { + name: "servers command with non-existent config", + configPath: filepath.Join(tempDir, "nonexistent.json"), + args: []string{"servers"}, + expectedOutput: `Servers command is not enabled, please create a server config at ` + filepath.Join(tempDir, "nonexistent.json") + "\n", + expectError: false, + }, + { + name: "help command shows servers", + configPath: configPath, + args: []string{"--help"}, + expectedOutput: "servers", + expectError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Set up test environment + os.Setenv("HOME", tempDir) + defer os.Unsetenv("HOME") + + // Create pipes for capturing output + oldStdout := os.Stdout + oldStderr := os.Stderr + r, w, _ := os.Pipe() + os.Stdout = w + os.Stderr = w + defer func() { + os.Stdout = oldStdout + os.Stderr = oldStderr + }() + + // Create and execute root command + rootCmd := commands.RootCmd() + rootCmd.AddCommand( + commands.VersionCmd(), + commands.ToolsCmd(), + commands.ResourcesCmd(), + commands.PromptsCmd(), + commands.CallCmd(), + commands.GetPromptCmd(), + commands.ReadResourceCmd(), + commands.ShellCmd(), + commands.WebCmd(), + commands.MockCmd(), + commands.ProxyCmd(), + commands.AliasCmd(), + commands.ConfigsCmd(), + commands.NewCmd(), + commands.GuardCmd(), + commands.ServersCmd(tt.configPath, true), + ) + + // Set the command arguments + rootCmd.SetArgs(tt.args) + + // Execute the command + err := rootCmd.Execute() + + // Close the write end of the pipe + w.Close() + + // Read the output + output, _ := io.ReadAll(r) + + // Check error conditions + if tt.expectError { + if err == nil { + t.Error("Expected error but got none") + } + return + } + + if err != nil { + t.Errorf("Unexpected error: %v", err) + return + } + + // Check output + if got := string(output); !strings.Contains(got, tt.expectedOutput) { + t.Errorf("Expected output to contain:\n%s\nGot:\n%s", tt.expectedOutput, got) + } + }) + } +}