diff --git a/cmd/mcptools/commands/alias.go b/cmd/mcptools/commands/alias.go new file mode 100644 index 0000000..dc934c6 --- /dev/null +++ b/cmd/mcptools/commands/alias.go @@ -0,0 +1,147 @@ +package commands + +import ( + "fmt" + "strings" + + "github.com/f/mcptools/pkg/alias" + "github.com/spf13/cobra" +) + +// AliasCmd creates the alias command. +func AliasCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "alias", + Short: "Manage MCP server aliases", + Long: `Manage aliases for MCP servers. + +This command allows you to register MCP server commands with a friendly name and +reuse them later. + +Aliases are stored in $HOME/.mcpt/aliases.json. + +Examples: + # Add a new server alias + mcp alias add myfs npx -y @modelcontextprotocol/server-filesystem ~/ + + # List all registered server aliases + mcp alias list + + # Remove a server alias + mcp alias remove myfs + + # Use an alias with any MCP command + mcp tools myfs`, + } + + cmd.AddCommand(aliasAddCmd()) + cmd.AddCommand(aliasListCmd()) + cmd.AddCommand(aliasRemoveCmd()) + + return cmd +} + +func aliasAddCmd() *cobra.Command { + addCmd := &cobra.Command{ + Use: "add [alias] [command args...]", + Short: "Add a new MCP server alias", + DisableFlagParsing: true, + Long: `Add a new alias for an MCP server command. + +The alias will be registered and can be used in place of the server command. + +Example: + mcp alias add myfs npx -y @modelcontextprotocol/server-filesystem ~/`, + Args: cobra.MinimumNArgs(2), + RunE: func(thisCmd *cobra.Command, args []string) error { + if len(args) == 1 && (args[0] == FlagHelp || args[0] == FlagHelpShort) { + _ = thisCmd.Help() + return nil + } + + aliasName := args[0] + serverCommand := strings.Join(args[1:], " ") + + aliases, err := alias.Load() + if err != nil { + return fmt.Errorf("error loading aliases: %w", err) + } + + aliases[aliasName] = alias.ServerAlias{ + Command: serverCommand, + } + + if saveErr := alias.Save(aliases); saveErr != nil { + return fmt.Errorf("error saving aliases: %w", saveErr) + } + + fmt.Fprintf(thisCmd.OutOrStdout(), "Alias '%s' registered for command: %s\n", aliasName, serverCommand) + return nil + }, + } + return addCmd +} + +func aliasListCmd() *cobra.Command { + return &cobra.Command{ + Use: "list", + Short: "List all registered MCP server aliases", + RunE: func(cmd *cobra.Command, _ []string) error { + // Load existing aliases + aliases, err := alias.Load() + if err != nil { + return fmt.Errorf("error loading aliases: %w", err) + } + + if len(aliases) == 0 { + fmt.Fprintln(cmd.OutOrStdout(), "No aliases registered.") + return nil + } + + fmt.Fprintln(cmd.OutOrStdout(), "Registered MCP server aliases:") + for name, a := range aliases { + fmt.Fprintf(cmd.OutOrStdout(), " %s: %s\n", name, a.Command) + } + + return nil + }, + } +} + +func aliasRemoveCmd() *cobra.Command { + return &cobra.Command{ + Use: "remove ", + Short: "Remove an MCP server alias", + Long: `Remove a registered alias for an MCP server command. + +Example: + mcp alias remove myfs`, + Args: cobra.ExactArgs(1), + RunE: func(thisCmd *cobra.Command, args []string) error { + if len(args) == 1 && (args[0] == FlagHelp || args[0] == FlagHelpShort) { + _ = thisCmd.Help() + return nil + } + + aliasName := args[0] + + aliases, err := alias.Load() + if err != nil { + return fmt.Errorf("error loading aliases: %w", err) + } + + if _, exists := aliases[aliasName]; !exists { + return fmt.Errorf("alias '%s' does not exist", aliasName) + } + + delete(aliases, aliasName) + + if saveErr := alias.Save(aliases); saveErr != nil { + return fmt.Errorf("error saving aliases: %w", saveErr) + } + + fmt.Fprintf(thisCmd.OutOrStdout(), "Alias '%s' removed.\n", aliasName) + return nil + }, + } +} diff --git a/cmd/mcptools/commands/alias_test.go b/cmd/mcptools/commands/alias_test.go new file mode 100644 index 0000000..3582052 --- /dev/null +++ b/cmd/mcptools/commands/alias_test.go @@ -0,0 +1,167 @@ +package commands + +import ( + "bytes" + "os" + "testing" + + "github.com/f/mcptools/pkg/alias" +) + +func TestAliasCommands(t *testing.T) { + // Create a temporary directory for test files + tmpDir := t.TempDir() + if err := os.Setenv("HOME", tmpDir); err != nil { + t.Fatalf("Failed to set HOME environment variable: %v", err) + } + + // Test alias add command + t.Run("add", func(t *testing.T) { + cmd := aliasAddCmd() + buf := new(bytes.Buffer) + cmd.SetOut(buf) + + // Run add command + cmd.SetArgs([]string{"myalias", "echo", "hello"}) + err := cmd.Execute() + if err != nil { + t.Errorf("add command failed: %v", err) + } + + // Check output + output := buf.String() + if output == "" { + t.Error("Expected output from add command, got empty string") + } + + // Verify alias was saved + aliases, err := alias.Load() + if err != nil { + t.Errorf("Failed to load aliases: %v", err) + } + + serverAlias, exists := aliases["myalias"] + if !exists { + t.Error("Alias 'myalias' was not saved") + } else if serverAlias.Command != "echo hello" { + t.Errorf("Incorrect command stored. Expected 'echo hello', got '%s'", serverAlias.Command) + } + }) + + // Test alias list command + t.Run("list", func(t *testing.T) { + // First verify the alias exists + aliases, err := alias.Load() + if err != nil { + t.Errorf("Failed to load aliases: %v", err) + } + + if _, exists := aliases["myalias"]; !exists { + t.Log("Alias 'myalias' not found, adding it for list test") + // Add it back if missing + aliases["myalias"] = alias.ServerAlias{Command: "echo hello"} + err = alias.Save(aliases) + if err != nil { + t.Fatalf("Failed to save alias for test: %v", err) + } + } + + // Setup cmd + cmd := aliasListCmd() + buf := new(bytes.Buffer) + cmd.SetOut(buf) + + // Run list command + err = cmd.Execute() + if err != nil { + t.Errorf("list command failed: %v", err) + } + + // Check output + output := buf.String() + if !bytes.Contains(buf.Bytes(), []byte("myalias")) { + t.Errorf("Expected output to contain 'myalias', got: %s", output) + } + if !bytes.Contains(buf.Bytes(), []byte("echo hello")) { + t.Errorf("Expected output to contain 'echo hello', got: %s", output) + } + }) + + // Test alias remove command + t.Run("remove", func(t *testing.T) { + // First verify the alias exists + aliases, err := alias.Load() + if err != nil { + t.Errorf("Failed to load aliases: %v", err) + } + + if _, exists := aliases["myalias"]; !exists { + t.Log("Alias 'myalias' not found, adding it for remove test") + // Add it back if missing + aliases["myalias"] = alias.ServerAlias{Command: "echo hello"} + err = alias.Save(aliases) + if err != nil { + t.Fatalf("Failed to save alias for test: %v", err) + } + } + + // Setup cmd + cmd := aliasRemoveCmd() + buf := new(bytes.Buffer) + cmd.SetOut(buf) + + // Run remove command + cmd.SetArgs([]string{"myalias"}) + err = cmd.Execute() + if err != nil { + t.Errorf("remove command failed: %v", err) + } + + // Check output + output := buf.String() + if !bytes.Contains(buf.Bytes(), []byte("removed")) { + t.Errorf("Expected output to contain 'removed', got: %s", output) + } + + // Verify alias was removed + updatedAliases, err := alias.Load() + if err != nil { + t.Errorf("Failed to load aliases: %v", err) + } + + if _, exists := updatedAliases["myalias"]; exists { + t.Error("Alias 'myalias' was not removed") + } + }) + + // Test remove non-existent alias + t.Run("remove_nonexistent", func(t *testing.T) { + // Setup cmd + cmd := aliasRemoveCmd() + cmd.SetArgs([]string{"nonexistent"}) + err := cmd.Execute() + + // Should get an error + if err == nil { + t.Error("Expected error when removing non-existent alias, got nil") + } + }) + + // Test main alias command + t.Run("main_command_help", func(t *testing.T) { + cmd := AliasCmd() + buf := new(bytes.Buffer) + cmd.SetOut(buf) + + cmd.SetArgs([]string{"--help"}) + err := cmd.Execute() + if err != nil { + t.Errorf("main alias help command failed: %v", err) + } + + output := buf.String() + if output == "" { + t.Error("Expected help output for main alias command, got empty string") + } + }) +} diff --git a/cmd/mcptools/commands/call.go b/cmd/mcptools/commands/call.go new file mode 100644 index 0000000..83133c0 --- /dev/null +++ b/cmd/mcptools/commands/call.go @@ -0,0 +1,120 @@ +package commands + +import ( + "encoding/json" + "fmt" + "os" + "strings" + + "github.com/spf13/cobra" +) + +// CallCmd creates the call command. +func CallCmd() *cobra.Command { + return &cobra.Command{ + Use: "call entity [command args...]", + Short: "Call a tool, resource, or prompt on the MCP server", + DisableFlagParsing: true, + SilenceUsage: true, + Run: func(thisCmd *cobra.Command, args []string) { + if len(args) == 1 && (args[0] == FlagHelp || args[0] == FlagHelpShort) { + _ = thisCmd.Help() + return + } + + if len(args) == 0 { + fmt.Fprintln(os.Stderr, "Error: entity name is required") + fmt.Fprintln( + os.Stderr, + "Example: mcp call read_file npx -y @modelcontextprotocol/server-filesystem ~", + ) + os.Exit(1) + } + + cmdArgs := args + parsedArgs := []string{} + entityName := "" + + i := 0 + entityExtracted := false + + for i < len(cmdArgs) { + switch { + case (cmdArgs[i] == FlagFormat || cmdArgs[i] == FlagFormatShort) && i+1 < len(cmdArgs): + FormatOption = cmdArgs[i+1] + i += 2 + case (cmdArgs[i] == FlagParams || cmdArgs[i] == FlagParamsShort) && i+1 < len(cmdArgs): + ParamsString = cmdArgs[i+1] + i += 2 + case !entityExtracted: + entityName = cmdArgs[i] + entityExtracted = true + i++ + default: + parsedArgs = append(parsedArgs, cmdArgs[i]) + i++ + } + } + + if entityName == "" { + fmt.Fprintln(os.Stderr, "Error: entity name is required") + fmt.Fprintln( + os.Stderr, + "Example: mcp call read_file npx -y @modelcontextprotocol/server-filesystem ~", + ) + os.Exit(1) + } + + entityType := EntityTypeTool + + parts := strings.SplitN(entityName, ":", 2) + if len(parts) == 2 { + entityType = parts[0] + entityName = parts[1] + } + + if len(parsedArgs) == 0 { + fmt.Fprintln(os.Stderr, "Error: command to execute is required when using stdio transport") + fmt.Fprintln( + os.Stderr, + "Example: mcp call read_file npx -y @modelcontextprotocol/server-filesystem ~", + ) + os.Exit(1) + } + + var params map[string]any + if ParamsString != "" { + if jsonErr := json.Unmarshal([]byte(ParamsString), ¶ms); jsonErr != nil { + fmt.Fprintf(os.Stderr, "Error: invalid JSON for params: %v\n", jsonErr) + os.Exit(1) + } + } + + mcpClient, clientErr := CreateClientFunc(parsedArgs) + if clientErr != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", clientErr) + os.Exit(1) + } + + var resp map[string]any + var execErr error + + switch entityType { + case EntityTypeTool: + resp, execErr = mcpClient.CallTool(entityName, params) + case EntityTypeRes: + resp, execErr = mcpClient.ReadResource(entityName) + case EntityTypePrompt: + resp, execErr = mcpClient.GetPrompt(entityName) + default: + fmt.Fprintf(os.Stderr, "Error: unsupported entity type: %s\n", entityType) + os.Exit(1) + } + + if formatErr := FormatAndPrintResponse(thisCmd, resp, execErr); formatErr != nil { + fmt.Fprintf(os.Stderr, "%v\n", formatErr) + os.Exit(1) + } + }, + } +} diff --git a/cmd/mcptools/commands/call_test.go b/cmd/mcptools/commands/call_test.go new file mode 100644 index 0000000..b9c0112 --- /dev/null +++ b/cmd/mcptools/commands/call_test.go @@ -0,0 +1,103 @@ +package commands + +import ( + "bytes" + "strings" + "testing" +) + +func TestCallCmdRun_Help(t *testing.T) { + // Test that the help flag displays help text + cmd := CallCmd() + buf := new(bytes.Buffer) + cmd.SetOut(buf) + + // Execute with help flag + cmd.SetArgs([]string{"--help"}) + err := cmd.Execute() + if err != nil { + t.Errorf("cmd.Execute() error = %v", err) + } + + // Check that help output is not empty + if buf.String() == "" { + t.Error("Expected help output, got empty string") + } +} + +func TestCallCmdRun_Tool(t *testing.T) { + // Create a mock client that returns successful response + mockResponse := map[string]any{ + "content": []any{ + map[string]any{ + "type": "text", + "text": "Tool executed successfully", + }, + }, + } + + cleanup := setupMockClient(func(method string, _ any) (map[string]any, error) { + if method != "tools/call" { + t.Errorf("Expected method 'tools/call', got %q", method) + } + return mockResponse, nil + }) + defer cleanup() + + // Set up command + cmd := CallCmd() + buf := new(bytes.Buffer) + cmd.SetOut(buf) + + // Execute command with tool + cmd.SetArgs([]string{"test-tool", "--params", `{"key":"value"}`, "server", "arg"}) + err := cmd.Execute() + if err != nil { + t.Errorf("cmd.Execute() error = %v", err) + } + + // Verify output contains expected content + output := strings.TrimSpace(buf.String()) + expectedOutput := "Tool executed successfully" + assertEquals(t, output, expectedOutput) +} + +func TestCallCmdRun_Resource(t *testing.T) { + t.Skip("Skipping resource command test as resources are not working as expected") + + // Create a mock client that returns successful response + mockResponse := map[string]any{ + "resources": []any{ + map[string]any{ + "uri": "test-resource", + "type": "text", + "description": "Test resource description", + }, + }, + } + + cleanup := setupMockClient(func(method string, _ any) (map[string]any, error) { + if method != "resources/read" { + t.Errorf("Expected method 'resources/read', got %q", method) + } + return mockResponse, nil + }) + defer cleanup() + + // Set up command + cmd := CallCmd() + buf := new(bytes.Buffer) + cmd.SetOut(buf) + + // Execute command with resource + cmd.SetArgs([]string{"resource:test-resource", "server", "arg"}) + err := cmd.Execute() + if err != nil { + t.Errorf("cmd.Execute() error = %v", err) + } + + // Verify output contains expected content + output := buf.String() + expectedOutput := "Resource content" + assertContains(t, output, expectedOutput) +} diff --git a/cmd/mcptools/commands/get_prompt.go b/cmd/mcptools/commands/get_prompt.go new file mode 100644 index 0000000..a39ed95 --- /dev/null +++ b/cmd/mcptools/commands/get_prompt.go @@ -0,0 +1,88 @@ +package commands + +import ( + "encoding/json" + "fmt" + "os" + + "github.com/spf13/cobra" +) + +// GetPromptCmd creates the get-prompt command. +func GetPromptCmd() *cobra.Command { + return &cobra.Command{ + Use: "get-prompt prompt [command args...]", + Short: "Get a prompt on the MCP server", + DisableFlagParsing: true, + SilenceUsage: true, + Run: func(thisCmd *cobra.Command, args []string) { + if len(args) == 1 && (args[0] == FlagHelp || args[0] == FlagHelpShort) { + _ = thisCmd.Help() + return + } + + if len(args) == 0 { + fmt.Fprintln(os.Stderr, "Error: prompt name is required") + fmt.Fprintln( + os.Stderr, + "Example: mcp get-prompt read_file npx -y @modelcontextprotocol/server-filesystem ~", + ) + os.Exit(1) + } + + cmdArgs := args + parsedArgs := []string{} + promptName := "" + + i := 0 + promptExtracted := false + + for i < len(cmdArgs) { + switch { + case (cmdArgs[i] == FlagFormat || cmdArgs[i] == FlagFormatShort) && i+1 < len(cmdArgs): + FormatOption = cmdArgs[i+1] + i += 2 + case (cmdArgs[i] == FlagParams || cmdArgs[i] == FlagParamsShort) && i+1 < len(cmdArgs): + ParamsString = cmdArgs[i+1] + i += 2 + case !promptExtracted: + promptName = cmdArgs[i] + promptExtracted = true + i++ + default: + parsedArgs = append(parsedArgs, cmdArgs[i]) + i++ + } + } + + if promptName == "" { + fmt.Fprintln(os.Stderr, "Error: prompt name is required") + fmt.Fprintln( + os.Stderr, + "Example: mcp get-prompt read_file npx -y @modelcontextprotocol/server-filesystem ~", + ) + os.Exit(1) + } + + var params map[string]any + if ParamsString != "" { + if jsonErr := json.Unmarshal([]byte(ParamsString), ¶ms); jsonErr != nil { + fmt.Fprintf(os.Stderr, "Error: invalid JSON for params: %v\n", jsonErr) + os.Exit(1) + } + } + + mcpClient, clientErr := CreateClientFunc(parsedArgs) + if clientErr != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", clientErr) + os.Exit(1) + } + + resp, execErr := mcpClient.GetPrompt(promptName) + if formatErr := FormatAndPrintResponse(thisCmd, resp, execErr); formatErr != nil { + fmt.Fprintf(os.Stderr, "%v\n", formatErr) + os.Exit(1) + } + }, + } +} diff --git a/cmd/mcptools/commands/mock.go b/cmd/mcptools/commands/mock.go new file mode 100644 index 0000000..3f03700 --- /dev/null +++ b/cmd/mcptools/commands/mock.go @@ -0,0 +1,126 @@ +package commands + +import ( + "fmt" + "os" + + "github.com/f/mcptools/pkg/mock" + "github.com/spf13/cobra" +) + +// MockCmd creates the mock command. +func MockCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "mock [type] [name] [description] [content]...", + Short: "Create a mock MCP server with tools, prompts, and resources", + Long: `Create a mock MCP server with tools, prompts, and resources. +This is useful for testing MCP clients without implementing a full server. + +The mock server implements the MCP protocol with: +- Full initialization handshake (initialize method) +- Support for notifications/initialized notification +- Tool listing with standardized schema format +- Tool calling with simple responses +- Resource listing and reading with proper format +- Prompt listing and retrieving with proper format +- Standard error codes (-32601 for method not found) +- Detailed request/response logging to ~/.mcpt/logs/mock.log + +Available types: +- tool +- prompt