Skip to content

Commit 6c92df9

Browse files
committed
fix: add MCP initialize handshake to mcpcurl
mcpcurl was sending tools/list and tools/call requests without first performing the MCP initialize handshake, causing the server to silently reject all requests and discover zero tools. Before: $ mcpcurl --stdio-server-cmd "github-mcp-server stdio" tools --help (no tools listed) After: $ mcpcurl --stdio-server-cmd "github-mcp-server stdio" tools --help Available Commands: add_comment_to_pending_review ... add_issue_comment ... create_branch ...
1 parent d44894e commit 6c92df9

File tree

1 file changed

+79
-11
lines changed

1 file changed

+79
-11
lines changed

cmd/mcpcurl/main.go

Lines changed: 79 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
package main
22

33
import (
4-
"bytes"
4+
"bufio"
55
"crypto/rand"
66
"encoding/json"
77
"fmt"
@@ -376,8 +376,8 @@ func buildJSONRPCRequest(method, toolName string, arguments map[string]any) (str
376376
return string(jsonData), nil
377377
}
378378

379-
// executeServerCommand runs the specified command, sends the JSON request to stdin,
380-
// and returns the response from stdout
379+
// executeServerCommand runs the specified command, performs the MCP initialization
380+
// handshake, sends the JSON request to stdin, and returns the response from stdout.
381381
func executeServerCommand(cmdStr, jsonRequest string) (string, error) {
382382
// Split the command string into command and arguments
383383
cmdParts := strings.Fields(cmdStr)
@@ -393,28 +393,96 @@ func executeServerCommand(cmdStr, jsonRequest string) (string, error) {
393393
return "", fmt.Errorf("failed to create stdin pipe: %w", err)
394394
}
395395

396-
// Setup stdout and stderr pipes
397-
var stdout, stderr bytes.Buffer
398-
cmd.Stdout = &stdout
396+
// Setup stdout pipe for line-by-line reading
397+
stdoutPipe, err := cmd.StdoutPipe()
398+
if err != nil {
399+
return "", fmt.Errorf("failed to create stdout pipe: %w", err)
400+
}
401+
402+
// Stderr still uses a buffer
403+
var stderr strings.Builder
399404
cmd.Stderr = &stderr
400405

401406
// Start the command
402407
if err := cmd.Start(); err != nil {
403408
return "", fmt.Errorf("failed to start command: %w", err)
404409
}
405410

406-
// Write the JSON request to stdin
411+
// Use a scanner with a large buffer for reading JSON-RPC responses
412+
scanner := bufio.NewScanner(stdoutPipe)
413+
scanner.Buffer(make([]byte, 0, 1024*1024), 1024*1024) // 1MB max line size
414+
415+
// Step 1: Send MCP initialize request
416+
initReq, err := buildInitializeRequest()
417+
if err != nil {
418+
return "", fmt.Errorf("failed to build initialize request: %w", err)
419+
}
420+
if _, err := io.WriteString(stdin, initReq+"\n"); err != nil {
421+
return "", fmt.Errorf("failed to write initialize request: %w", err)
422+
}
423+
424+
// Step 2: Read initialize response
425+
if !scanner.Scan() {
426+
scanErr := scanner.Err()
427+
return "", fmt.Errorf("failed to read initialize response: %v, stderr: %s", scanErr, stderr.String())
428+
}
429+
// Initialize response is discarded — we only need the handshake to complete
430+
431+
// Step 3: Send initialized notification
432+
if _, err := io.WriteString(stdin, buildInitializedNotification()+"\n"); err != nil {
433+
return "", fmt.Errorf("failed to write initialized notification: %w", err)
434+
}
435+
436+
// Step 4: Send the actual request
407437
if _, err := io.WriteString(stdin, jsonRequest+"\n"); err != nil {
408-
return "", fmt.Errorf("failed to write to stdin: %w", err)
438+
return "", fmt.Errorf("failed to write request: %w", err)
409439
}
410-
_ = stdin.Close()
411440

412-
// Wait for the command to complete
441+
// Step 5: Read the actual response
442+
if !scanner.Scan() {
443+
scanErr := scanner.Err()
444+
return "", fmt.Errorf("failed to read response: %v, stderr: %s", scanErr, stderr.String())
445+
}
446+
response := scanner.Text()
447+
448+
// Close stdin and wait for process to exit
449+
_ = stdin.Close()
413450
if err := cmd.Wait(); err != nil {
414451
return "", fmt.Errorf("command failed: %w, stderr: %s", err, stderr.String())
415452
}
416453

417-
return stdout.String(), nil
454+
return response, nil
455+
}
456+
457+
// buildInitializeRequest creates the MCP initialize handshake request.
458+
func buildInitializeRequest() (string, error) {
459+
id, err := rand.Int(rand.Reader, big.NewInt(10000))
460+
if err != nil {
461+
return "", fmt.Errorf("failed to generate random ID: %w", err)
462+
}
463+
msg := map[string]any{
464+
"jsonrpc": "2.0",
465+
"id": int(id.Int64()),
466+
"method": "initialize",
467+
"params": map[string]any{
468+
"protocolVersion": "2024-11-05",
469+
"capabilities": map[string]any{},
470+
"clientInfo": map[string]any{
471+
"name": "mcpcurl",
472+
"version": "0.1.0",
473+
},
474+
},
475+
}
476+
data, err := json.Marshal(msg)
477+
if err != nil {
478+
return "", fmt.Errorf("failed to marshal initialize request: %w", err)
479+
}
480+
return string(data), nil
481+
}
482+
483+
// buildInitializedNotification creates the MCP initialized notification.
484+
func buildInitializedNotification() string {
485+
return `{"jsonrpc":"2.0","method":"notifications/initialized"}`
418486
}
419487

420488
func printResponse(response string, prettyPrint bool) error {

0 commit comments

Comments
 (0)