Skip to content

Commit 5fd92a2

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 5fd92a2

File tree

2 files changed

+270
-13
lines changed

2 files changed

+270
-13
lines changed

cmd/mcpcurl/main.go

Lines changed: 109 additions & 13 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,124 @@ 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+
// Ensure the child process is cleaned up on any error after Start()
412+
cleanup := func() {
413+
_ = stdin.Close()
414+
_ = cmd.Wait()
415+
}
416+
417+
// Use a scanner with a large buffer for reading JSON-RPC responses
418+
scanner := bufio.NewScanner(stdoutPipe)
419+
scanner.Buffer(make([]byte, 0, 1024*1024), 1024*1024) // 1MB max line size
420+
421+
// Step 1: Send MCP initialize request
422+
initReq, err := buildInitializeRequest()
423+
if err != nil {
424+
cleanup()
425+
return "", fmt.Errorf("failed to build initialize request: %w", err)
426+
}
427+
if _, err := io.WriteString(stdin, initReq+"\n"); err != nil {
428+
cleanup()
429+
return "", fmt.Errorf("failed to write initialize request: %w", err)
430+
}
431+
432+
// Step 2: Read initialize response (skip any server notifications)
433+
if _, err := readJSONRPCResponse(scanner); err != nil {
434+
cleanup()
435+
return "", fmt.Errorf("failed to read initialize response: %w, stderr: %s", err, stderr.String())
436+
}
437+
438+
// Step 3: Send initialized notification
439+
if _, err := io.WriteString(stdin, buildInitializedNotification()+"\n"); err != nil {
440+
cleanup()
441+
return "", fmt.Errorf("failed to write initialized notification: %w", err)
442+
}
443+
444+
// Step 4: Send the actual request
407445
if _, err := io.WriteString(stdin, jsonRequest+"\n"); err != nil {
408-
return "", fmt.Errorf("failed to write to stdin: %w", err)
446+
cleanup()
447+
return "", fmt.Errorf("failed to write request: %w", err)
448+
}
449+
450+
// Step 5: Read the actual response (skip any server notifications)
451+
response, err := readJSONRPCResponse(scanner)
452+
if err != nil {
453+
cleanup()
454+
return "", fmt.Errorf("failed to read response: %w, stderr: %s", err, stderr.String())
409455
}
410-
_ = stdin.Close()
411456

412-
// Wait for the command to complete
413-
if err := cmd.Wait(); err != nil {
414-
return "", fmt.Errorf("command failed: %w, stderr: %s", err, stderr.String())
457+
// Close stdin and wait for process to exit. The server will see EOF and
458+
// exit with a non-zero status, which is expected — we already have the response.
459+
cleanup()
460+
461+
return response, nil
462+
}
463+
464+
// buildInitializeRequest creates the MCP initialize handshake request.
465+
func buildInitializeRequest() (string, error) {
466+
id, err := rand.Int(rand.Reader, big.NewInt(10000))
467+
if err != nil {
468+
return "", fmt.Errorf("failed to generate random ID: %w", err)
469+
}
470+
msg := map[string]any{
471+
"jsonrpc": "2.0",
472+
"id": int(id.Int64()),
473+
"method": "initialize",
474+
"params": map[string]any{
475+
"protocolVersion": "2024-11-05",
476+
"capabilities": map[string]any{},
477+
"clientInfo": map[string]any{
478+
"name": "mcpcurl",
479+
"version": "0.1.0",
480+
},
481+
},
415482
}
483+
data, err := json.Marshal(msg)
484+
if err != nil {
485+
return "", fmt.Errorf("failed to marshal initialize request: %w", err)
486+
}
487+
return string(data), nil
488+
}
489+
490+
// buildInitializedNotification creates the MCP initialized notification.
491+
func buildInitializedNotification() string {
492+
return `{"jsonrpc":"2.0","method":"notifications/initialized"}`
493+
}
416494

417-
return stdout.String(), nil
495+
// readJSONRPCResponse reads lines from the scanner, skipping server-initiated
496+
// notifications (messages without an "id" field), and returns the first response.
497+
func readJSONRPCResponse(scanner *bufio.Scanner) (string, error) {
498+
for scanner.Scan() {
499+
line := scanner.Text()
500+
// JSON-RPC responses have an "id" field; notifications do not.
501+
var msg map[string]json.RawMessage
502+
if err := json.Unmarshal([]byte(line), &msg); err != nil {
503+
return "", fmt.Errorf("failed to parse JSON-RPC message: %w", err)
504+
}
505+
if _, hasID := msg["id"]; hasID {
506+
return line, nil
507+
}
508+
// No "id" — this is a notification, skip it
509+
}
510+
if err := scanner.Err(); err != nil {
511+
return "", err
512+
}
513+
return "", fmt.Errorf("unexpected end of output")
418514
}
419515

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

cmd/mcpcurl/main_test.go

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
package main
2+
3+
import (
4+
"bufio"
5+
"encoding/json"
6+
"strings"
7+
"testing"
8+
)
9+
10+
func TestReadJSONRPCResponse_DirectResponse(t *testing.T) {
11+
t.Parallel()
12+
input := `{"jsonrpc":"2.0","id":1,"result":{"tools":[]}}` + "\n"
13+
scanner := bufio.NewScanner(strings.NewReader(input))
14+
15+
got, err := readJSONRPCResponse(scanner)
16+
if err != nil {
17+
t.Fatalf("unexpected error: %v", err)
18+
}
19+
if got != `{"jsonrpc":"2.0","id":1,"result":{"tools":[]}}` {
20+
t.Fatalf("unexpected response: %s", got)
21+
}
22+
}
23+
24+
func TestReadJSONRPCResponse_SkipsNotifications(t *testing.T) {
25+
t.Parallel()
26+
input := strings.Join([]string{
27+
`{"jsonrpc":"2.0","method":"notifications/resources/list_changed","params":{}}`,
28+
`{"jsonrpc":"2.0","method":"notifications/tools/list_changed"}`,
29+
`{"jsonrpc":"2.0","id":42,"result":{"content":[{"type":"text","text":"hello"}]}}`,
30+
}, "\n") + "\n"
31+
scanner := bufio.NewScanner(strings.NewReader(input))
32+
33+
got, err := readJSONRPCResponse(scanner)
34+
if err != nil {
35+
t.Fatalf("unexpected error: %v", err)
36+
}
37+
38+
var msg map[string]json.RawMessage
39+
if err := json.Unmarshal([]byte(got), &msg); err != nil {
40+
t.Fatalf("response is not valid JSON: %v", err)
41+
}
42+
// Verify we got the response with id:42, not a notification
43+
var id int
44+
if err := json.Unmarshal(msg["id"], &id); err != nil {
45+
t.Fatalf("failed to parse id: %v", err)
46+
}
47+
if id != 42 {
48+
t.Fatalf("expected id 42, got %d", id)
49+
}
50+
}
51+
52+
func TestReadJSONRPCResponse_NoResponse(t *testing.T) {
53+
t.Parallel()
54+
// Only notifications, no response
55+
input := `{"jsonrpc":"2.0","method":"notifications/resources/list_changed","params":{}}` + "\n"
56+
scanner := bufio.NewScanner(strings.NewReader(input))
57+
58+
_, err := readJSONRPCResponse(scanner)
59+
if err == nil {
60+
t.Fatal("expected error for missing response, got nil")
61+
}
62+
if !strings.Contains(err.Error(), "unexpected end of output") {
63+
t.Fatalf("expected 'unexpected end of output' error, got: %v", err)
64+
}
65+
}
66+
67+
func TestReadJSONRPCResponse_EmptyInput(t *testing.T) {
68+
t.Parallel()
69+
scanner := bufio.NewScanner(strings.NewReader(""))
70+
71+
_, err := readJSONRPCResponse(scanner)
72+
if err == nil {
73+
t.Fatal("expected error for empty input, got nil")
74+
}
75+
}
76+
77+
func TestReadJSONRPCResponse_InvalidJSON(t *testing.T) {
78+
t.Parallel()
79+
input := "not valid json\n"
80+
scanner := bufio.NewScanner(strings.NewReader(input))
81+
82+
_, err := readJSONRPCResponse(scanner)
83+
if err == nil {
84+
t.Fatal("expected error for invalid JSON, got nil")
85+
}
86+
if !strings.Contains(err.Error(), "failed to parse JSON-RPC message") {
87+
t.Fatalf("expected parse error, got: %v", err)
88+
}
89+
}
90+
91+
func TestBuildInitializeRequest(t *testing.T) {
92+
t.Parallel()
93+
got, err := buildInitializeRequest()
94+
if err != nil {
95+
t.Fatalf("unexpected error: %v", err)
96+
}
97+
98+
var msg map[string]json.RawMessage
99+
if err := json.Unmarshal([]byte(got), &msg); err != nil {
100+
t.Fatalf("result is not valid JSON: %v", err)
101+
}
102+
103+
// Verify required fields
104+
for _, field := range []string{"jsonrpc", "id", "method", "params"} {
105+
if _, ok := msg[field]; !ok {
106+
t.Errorf("missing required field %q", field)
107+
}
108+
}
109+
110+
// Verify method
111+
var method string
112+
if err := json.Unmarshal(msg["method"], &method); err != nil {
113+
t.Fatalf("failed to parse method: %v", err)
114+
}
115+
if method != "initialize" {
116+
t.Errorf("expected method 'initialize', got %q", method)
117+
}
118+
119+
// Verify params contain protocolVersion and clientInfo
120+
var params map[string]json.RawMessage
121+
if err := json.Unmarshal(msg["params"], &params); err != nil {
122+
t.Fatalf("failed to parse params: %v", err)
123+
}
124+
for _, field := range []string{"protocolVersion", "capabilities", "clientInfo"} {
125+
if _, ok := params[field]; !ok {
126+
t.Errorf("missing params field %q", field)
127+
}
128+
}
129+
130+
var version string
131+
if err := json.Unmarshal(params["protocolVersion"], &version); err != nil {
132+
t.Fatalf("failed to parse protocolVersion: %v", err)
133+
}
134+
if version != "2024-11-05" {
135+
t.Errorf("expected protocolVersion '2024-11-05', got %q", version)
136+
}
137+
}
138+
139+
func TestBuildInitializedNotification(t *testing.T) {
140+
t.Parallel()
141+
got := buildInitializedNotification()
142+
143+
var msg map[string]json.RawMessage
144+
if err := json.Unmarshal([]byte(got), &msg); err != nil {
145+
t.Fatalf("result is not valid JSON: %v", err)
146+
}
147+
148+
// Must have jsonrpc and method
149+
var method string
150+
if err := json.Unmarshal(msg["method"], &method); err != nil {
151+
t.Fatalf("failed to parse method: %v", err)
152+
}
153+
if method != "notifications/initialized" {
154+
t.Errorf("expected method 'notifications/initialized', got %q", method)
155+
}
156+
157+
// Must NOT have an id (it's a notification)
158+
if _, hasID := msg["id"]; hasID {
159+
t.Error("notification should not have an 'id' field")
160+
}
161+
}

0 commit comments

Comments
 (0)