chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,150 @@
|
||||
# mcpcurl
|
||||
|
||||
A CLI tool that dynamically builds commands based on schemas retrieved from MCP servers that can
|
||||
be executed against the configured MCP server.
|
||||
|
||||
## Overview
|
||||
|
||||
`mcpcurl` is a command-line interface that:
|
||||
|
||||
1. Connects to an MCP server via stdio
|
||||
2. Dynamically retrieves the available tools schema
|
||||
3. Generates CLI commands corresponding to each tool
|
||||
4. Handles parameter validation based on the schema
|
||||
5. Executes commands and displays responses
|
||||
|
||||
## Installation
|
||||
|
||||
### Prerequisites
|
||||
- Go 1.24 or later
|
||||
- Access to the GitHub MCP Server from either Docker or local build
|
||||
|
||||
### Build from Source
|
||||
```bash
|
||||
cd cmd/mcpcurl
|
||||
go build -o mcpcurl
|
||||
```
|
||||
|
||||
### Using Go Install
|
||||
```bash
|
||||
go install github.com/github/github-mcp-server/cmd/mcpcurl@latest
|
||||
```
|
||||
|
||||
### Verify Installation
|
||||
```bash
|
||||
./mcpcurl --help
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
```console
|
||||
mcpcurl --stdio-server-cmd="<command to start MCP server>" <command> [flags]
|
||||
```
|
||||
|
||||
The `--stdio-server-cmd` flag is required for all commands and specifies the command to run the MCP server.
|
||||
|
||||
### Available Commands
|
||||
|
||||
- `tools`: Contains all dynamically generated tool commands from the schema
|
||||
- `schema`: Fetches and displays the raw schema from the MCP server
|
||||
- `help`: Shows help for any command
|
||||
|
||||
### Examples
|
||||
|
||||
List available tools in Github's MCP server:
|
||||
|
||||
```console
|
||||
% ./mcpcurl --stdio-server-cmd "docker run -i --rm -e GITHUB_PERSONAL_ACCESS_TOKEN mcp/github" tools --help
|
||||
Contains all dynamically generated tool commands from the schema
|
||||
|
||||
Usage:
|
||||
mcpcurl tools [command]
|
||||
|
||||
Available Commands:
|
||||
add_issue_comment Add a comment to an existing issue
|
||||
create_branch Create a new branch in a GitHub repository
|
||||
create_issue Create a new issue in a GitHub repository
|
||||
create_or_update_file Create or update a single file in a GitHub repository
|
||||
create_pull_request Create a new pull request in a GitHub repository
|
||||
create_repository Create a new GitHub repository in your account
|
||||
fork_repository Fork a GitHub repository to your account or specified organization
|
||||
get_file_contents Get the contents of a file or directory from a GitHub repository
|
||||
get_issue Get details of a specific issue in a GitHub repository
|
||||
get_issue_comments Get comments for a GitHub issue
|
||||
list_commits Get list of commits of a branch in a GitHub repository
|
||||
list_issues List issues in a GitHub repository with filtering options
|
||||
push_files Push multiple files to a GitHub repository in a single commit
|
||||
search_code Search for code across GitHub repositories
|
||||
search_issues Search for issues and pull requests across GitHub repositories
|
||||
search_repositories Search for GitHub repositories
|
||||
search_users Search for users on GitHub
|
||||
update_issue Update an existing issue in a GitHub repository
|
||||
|
||||
Flags:
|
||||
-h, --help help for tools
|
||||
|
||||
Global Flags:
|
||||
--pretty Pretty print MCP response (only for JSON responses) (default true)
|
||||
--stdio-server-cmd string Shell command to invoke MCP server via stdio (required)
|
||||
|
||||
Use "mcpcurl tools [command] --help" for more information about a command.
|
||||
```
|
||||
|
||||
Get help for a specific tool:
|
||||
|
||||
```console
|
||||
% ./mcpcurl --stdio-server-cmd "docker run -i --rm -e GITHUB_PERSONAL_ACCESS_TOKEN mcp/github" tools get_issue --help
|
||||
Get details of a specific issue in a GitHub repository
|
||||
|
||||
Usage:
|
||||
mcpcurl tools get_issue [flags]
|
||||
|
||||
Flags:
|
||||
-h, --help help for get_issue
|
||||
--issue_number float
|
||||
--owner string
|
||||
--repo string
|
||||
|
||||
Global Flags:
|
||||
--pretty Pretty print MCP response (only for JSON responses) (default true)
|
||||
--stdio-server-cmd string Shell command to invoke MCP server via stdio (required)
|
||||
|
||||
```
|
||||
|
||||
Use one of the tools:
|
||||
|
||||
```console
|
||||
% ./mcpcurl --stdio-server-cmd "docker run -i --rm -e GITHUB_PERSONAL_ACCESS_TOKEN mcp/github" tools get_issue --owner golang --repo go --issue_number 1
|
||||
{
|
||||
"active_lock_reason": null,
|
||||
"assignee": null,
|
||||
"assignees": [],
|
||||
"author_association": "CONTRIBUTOR",
|
||||
"body": "by **rsc+personal@swtch.com**:\n\n\u003cpre\u003eWhat steps will reproduce the problem?\n1. Run build on Ubuntu 9.10, which uses gcc 4.4.1\n\nWhat is the expected output? What do you see instead?\n\nCgo fails with the following error:\n\n{{{\ngo/misc/cgo/stdio$ make\ncgo file.go\ncould not determine kind of name for C.CString\ncould not determine kind of name for C.puts\ncould not determine kind of name for C.fflushstdout\ncould not determine kind of name for C.free\nthrow: sys·mapaccess1: key not in map\n\npanic PC=0x2b01c2b96a08\nthrow+0x33 /media/scratch/workspace/go/src/pkg/runtime/runtime.c:71\n throw(0x4d2daf, 0x0)\nsys·mapaccess1+0x74 \n/media/scratch/workspace/go/src/pkg/runtime/hashmap.c:769\n sys·mapaccess1(0xc2b51930, 0x2b01)\nmain·*Prog·loadDebugInfo+0xa67 \n/media/scratch/workspace/go/src/cmd/cgo/gcc.go:164\n main·*Prog·loadDebugInfo(0xc2bc0000, 0x2b01)\nmain·main+0x352 \n/media/scratch/workspace/go/src/cmd/cgo/main.go:68\n main·main()\nmainstart+0xf \n/media/scratch/workspace/go/src/pkg/runtime/amd64/asm.s:55\n mainstart()\ngoexit /media/scratch/workspace/go/src/pkg/runtime/proc.c:133\n goexit()\nmake: *** [file.cgo1.go] Error 2\n}}}\n\nPlease use labels and text to provide additional information.\u003c/pre\u003e\n",
|
||||
"closed_at": "2014-12-08T10:02:16Z",
|
||||
"closed_by": null,
|
||||
"comments": 12,
|
||||
"comments_url": "https://api.github.com/repos/golang/go/issues/1/comments",
|
||||
"created_at": "2009-10-22T06:07:26Z",
|
||||
"events_url": "https://api.github.com/repos/golang/go/issues/1/events",
|
||||
[...]
|
||||
}
|
||||
```
|
||||
|
||||
## Dynamic Commands
|
||||
|
||||
All tools provided by the MCP server are automatically available as subcommands under the `tools` command. Each generated command has:
|
||||
|
||||
- Appropriate flags matching the tool's input schema
|
||||
- Validation for required parameters
|
||||
- Type validation
|
||||
- Enum validation (for string parameters with allowable values)
|
||||
- Help text generated from the tool's description
|
||||
|
||||
## How It Works
|
||||
|
||||
1. `mcpcurl` makes a JSON-RPC request to the server using the `tools/list` method
|
||||
2. The server responds with a schema describing all available tools
|
||||
3. `mcpcurl` dynamically builds a command structure based on this schema
|
||||
4. When a command is executed, arguments are converted to a JSON-RPC request
|
||||
5. The request is sent to the server via stdin, and the response is printed to stdout
|
||||
@@ -0,0 +1,557 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestReadJSONRPCResponse_DirectResponse(t *testing.T) {
|
||||
t.Parallel()
|
||||
input := `{"jsonrpc":"2.0","id":1,"result":{"tools":[]}}` + "\n"
|
||||
scanner := bufio.NewScanner(strings.NewReader(input))
|
||||
|
||||
got, err := readJSONRPCResponse(scanner)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got != `{"jsonrpc":"2.0","id":1,"result":{"tools":[]}}` {
|
||||
t.Fatalf("unexpected response: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadJSONRPCResponse_SkipsNotifications(t *testing.T) {
|
||||
t.Parallel()
|
||||
input := strings.Join([]string{
|
||||
`{"jsonrpc":"2.0","method":"notifications/resources/list_changed","params":{}}`,
|
||||
`{"jsonrpc":"2.0","method":"notifications/tools/list_changed"}`,
|
||||
`{"jsonrpc":"2.0","id":42,"result":{"content":[{"type":"text","text":"hello"}]}}`,
|
||||
}, "\n") + "\n"
|
||||
scanner := bufio.NewScanner(strings.NewReader(input))
|
||||
|
||||
got, err := readJSONRPCResponse(scanner)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
var msg map[string]json.RawMessage
|
||||
if err := json.Unmarshal([]byte(got), &msg); err != nil {
|
||||
t.Fatalf("response is not valid JSON: %v", err)
|
||||
}
|
||||
// Verify we got the response with id:42, not a notification
|
||||
var id int
|
||||
if err := json.Unmarshal(msg["id"], &id); err != nil {
|
||||
t.Fatalf("failed to parse id: %v", err)
|
||||
}
|
||||
if id != 42 {
|
||||
t.Fatalf("expected id 42, got %d", id)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadJSONRPCResponse_NoResponse(t *testing.T) {
|
||||
t.Parallel()
|
||||
// Only notifications, no response
|
||||
input := `{"jsonrpc":"2.0","method":"notifications/resources/list_changed","params":{}}` + "\n"
|
||||
scanner := bufio.NewScanner(strings.NewReader(input))
|
||||
|
||||
_, err := readJSONRPCResponse(scanner)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing response, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "unexpected end of output") {
|
||||
t.Fatalf("expected 'unexpected end of output' error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadJSONRPCResponse_EmptyInput(t *testing.T) {
|
||||
t.Parallel()
|
||||
scanner := bufio.NewScanner(strings.NewReader(""))
|
||||
|
||||
_, err := readJSONRPCResponse(scanner)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for empty input, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadJSONRPCResponse_InvalidJSON(t *testing.T) {
|
||||
t.Parallel()
|
||||
input := "not valid json\n"
|
||||
scanner := bufio.NewScanner(strings.NewReader(input))
|
||||
|
||||
_, err := readJSONRPCResponse(scanner)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid JSON, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "failed to parse JSON-RPC message") {
|
||||
t.Fatalf("expected parse error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadJSONRPCResponse_ServerError(t *testing.T) {
|
||||
t.Parallel()
|
||||
input := `{"jsonrpc":"2.0","id":1,"error":{"code":-32601,"message":"method not found"}}` + "\n"
|
||||
scanner := bufio.NewScanner(strings.NewReader(input))
|
||||
|
||||
_, err := readJSONRPCResponse(scanner)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for server error response, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "server returned error") {
|
||||
t.Fatalf("expected 'server returned error', got: %v", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "method not found") {
|
||||
t.Fatalf("expected error to contain server message, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildInitializeRequest(t *testing.T) {
|
||||
t.Parallel()
|
||||
got, err := buildInitializeRequest()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
var msg map[string]json.RawMessage
|
||||
if err := json.Unmarshal([]byte(got), &msg); err != nil {
|
||||
t.Fatalf("result is not valid JSON: %v", err)
|
||||
}
|
||||
|
||||
// Verify required fields
|
||||
for _, field := range []string{"jsonrpc", "id", "method", "params"} {
|
||||
if _, ok := msg[field]; !ok {
|
||||
t.Errorf("missing required field %q", field)
|
||||
}
|
||||
}
|
||||
|
||||
// Verify method
|
||||
var method string
|
||||
if err := json.Unmarshal(msg["method"], &method); err != nil {
|
||||
t.Fatalf("failed to parse method: %v", err)
|
||||
}
|
||||
if method != "initialize" {
|
||||
t.Errorf("expected method 'initialize', got %q", method)
|
||||
}
|
||||
|
||||
// Verify params contain protocolVersion and clientInfo
|
||||
var params map[string]json.RawMessage
|
||||
if err := json.Unmarshal(msg["params"], ¶ms); err != nil {
|
||||
t.Fatalf("failed to parse params: %v", err)
|
||||
}
|
||||
for _, field := range []string{"protocolVersion", "capabilities", "clientInfo"} {
|
||||
if _, ok := params[field]; !ok {
|
||||
t.Errorf("missing params field %q", field)
|
||||
}
|
||||
}
|
||||
|
||||
var version string
|
||||
if err := json.Unmarshal(params["protocolVersion"], &version); err != nil {
|
||||
t.Fatalf("failed to parse protocolVersion: %v", err)
|
||||
}
|
||||
if version != "2024-11-05" {
|
||||
t.Errorf("expected protocolVersion '2024-11-05', got %q", version)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildInitializedNotification(t *testing.T) {
|
||||
t.Parallel()
|
||||
got := buildInitializedNotification()
|
||||
|
||||
var msg map[string]json.RawMessage
|
||||
if err := json.Unmarshal([]byte(got), &msg); err != nil {
|
||||
t.Fatalf("result is not valid JSON: %v", err)
|
||||
}
|
||||
|
||||
// Must have jsonrpc and method
|
||||
var method string
|
||||
if err := json.Unmarshal(msg["method"], &method); err != nil {
|
||||
t.Fatalf("failed to parse method: %v", err)
|
||||
}
|
||||
if method != "notifications/initialized" {
|
||||
t.Errorf("expected method 'notifications/initialized', got %q", method)
|
||||
}
|
||||
|
||||
// Must NOT have an id (it's a notification)
|
||||
if _, hasID := msg["id"]; hasID {
|
||||
t.Error("notification should not have an 'id' field")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user