Spaces:
Build error
Build error
| package main | |
| import ( | |
| "bytes" | |
| "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]interface{} `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]interface{}, error) { | |
| arguments := make(map[string]interface{}) | |
| 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 []interface{} | |
| 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]interface{}) (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, 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 and stderr pipes | |
| var stdout, stderr bytes.Buffer | |
| cmd.Stdout = &stdout | |
| cmd.Stderr = &stderr | |
| // Start the command | |
| if err := cmd.Start(); err != nil { | |
| return "", fmt.Errorf("failed to start command: %w", err) | |
| } | |
| // Write the JSON request to stdin | |
| if _, err := io.WriteString(stdin, jsonRequest+"\n"); err != nil { | |
| return "", fmt.Errorf("failed to write to stdin: %w", err) | |
| } | |
| _ = stdin.Close() | |
| // Wait for the command to complete | |
| if err := cmd.Wait(); err != nil { | |
| return "", fmt.Errorf("command failed: %w, stderr: %s", err, stderr.String()) | |
| } | |
| return stdout.String(), nil | |
| } | |
| 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]interface{} | |
| 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]interface{} | |
| 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 | |
| } | |