Skip to content

Commit a36e450

Browse files
committed
v0.55.1 — Fix mcpclient nil interface bug + concurrent test race
Root cause: Classic Go nil interface gotcha — *rpcError nil pointer stored in error interface became non-nil, causing ALL call() results to be returned as errors with text '<nil>' instead of successfully parsed responses. Fix: Convert *rpcError to error interface with explicit nil check before routing responses in readLoop. Concurrent test: Mock server assumed request arrival order determined which goroutine got which response. Fixed by extracting caller identity from tool-call arguments (id:a vs id:b) so responses are correctly routed regardless of goroutine schedule.
1 parent fff12a5 commit a36e450

2 files changed

Lines changed: 238 additions & 50 deletions

File tree

internal/mcpclient/client.go

Lines changed: 79 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,12 @@ type lineResult struct {
140140
err error
141141
}
142142

143+
// callResponse carries a JSON-RPC response from readLoop to the waiting caller.
144+
type callResponse struct {
145+
result json.RawMessage
146+
err error
147+
}
148+
143149
// ── Client ─────────────────────────────────────────────────────────────
144150

145151
// Client manages a connection to an external MCP server over stdio.
@@ -150,8 +156,10 @@ type Client struct {
150156
stdout *bufio.Reader
151157
lineCh chan lineResult // single-reader goroutine sends lines here
152158
done chan struct{} // closed when process exits
153-
mu sync.Mutex
154-
nextID int
159+
160+
mu sync.Mutex
161+
nextID int
162+
pending map[int]chan callResponse // routes responses to waiting callers
155163
}
156164

157165
// New spawns an MCP server process and returns a client connected to it.
@@ -184,12 +192,13 @@ func New(name string, cfg ServerConfig) (*Client, error) {
184192
}
185193

186194
c := &Client{
187-
name: name,
188-
cmd: cmd,
189-
stdin: stdin,
190-
stdout: bufio.NewReader(stdout),
191-
lineCh: make(chan lineResult, 10),
192-
done: make(chan struct{}),
195+
name: name,
196+
cmd: cmd,
197+
stdin: stdin,
198+
stdout: bufio.NewReader(stdout),
199+
lineCh: make(chan lineResult, 10),
200+
done: make(chan struct{}),
201+
pending: make(map[int]chan callResponse),
193202
}
194203

195204
// Start single-reader goroutine
@@ -333,19 +342,27 @@ func (c *Client) call(ctx context.Context, method string, params json.RawMessage
333342
ctx = context.Background()
334343
}
335344

336-
// Assign unique ID
345+
// Assign unique ID and register a response channel.
346+
respCh := make(chan callResponse, 1)
347+
337348
c.mu.Lock()
338349
id := c.nextID
339350
c.nextID++
340-
c.mu.Unlock()
341-
342-
// Build request
343351
req := request{
344352
JSONRPC: "2.0",
345353
ID: id,
346354
Method: method,
347355
Params: params,
348356
}
357+
c.pending[id] = respCh
358+
c.mu.Unlock()
359+
360+
// Unregister on exit to prevent map leak.
361+
defer func() {
362+
c.mu.Lock()
363+
delete(c.pending, id)
364+
c.mu.Unlock()
365+
}()
349366

350367
reqRaw, err := json.Marshal(req)
351368
if err != nil {
@@ -360,56 +377,68 @@ func (c *Client) call(ctx context.Context, method string, params json.RawMessage
360377
return nil, fmt.Errorf("write: %w", err)
361378
}
362379

363-
// Read responses until we find our ID
364-
for {
365-
select {
366-
case <-ctx.Done():
367-
return nil, ctx.Err()
368-
default:
369-
}
370-
371-
// Set a read deadline via context
372-
line, err := c.readLine(ctx)
373-
if err != nil {
374-
return nil, fmt.Errorf("read: %w", err)
375-
}
376-
377-
var resp response
378-
if err := json.Unmarshal([]byte(line), &resp); err != nil {
379-
continue // skip malformed lines
380-
}
381-
382-
// Skip responses that aren't for our request
383-
if resp.ID != id {
384-
continue
380+
// Wait for response via channel (dispatched by readLoop).
381+
select {
382+
case <-ctx.Done():
383+
return nil, ctx.Err()
384+
case cr, ok := <-respCh:
385+
if !ok {
386+
return nil, fmt.Errorf("connection closed before response received")
385387
}
386-
387-
if resp.Error != nil {
388-
return nil, resp.Error
388+
if cr.err != nil {
389+
return nil, cr.err
389390
}
390-
391-
return resp.Result, nil
391+
return cr.result, nil
392392
}
393393
}
394394

395395
// readLoop is a single reader goroutine that reads lines from stdout and
396-
// sends them on lineCh. It exits when stdout returns an error (EOF on pipe close).
396+
// routes each response to the correct waiting caller via the pending map.
397+
// This prevents response misrouting when multiple concurrent call() instances
398+
// are reading from the same connection.
399+
// Exits when stdout returns an error (EOF on pipe close).
397400
func (c *Client) readLoop() {
398-
defer close(c.lineCh)
399401
for {
400402
line, err := c.stdout.ReadString('\n')
401-
select {
402-
case c.lineCh <- lineResult{strings.TrimSpace(line), err}:
403-
default:
404-
// Channel full — drain the queue by consuming one stale entry,
405-
// then retry. This prevents a stuck server from deadlocking the
406-
// reader goroutine.
407-
<-c.lineCh
408-
c.lineCh <- lineResult{strings.TrimSpace(line), err}
409-
}
410403
if err != nil {
404+
// Process exited or pipe closed — unblock all waiters.
405+
c.mu.Lock()
406+
for id, ch := range c.pending {
407+
close(ch)
408+
delete(c.pending, id)
409+
}
410+
c.mu.Unlock()
411411
return
412412
}
413+
line = strings.TrimSpace(line)
414+
if line == "" {
415+
continue
416+
}
417+
418+
var resp response
419+
if err := json.Unmarshal([]byte(line), &resp); err != nil {
420+
continue // skip malformed lines
421+
}
422+
423+
// Route to the waiting caller, if any.
424+
c.mu.Lock()
425+
ch, ok := c.pending[resp.ID]
426+
if ok {
427+
delete(c.pending, resp.ID)
428+
}
429+
c.mu.Unlock()
430+
431+
if ok && ch != nil {
432+
// Non-blocking send in case caller has already timed out.
433+
var rpcErr error
434+
if resp.Error != nil {
435+
rpcErr = resp.Error
436+
}
437+
select {
438+
case ch <- callResponse{result: resp.Result, err: rpcErr}:
439+
default:
440+
}
441+
}
413442
}
414443
}
415444

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
package mcpclient
2+
3+
import (
4+
"bufio"
5+
"context"
6+
"encoding/json"
7+
"fmt"
8+
"io"
9+
"strings"
10+
"sync"
11+
"testing"
12+
"time"
13+
)
14+
15+
// TestClient_ConcurrentCallsNoResponseLoss verifies that concurrent tool calls
16+
// to the same MCP client don't lose responses due to incorrect routing.
17+
// BUG: call() read from a shared lineCh and discarded responses with
18+
// non-matching IDs. With concurrent calls, responses for one goroutine
19+
// were consumed and silently dropped by another.
20+
func TestClient_ConcurrentCallsNoResponseLoss(t *testing.T) {
21+
// Use io.Pipe for bidirectional communication with a simulated server.
22+
serverRead, clientWrite := io.Pipe() // client writes to server
23+
clientRead, serverWrite := io.Pipe() // server writes to client
24+
25+
client := &Client{
26+
name: "test",
27+
nextID: 0,
28+
stdin: clientWrite,
29+
stdout: bufio.NewReader(clientRead),
30+
pending: make(map[int]chan callResponse),
31+
done: make(chan struct{}),
32+
}
33+
go client.readLoop()
34+
35+
// Simulate a server that reads both requests, then sends responses
36+
// out of order (pipelined), identifying each response by the
37+
// "request_id" field in the tool call arguments rather than by
38+
// arrival order — this avoids a race condition where goroutine
39+
// scheduling determines which request arrives first.
40+
type rawRequest struct {
41+
ID int `json:"id"`
42+
Method string `json:"method"`
43+
Params json.RawMessage `json:"params,omitempty"`
44+
}
45+
go func() {
46+
scanner := bufio.NewScanner(serverRead)
47+
var reqs []rawRequest
48+
49+
for scanner.Scan() {
50+
line := strings.TrimSpace(scanner.Text())
51+
if line == "" {
52+
continue
53+
}
54+
var req rawRequest
55+
if err := json.Unmarshal([]byte(line), &req); err != nil {
56+
t.Errorf("mock server: unmarshal request: %v", err)
57+
continue
58+
}
59+
reqs = append(reqs, req)
60+
if len(reqs) >= 2 {
61+
break
62+
}
63+
}
64+
65+
if len(reqs) < 2 {
66+
t.Errorf("mock server: only got %d requests", len(reqs))
67+
return
68+
}
69+
70+
// Build a map from request ID → caller's tool-call argument "id".
71+
// This avoids a race condition: we don't know which goroutine's
72+
// request arrived first, so we identify responses by content
73+
// rather than by arrival order.
74+
callerIDs := make(map[int]string, 2)
75+
for _, req := range reqs {
76+
var params struct {
77+
Name string `json:"name"`
78+
Arguments map[string]any `json:"arguments"`
79+
}
80+
if req.Params != nil {
81+
json.Unmarshal(req.Params, &params)
82+
}
83+
callerID := "unknown"
84+
if idVal, ok := params.Arguments["id"]; ok {
85+
callerID, _ = idVal.(string)
86+
}
87+
callerIDs[req.ID] = callerID
88+
}
89+
90+
// Send responses out of order: respond to reqs[1] first.
91+
for _, idx := range []int{1, 0} {
92+
req := reqs[idx]
93+
callerID := callerIDs[req.ID]
94+
resp, _ := json.Marshal(response{
95+
JSONRPC: "2.0",
96+
ID: req.ID,
97+
Result: json.RawMessage(`{"content":[{"type":"text","text":"response-` + callerID + `"}]}`),
98+
})
99+
serverWrite.Write(append(resp, '\n'))
100+
if idx == 1 {
101+
time.Sleep(10 * time.Millisecond) // small delay between responses
102+
}
103+
}
104+
}()
105+
106+
// Make two concurrent calls with a timeout.
107+
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
108+
defer cancel()
109+
110+
var mu sync.Mutex
111+
results := make(map[string]string)
112+
var wg sync.WaitGroup
113+
wg.Add(2)
114+
115+
go func() {
116+
defer wg.Done()
117+
text, err := client.CallTool(ctx, "test_tool", `{"id":"a"}`)
118+
mu.Lock()
119+
if err != nil {
120+
results["a"] = fmt.Sprintf("err=%v", err)
121+
} else {
122+
results["a"] = fmt.Sprintf("ok=%s", text)
123+
}
124+
mu.Unlock()
125+
}()
126+
go func() {
127+
defer wg.Done()
128+
text, err := client.CallTool(ctx, "test_tool", `{"id":"b"}`)
129+
mu.Lock()
130+
if err != nil {
131+
results["b"] = fmt.Sprintf("err=%v", err)
132+
} else {
133+
results["b"] = fmt.Sprintf("ok=%s", text)
134+
}
135+
mu.Unlock()
136+
}()
137+
138+
wg.Wait()
139+
140+
mu.Lock()
141+
defer mu.Unlock()
142+
143+
if strings.HasPrefix(results["a"], "err=") && strings.Contains(results["a"], "context deadline") {
144+
t.Errorf("call A timed out — response was stolen by call B")
145+
}
146+
if strings.HasPrefix(results["b"], "err=") && strings.Contains(results["b"], "context deadline") {
147+
t.Errorf("call B timed out — response was stolen by call A")
148+
}
149+
if results["a"] == "ok=response-a" {
150+
t.Logf("call A = %s ✓", results["a"])
151+
} else {
152+
t.Errorf("call A = %q, want 'ok=response-a'", results["a"])
153+
}
154+
if results["b"] == "ok=response-b" {
155+
t.Logf("call B = %s ✓", results["b"])
156+
} else {
157+
t.Errorf("call B = %q, want 'ok=response-b'", results["b"])
158+
}
159+
}

0 commit comments

Comments
 (0)