chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 12:37:57 +08:00
commit e30f8ba47c
533 changed files with 115926 additions and 0 deletions
+150
View File
@@ -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
+557
View File
@@ -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
}
+178
View File
@@ -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"], &params); 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")
}
}