package main import ( "bufio" "crypto/rand" "encoding/json" "fmt" "io" "math/big" "os" "os/exec" "slices" "strings" "github.com/spf13/cobra" "github.com/spf13/viper" ) type ( // SchemaResponse represents the top-level response containing tools SchemaResponse struct { Result Result `json:"result"` JSONRPC string `json:"jsonrpc"` ID int `json:"id"` } // Result contains the list of available tools Result struct { Tools []Tool `json:"tools"` } // Tool represents a single command with its schema Tool struct { Name string `json:"name"` Description string `json:"description"` InputSchema InputSchema `json:"inputSchema"` } // InputSchema defines the structure of a tool's input parameters InputSchema struct { Type string `json:"type"` Properties map[string]Property `json:"properties"` Required []string `json:"required"` AdditionalProperties bool `json:"additionalProperties"` Schema string `json:"$schema"` } // Property defines a single parameter's type and constraints Property struct { Type string `json:"type"` Description string `json:"description"` Enum []string `json:"enum,omitempty"` Minimum *float64 `json:"minimum,omitempty"` Maximum *float64 `json:"maximum,omitempty"` Items *PropertyItem `json:"items,omitempty"` } // PropertyItem defines the type of items in an array property PropertyItem struct { Type string `json:"type"` Properties map[string]Property `json:"properties,omitempty"` Required []string `json:"required,omitempty"` AdditionalProperties bool `json:"additionalProperties,omitempty"` } // JSONRPCRequest represents a JSON-RPC 2.0 request JSONRPCRequest struct { JSONRPC string `json:"jsonrpc"` ID int `json:"id"` Method string `json:"method"` Params RequestParams `json:"params"` } // RequestParams contains the tool name and arguments RequestParams struct { Name string `json:"name"` Arguments map[string]any `json:"arguments"` } // Content matches the response format of a text content response Content struct { Type string `json:"type"` Text string `json:"text"` } ResponseResult struct { Content []Content `json:"content"` } Response struct { Result ResponseResult `json:"result"` JSONRPC string `json:"jsonrpc"` ID int `json:"id"` } ) var ( // Create root command rootCmd = &cobra.Command{ Use: "mcpcurl", Short: "CLI tool with dynamically generated commands", Long: "A CLI tool for interacting with MCP API based on dynamically loaded schemas", PersistentPreRunE: func(cmd *cobra.Command, _ []string) error { // Skip validation for help and completion commands if cmd.Name() == "help" || cmd.Name() == "completion" { return nil } // Check if the required global flag is provided serverCmd, _ := cmd.Flags().GetString("stdio-server-cmd") if serverCmd == "" { return fmt.Errorf("--stdio-server-cmd is required") } return nil }, } // Add schema command schemaCmd = &cobra.Command{ Use: "schema", Short: "Fetch schema from MCP server", Long: "Fetches the tools schema from the MCP server specified by --stdio-server-cmd", RunE: func(cmd *cobra.Command, _ []string) error { serverCmd, _ := cmd.Flags().GetString("stdio-server-cmd") if serverCmd == "" { return fmt.Errorf("--stdio-server-cmd is required") } // Build the JSON-RPC request for tools/list jsonRequest, err := buildJSONRPCRequest("tools/list", "", nil) if err != nil { return fmt.Errorf("failed to build JSON-RPC request: %w", err) } // Execute the server command and pass the JSON-RPC request response, err := executeServerCommand(serverCmd, jsonRequest) if err != nil { return fmt.Errorf("error executing server command: %w", err) } // Output the response fmt.Println(response) return nil }, } // Create the tools command toolsCmd = &cobra.Command{ Use: "tools", Short: "Access available tools", Long: "Contains all dynamically generated tool commands from the schema", } ) func main() { rootCmd.AddCommand(schemaCmd) // Add global flag for stdio server command rootCmd.PersistentFlags().String("stdio-server-cmd", "", "Shell command to invoke MCP server via stdio (required)") _ = rootCmd.MarkPersistentFlagRequired("stdio-server-cmd") // Add global flag for pretty printing rootCmd.PersistentFlags().Bool("pretty", true, "Pretty print MCP response (only for JSON or JSONL responses)") // Add the tools command to the root command rootCmd.AddCommand(toolsCmd) // Execute the root command once to parse flags _ = rootCmd.ParseFlags(os.Args[1:]) // Get pretty flag prettyPrint, err := rootCmd.Flags().GetBool("pretty") if err != nil { _, _ = fmt.Fprintf(os.Stderr, "Error getting pretty flag: %v\n", err) os.Exit(1) } // Get server command serverCmd, err := rootCmd.Flags().GetString("stdio-server-cmd") if err == nil && serverCmd != "" { // Fetch schema from server jsonRequest, err := buildJSONRPCRequest("tools/list", "", nil) if err == nil { response, err := executeServerCommand(serverCmd, jsonRequest) if err == nil { // Parse the schema response var schemaResp SchemaResponse if err := json.Unmarshal([]byte(response), &schemaResp); err == nil { // Add all the generated commands as subcommands of tools for _, tool := range schemaResp.Result.Tools { addCommandFromTool(toolsCmd, &tool, prettyPrint) } } } } } // Execute if err := rootCmd.Execute(); err != nil { _, _ = fmt.Fprintf(os.Stderr, "Error executing command: %v\n", err) os.Exit(1) } } // addCommandFromTool creates a cobra command from a tool schema func addCommandFromTool(toolsCmd *cobra.Command, tool *Tool, prettyPrint bool) { // Create command from tool cmd := &cobra.Command{ Use: tool.Name, Short: tool.Description, Run: func(cmd *cobra.Command, _ []string) { // Build a map of arguments from flags arguments, err := buildArgumentsMap(cmd, tool) if err != nil { _, _ = fmt.Fprintf(os.Stderr, "failed to build arguments map: %v\n", err) return } jsonData, err := buildJSONRPCRequest("tools/call", tool.Name, arguments) if err != nil { _, _ = fmt.Fprintf(os.Stderr, "failed to build JSONRPC request: %v\n", err) return } // Execute the server command serverCmd, err := cmd.Flags().GetString("stdio-server-cmd") if err != nil { _, _ = fmt.Fprintf(os.Stderr, "failed to get stdio-server-cmd: %v\n", err) return } response, err := executeServerCommand(serverCmd, jsonData) if err != nil { _, _ = fmt.Fprintf(os.Stderr, "error executing server command: %v\n", err) return } if err := printResponse(response, prettyPrint); err != nil { _, _ = fmt.Fprintf(os.Stderr, "error printing response: %v\n", err) return } }, } // Initialize viper for this command viperInit := func() { viper.Reset() viper.AutomaticEnv() viper.SetEnvPrefix(strings.ToUpper(tool.Name)) viper.SetEnvKeyReplacer(strings.NewReplacer("-", "_")) } // We'll call the init function directly instead of with cobra.OnInitialize // to avoid conflicts between commands viperInit() // Add flags based on schema properties for name, prop := range tool.InputSchema.Properties { isRequired := slices.Contains(tool.InputSchema.Required, name) // Enhance description to indicate if parameter is optional description := prop.Description if !isRequired { description += " (optional)" } switch prop.Type { case "string": cmd.Flags().String(name, "", description) if len(prop.Enum) > 0 { // Add validation in PreRun for enum values cmd.PreRunE = func(cmd *cobra.Command, _ []string) error { for flagName, property := range tool.InputSchema.Properties { if len(property.Enum) > 0 { value, _ := cmd.Flags().GetString(flagName) if value != "" && !slices.Contains(property.Enum, value) { return fmt.Errorf("%s must be one of: %s", flagName, strings.Join(property.Enum, ", ")) } } } return nil } } case "number": cmd.Flags().Float64(name, 0, description) case "integer": cmd.Flags().Int64(name, 0, description) case "boolean": cmd.Flags().Bool(name, false, description) case "array": if prop.Items != nil { switch prop.Items.Type { case "string": cmd.Flags().StringSlice(name, []string{}, description) case "object": cmd.Flags().String(name+"-json", "", description+" (provide as JSON array)") } } } if isRequired { _ = cmd.MarkFlagRequired(name) } // Bind flag to viper _ = viper.BindPFlag(name, cmd.Flags().Lookup(name)) } // Add command to root toolsCmd.AddCommand(cmd) } // buildArgumentsMap extracts flag values into a map of arguments func buildArgumentsMap(cmd *cobra.Command, tool *Tool) (map[string]any, error) { arguments := make(map[string]any) for name, prop := range tool.InputSchema.Properties { switch prop.Type { case "string": if value, _ := cmd.Flags().GetString(name); value != "" { arguments[name] = value } case "number": if value, _ := cmd.Flags().GetFloat64(name); value != 0 { arguments[name] = value } case "integer": if value, _ := cmd.Flags().GetInt64(name); value != 0 { arguments[name] = value } case "boolean": // For boolean, we need to check if it was explicitly set if cmd.Flags().Changed(name) { value, _ := cmd.Flags().GetBool(name) arguments[name] = value } case "array": if prop.Items != nil { switch prop.Items.Type { case "string": if values, _ := cmd.Flags().GetStringSlice(name); len(values) > 0 { arguments[name] = values } case "object": if jsonStr, _ := cmd.Flags().GetString(name + "-json"); jsonStr != "" { var jsonArray []any if err := json.Unmarshal([]byte(jsonStr), &jsonArray); err != nil { return nil, fmt.Errorf("error parsing JSON for %s: %w", name, err) } arguments[name] = jsonArray } } } } } return arguments, nil } // buildJSONRPCRequest creates a JSON-RPC request with the given tool name and arguments func buildJSONRPCRequest(method, toolName string, arguments map[string]any) (string, error) { id, err := rand.Int(rand.Reader, big.NewInt(10000)) if err != nil { return "", fmt.Errorf("failed to generate random ID: %w", err) } request := JSONRPCRequest{ JSONRPC: "2.0", ID: int(id.Int64()), // Random ID between 0 and 9999 Method: method, Params: RequestParams{ Name: toolName, Arguments: arguments, }, } jsonData, err := json.Marshal(request) if err != nil { return "", fmt.Errorf("failed to marshal JSON request: %w", err) } return string(jsonData), nil } // executeServerCommand runs the specified command, performs the MCP initialization // handshake, sends the JSON request to stdin, and returns the response from stdout. func executeServerCommand(cmdStr, jsonRequest string) (string, error) { // Split the command string into command and arguments cmdParts := strings.Fields(cmdStr) if len(cmdParts) == 0 { return "", fmt.Errorf("empty command") } cmd := exec.Command(cmdParts[0], cmdParts[1:]...) //nolint:gosec //mcpcurl is a test command that needs to execute arbitrary shell commands // Setup stdin pipe stdin, err := cmd.StdinPipe() if err != nil { return "", fmt.Errorf("failed to create stdin pipe: %w", err) } // Setup stdout pipe for line-by-line reading stdoutPipe, err := cmd.StdoutPipe() if err != nil { return "", fmt.Errorf("failed to create stdout pipe: %w", err) } // Stderr still uses a buffer var stderr strings.Builder cmd.Stderr = &stderr // Start the command if err := cmd.Start(); err != nil { return "", fmt.Errorf("failed to start command: %w", err) } // Ensure the child process is cleaned up on every return path. // stdin must be closed before Wait so the server sees EOF and exits; // its non-zero exit status on EOF is expected, so we ignore the error. defer func() { _ = stdin.Close() _ = cmd.Wait() }() // Use a scanner with a large buffer for reading JSON-RPC responses scanner := bufio.NewScanner(stdoutPipe) scanner.Buffer(make([]byte, 0, 1024*1024), 1024*1024) // 1MB max line size // Step 1: Send MCP initialize request initReq, err := buildInitializeRequest() if err != nil { return "", fmt.Errorf("failed to build initialize request: %w", err) } if _, err := io.WriteString(stdin, initReq+"\n"); err != nil { return "", fmt.Errorf("failed to write initialize request: %w", err) } // Step 2: Read initialize response (skip any server notifications) if _, err := readJSONRPCResponse(scanner); err != nil { return "", fmt.Errorf("failed to read initialize response: %w, stderr: %s", err, stderr.String()) } // Step 3: Send initialized notification if _, err := io.WriteString(stdin, buildInitializedNotification()+"\n"); err != nil { return "", fmt.Errorf("failed to write initialized notification: %w", err) } // Step 4: Send the actual request if _, err := io.WriteString(stdin, jsonRequest+"\n"); err != nil { return "", fmt.Errorf("failed to write request: %w", err) } // Step 5: Read the actual response (skip any server notifications) response, err := readJSONRPCResponse(scanner) if err != nil { return "", fmt.Errorf("failed to read response: %w, stderr: %s", err, stderr.String()) } return response, nil } // buildInitializeRequest creates the MCP initialize handshake request. func buildInitializeRequest() (string, error) { id, err := rand.Int(rand.Reader, big.NewInt(10000)) if err != nil { return "", fmt.Errorf("failed to generate random ID: %w", err) } msg := map[string]any{ "jsonrpc": "2.0", "id": int(id.Int64()), "method": "initialize", "params": map[string]any{ "protocolVersion": "2024-11-05", "capabilities": map[string]any{}, "clientInfo": map[string]any{ "name": "mcpcurl", "version": "0.1.0", }, }, } data, err := json.Marshal(msg) if err != nil { return "", fmt.Errorf("failed to marshal initialize request: %w", err) } return string(data), nil } // buildInitializedNotification creates the MCP initialized notification. func buildInitializedNotification() string { return `{"jsonrpc":"2.0","method":"notifications/initialized"}` } // readJSONRPCResponse reads lines from the scanner, skipping server-initiated // notifications (messages without an "id" field), and returns the first response. func readJSONRPCResponse(scanner *bufio.Scanner) (string, error) { for scanner.Scan() { line := scanner.Text() // JSON-RPC responses have an "id" field; notifications do not. var msg map[string]json.RawMessage if err := json.Unmarshal([]byte(line), &msg); err != nil { return "", fmt.Errorf("failed to parse JSON-RPC message: %w", err) } if _, hasID := msg["id"]; hasID { if errField, hasErr := msg["error"]; hasErr { return "", fmt.Errorf("server returned error: %s", string(errField)) } return line, nil } // No "id" — this is a notification, skip it } if err := scanner.Err(); err != nil { return "", err } return "", fmt.Errorf("unexpected end of output") } func printResponse(response string, prettyPrint bool) error { if !prettyPrint { fmt.Println(response) return nil } // Parse the JSON response var resp Response if err := json.Unmarshal([]byte(response), &resp); err != nil { return fmt.Errorf("failed to parse JSON: %w", err) } // Extract text from content items of type "text" for _, content := range resp.Result.Content { if content.Type == "text" { var textContentObj map[string]any err := json.Unmarshal([]byte(content.Text), &textContentObj) if err == nil { prettyText, err := json.MarshalIndent(textContentObj, "", " ") if err != nil { return fmt.Errorf("failed to pretty print text content: %w", err) } fmt.Println(string(prettyText)) continue } // Fallback parsing as JSONL var textContentList []map[string]any if err := json.Unmarshal([]byte(content.Text), &textContentList); err != nil { return fmt.Errorf("failed to parse text content as a list: %w", err) } prettyText, err := json.MarshalIndent(textContentList, "", " ") if err != nil { return fmt.Errorf("failed to pretty print array content: %w", err) } fmt.Println(string(prettyText)) } } // If no text content found, print the original response if len(resp.Result.Content) == 0 { fmt.Println(response) } return nil }