Files
zero/internal/mcp/client_test.go
7c50a9fb98 security(mcp): prevent server requests from matching client pending responses (#942)
* security(mcp): prevent server requests from matching client pending responses

* fix(mcp): send courtesy -32601 reply asynchronously off read loop

* fix(mcp): restore jsonRPCIDEchoable and validate request IDs before responding

* fix(mcp): queue and drop courtesy replies without blocking client callers

* fix(mcp): drop canceled queued requests

* fix(mcp): preserve valid numeric request IDs

* fix(mcp): treat JSON method member presence as the request discriminator

An explicit empty method string is a request, not a response.
Courtesy -32601 is still dropped when the write queue is full: failAll
on that path stopped the reader and blocked inbound pipe writes
(TestStdioClientUndrainedServerDoesNotBlockCallerWithDeadline).

* fix(mcp): count dropped courtesy replies when the write queue is full

The inbound reader must not stop. An empty select default is now an
atomic counter rather than a silent discard.

* fix(mcp): retain method presence, writer lifecycle, and original JSON-RPC IDs

* fix(mcp): bound courtesy overflow, pin writer queue, match exponent IDs

* fix(mcp): skip SSE frames that carry a method member

Treat method presence the same on stdio and SSE so empty or null method
never completes a pending client request.

---------

Co-authored-by: cl-ment <[email protected]>
Co-authored-by: KRATOS <[email protected]>
2026-09-12 08:34:42 +08:00

1683 lines
50 KiB
Go

package mcp
import (
"bufio"
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"math"
"net/http"
"net/http/httptest"
"os"
"os/exec"
"runtime"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/execution"
)
type mcpExecutionPreparer struct {
request execution.Request
}
func (preparer *mcpExecutionPreparer) PrepareExecution(ctx context.Context, request execution.Request) (execution.PreparedCommand, error) {
preparer.request = request
command := exec.CommandContext(ctx, request.Command.Name, request.Command.Args...)
command.Dir = request.WorkingDirectory
command.Env = request.Command.Env
return execution.PreparedCommand{Command: command}, nil
}
func TestStdioClientListsAndCallsTools(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
executable, err := os.Executable()
if err != nil {
t.Fatal(err)
}
client, err := Connect(ctx, Server{
Name: "docs",
Type: ServerTypeStdio,
Command: executable,
Args: []string{"-test.run=TestMCPStdioHelperProcess", "--"},
Env: map[string]string{"ZERO_MCP_STDIO_HELPER": "1"},
})
if err != nil {
t.Fatalf("Connect() error = %v", err)
}
defer func() {
if err := client.Close(); err != nil {
t.Fatalf("Close() error = %v", err)
}
}()
listed, err := client.ListTools(ctx)
if err != nil {
t.Fatalf("ListTools() error = %v", err)
}
if len(listed) != 1 || listed[0].Name != "lookup" {
t.Fatalf("listed tools = %#v, want lookup", listed)
}
if listed[0].InputSchema["type"] != "object" {
t.Fatalf("lookup schema = %#v, want object schema", listed[0].InputSchema)
}
result, err := client.CallTool(ctx, "lookup", map[string]any{"query": "zero"})
if err != nil {
t.Fatalf("CallTool() error = %v", err)
}
if result.IsError {
t.Fatalf("CallTool() result IsError = true: %#v", result)
}
if got := TextContent(result.Content); got != "lookup: zero" {
t.Fatalf("CallTool() text = %q, want lookup result", got)
}
}
func TestStdioClientUsesTypedMCPExecutionOrigin(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
executable, err := os.Executable()
if err != nil {
t.Fatal(err)
}
preparer := &mcpExecutionPreparer{}
workspace := t.TempDir()
client, err := ConnectWithOptions(ctx, Server{
Name: "docs",
Type: ServerTypeStdio,
Command: executable,
Args: []string{"-test.run=TestMCPStdioHelperProcess", "--"},
Env: map[string]string{"ZERO_MCP_STDIO_HELPER": "1"},
}, ConnectOptions{Execution: execution.NewRunner(preparer), WorkspaceRoot: workspace})
if err != nil {
t.Fatalf("ConnectWithOptions() error = %v", err)
}
defer client.Close()
if preparer.request.Origin != execution.OriginMCPServer || preparer.request.Mode != execution.ModeDurable {
t.Fatalf("execution request = %#v", preparer.request)
}
if preparer.request.WorkingDirectory != workspace {
t.Fatalf("working directory = %q, want %q", preparer.request.WorkingDirectory, workspace)
}
}
func TestStdioClientCloseAllowsConcurrentCallers(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
executable, err := os.Executable()
if err != nil {
t.Fatal(err)
}
client, err := Connect(ctx, Server{
Name: "docs",
Type: ServerTypeStdio,
Command: executable,
Args: []string{"-test.run=TestMCPStdioHelperProcess", "--"},
Env: map[string]string{"ZERO_MCP_STDIO_HELPER": "1"},
})
if err != nil {
t.Fatalf("Connect() error = %v", err)
}
start := make(chan struct{})
errs := make(chan error, 2)
var wait sync.WaitGroup
for range 2 {
wait.Add(1)
go func() {
defer wait.Done()
<-start
errs <- client.Close()
}()
}
close(start)
wait.Wait()
close(errs)
for err := range errs {
if err != nil {
t.Fatalf("Close() error = %v", err)
}
}
}
func TestHTTPClientListsAndCallsTools(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
testServer := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
if request.Method != http.MethodPost {
t.Errorf("method = %s, want POST", request.Method)
http.Error(response, "bad method", http.StatusMethodNotAllowed)
return
}
if request.URL.Path != "/mcp" {
t.Errorf("path = %s, want /mcp", request.URL.Path)
http.Error(response, "bad path", http.StatusNotFound)
return
}
if got := request.Header.Get("Authorization"); got != "Bearer test" {
t.Errorf("Authorization = %q, want bearer header", got)
http.Error(response, "missing auth", http.StatusUnauthorized)
return
}
message := readHTTPRPCMessage(t, request)
switch message.Method {
case "initialize":
response.Header().Set("Mcp-Session-Id", "session-123")
writeHTTPRPCResponse(t, response, message.ID, map[string]any{
"protocolVersion": "2024-11-05",
"capabilities": map[string]any{"tools": map[string]any{}},
"serverInfo": map[string]any{"name": "http-docs", "version": "1.0.0"},
})
case "notifications/initialized":
if got := request.Header.Get("Mcp-Session-Id"); got != "session-123" {
t.Errorf("initialized session header = %q, want session-123", got)
http.Error(response, "missing session", http.StatusBadRequest)
return
}
response.WriteHeader(http.StatusAccepted)
case "tools/list":
if got := request.Header.Get("Mcp-Session-Id"); got != "session-123" {
t.Errorf("tools/list session header = %q, want session-123", got)
http.Error(response, "missing session", http.StatusBadRequest)
return
}
writeHTTPRPCResponse(t, response, message.ID, map[string]any{
"tools": []map[string]any{{
"name": "lookup",
"description": "Lookup documentation",
"inputSchema": map[string]any{"type": "object"},
}},
})
case "tools/call":
var params struct {
Name string `json:"name"`
Arguments map[string]any `json:"arguments"`
}
if err := json.Unmarshal(message.Params, &params); err != nil {
t.Errorf("decode tools/call params: %v", err)
http.Error(response, "bad params", http.StatusBadRequest)
return
}
if params.Name != "lookup" || params.Arguments["query"] != "zero" {
t.Errorf("tools/call params = %#v", params)
http.Error(response, "bad tool call", http.StatusBadRequest)
return
}
writeHTTPRPCResponse(t, response, message.ID, map[string]any{
"content": []map[string]any{{"type": "text", "text": "lookup: zero"}},
})
default:
t.Errorf("unexpected method %q", message.Method)
writeHTTPRPCError(t, response, message.ID, "method not found")
}
}))
defer testServer.Close()
client, err := Connect(ctx, Server{
Name: "docs",
Type: ServerTypeHTTP,
URL: testServer.URL + "/mcp",
Headers: map[string]string{"Authorization": "Bearer test"},
})
if err != nil {
t.Fatalf("Connect() error = %v", err)
}
defer func() {
if err := client.Close(); err != nil {
t.Fatalf("Close() error = %v", err)
}
}()
listed, err := client.ListTools(ctx)
if err != nil {
t.Fatalf("ListTools() error = %v", err)
}
if len(listed) != 1 || listed[0].Name != "lookup" {
t.Fatalf("listed tools = %#v, want lookup", listed)
}
result, err := client.CallTool(ctx, "lookup", map[string]any{"query": "zero"})
if err != nil {
t.Fatalf("CallTool() error = %v", err)
}
if got := TextContent(result.Content); got != "lookup: zero" {
t.Fatalf("CallTool() text = %q, want lookup result", got)
}
}
func TestHTTPClientFollowsSameOriginRedirect(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
testServer := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
if request.URL.Path == "/mcp" {
http.Redirect(response, request, "/redirected", http.StatusTemporaryRedirect)
return
}
if request.Method != http.MethodPost || request.URL.Path != "/redirected" {
t.Errorf("request = %s %s, want POST /redirected", request.Method, request.URL.Path)
http.Error(response, "bad request", http.StatusNotFound)
return
}
if got := request.Header.Get("X-Api-Key"); got != "secret" {
t.Errorf("X-Api-Key = %q, want secret", got)
http.Error(response, "missing auth", http.StatusUnauthorized)
return
}
message := readHTTPRPCMessage(t, request)
switch message.Method {
case "initialize":
writeHTTPRPCResponse(t, response, message.ID, map[string]any{"protocolVersion": "2024-11-05"})
case "notifications/initialized":
response.WriteHeader(http.StatusAccepted)
default:
writeHTTPRPCResponse(t, response, message.ID, map[string]any{})
}
}))
defer testServer.Close()
client, err := Connect(ctx, Server{
Name: "docs",
Type: ServerTypeHTTP,
URL: testServer.URL + "/mcp",
Headers: map[string]string{"X-Api-Key": "secret"},
})
if err != nil {
t.Fatalf("Connect() error = %v", err)
}
if err := client.Close(); err != nil {
t.Fatalf("Close() error = %v", err)
}
}
func TestHTTPClientRejectsCrossOriginRedirectBeforeSendingHeaders(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
var targetHits int32
target := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
atomic.AddInt32(&targetHits, 1)
if got := request.Header.Get("X-Api-Key"); got != "" {
t.Errorf("redirect target received X-Api-Key = %q", got)
}
response.WriteHeader(http.StatusOK)
}))
defer target.Close()
redirector := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
http.Redirect(response, request, target.URL+"/mcp", http.StatusTemporaryRedirect)
}))
defer redirector.Close()
_, err := Connect(ctx, Server{
Name: "docs",
Type: ServerTypeHTTP,
URL: redirector.URL + "/mcp",
Headers: map[string]string{"X-Api-Key": "secret"},
})
if err == nil || !strings.Contains(err.Error(), "cross-origin redirect") {
t.Fatalf("Connect() error = %v, want cross-origin redirect error", err)
}
if got := atomic.LoadInt32(&targetHits); got != 0 {
t.Fatalf("redirect target hits = %d, want 0", got)
}
}
func TestSSEClientListsAndCallsToolsFromRemoteStream(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
events := make(chan string, 4)
testServer := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
if got := request.Header.Get("Authorization"); got != "Bearer test" {
t.Errorf("Authorization = %q, want bearer header", got)
http.Error(response, "missing auth", http.StatusUnauthorized)
return
}
if request.Method == http.MethodGet && request.URL.Path == "/sse" {
if got := request.Header.Get("Accept"); !strings.Contains(got, "text/event-stream") {
t.Errorf("Accept = %q, want text/event-stream", got)
http.Error(response, "bad accept", http.StatusBadRequest)
return
}
flusher, ok := response.(http.Flusher)
if !ok {
t.Errorf("test response writer does not support flushing")
http.Error(response, "streaming unsupported", http.StatusInternalServerError)
return
}
response.Header().Set("Content-Type", "text/event-stream")
if _, err := fmt.Fprint(response, "event: endpoint\ndata: /messages\n\n"); err != nil {
t.Errorf("write endpoint event: %v", err)
return
}
flusher.Flush()
for {
select {
case event := <-events:
if _, err := fmt.Fprint(response, event); err != nil {
t.Errorf("write SSE event: %v", err)
return
}
flusher.Flush()
case <-request.Context().Done():
return
}
}
}
if request.Method != http.MethodPost || request.URL.Path != "/messages" {
t.Errorf("request = %s %s, want POST /messages", request.Method, request.URL.Path)
http.Error(response, "bad request", http.StatusNotFound)
return
}
message := readHTTPRPCMessage(t, request)
switch message.Method {
case "initialize":
events <- formatSSERPCResponse(t, message.ID, map[string]any{
"protocolVersion": "2024-11-05",
"capabilities": map[string]any{"tools": map[string]any{}},
"serverInfo": map[string]any{"name": "sse-docs", "version": "1.0.0"},
})
response.WriteHeader(http.StatusAccepted)
case "notifications/initialized":
response.WriteHeader(http.StatusNoContent)
case "tools/list":
events <- formatSSERPCResponse(t, message.ID, map[string]any{
"tools": []map[string]any{{
"name": "lookup",
"description": "Lookup documentation",
"inputSchema": map[string]any{"type": "object"},
}},
})
response.WriteHeader(http.StatusAccepted)
case "tools/call":
var params struct {
Name string `json:"name"`
Arguments map[string]any `json:"arguments"`
}
if err := json.Unmarshal(message.Params, &params); err != nil {
t.Errorf("decode tools/call params: %v", err)
http.Error(response, "bad params", http.StatusBadRequest)
return
}
if params.Name != "lookup" || params.Arguments["query"] != "zero" {
t.Errorf("tools/call params = %#v", params)
http.Error(response, "bad tool call", http.StatusBadRequest)
return
}
events <- formatSSERPCResponse(t, message.ID, map[string]any{
"content": []map[string]any{{"type": "text", "text": "lookup: zero"}},
})
response.WriteHeader(http.StatusAccepted)
default:
t.Errorf("unexpected method %q", message.Method)
events <- formatSSERPCError(t, message.ID, "method not found")
response.WriteHeader(http.StatusAccepted)
}
}))
defer testServer.Close()
client, err := Connect(ctx, Server{
Name: "docs",
Type: ServerTypeSSE,
URL: testServer.URL + "/sse",
Headers: map[string]string{"Authorization": "Bearer test"},
})
if err != nil {
t.Fatalf("Connect() error = %v", err)
}
defer func() {
if err := client.Close(); err != nil {
t.Fatalf("Close() error = %v", err)
}
}()
listed, err := client.ListTools(ctx)
if err != nil {
t.Fatalf("ListTools() error = %v", err)
}
if len(listed) != 1 || listed[0].Name != "lookup" {
t.Fatalf("listed tools = %#v, want lookup", listed)
}
result, err := client.CallTool(ctx, "lookup", map[string]any{"query": "zero"})
if err != nil {
t.Fatalf("CallTool() error = %v", err)
}
if got := TextContent(result.Content); got != "lookup: zero" {
t.Fatalf("CallTool() text = %q, want lookup result", got)
}
}
func TestSSEClientRejectsCrossOriginEndpointBeforeSendingHeaders(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
var targetHits int32
target := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
atomic.AddInt32(&targetHits, 1)
if got := request.Header.Get("X-Api-Key"); got != "" {
t.Errorf("SSE endpoint target received X-Api-Key = %q", got)
}
response.WriteHeader(http.StatusAccepted)
}))
defer target.Close()
stream := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
if got := request.Header.Get("X-Api-Key"); got != "secret" {
t.Errorf("X-Api-Key = %q, want secret on configured SSE server", got)
http.Error(response, "missing auth", http.StatusUnauthorized)
return
}
if request.Method != http.MethodGet || request.URL.Path != "/sse" {
t.Errorf("request = %s %s, want GET /sse", request.Method, request.URL.Path)
http.Error(response, "bad request", http.StatusNotFound)
return
}
response.Header().Set("Content-Type", "text/event-stream")
_, _ = fmt.Fprintf(response, "event: endpoint\ndata: %s/messages\n\n", target.URL)
if flusher, ok := response.(http.Flusher); ok {
flusher.Flush()
}
}))
defer stream.Close()
_, err := Connect(ctx, Server{
Name: "docs",
Type: ServerTypeSSE,
URL: stream.URL + "/sse",
Headers: map[string]string{"X-Api-Key": "secret"},
})
if err == nil || !strings.Contains(err.Error(), "endpoint origin") {
t.Fatalf("Connect() error = %v, want cross-origin endpoint error", err)
}
if got := atomic.LoadInt32(&targetHits); got != 0 {
t.Fatalf("SSE endpoint target hits = %d, want 0", got)
}
}
func TestHTTPClientReportsNonOKStatus(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
testServer := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
http.Error(response, "server failed", http.StatusBadGateway)
}))
defer testServer.Close()
_, err := Connect(ctx, Server{
Name: "web",
Type: ServerTypeHTTP,
URL: testServer.URL + "/mcp",
})
if err == nil {
t.Fatal("Connect() error = nil, want status error")
}
if !strings.Contains(err.Error(), "502") {
t.Fatalf("error = %q, want HTTP status", err.Error())
}
}
func TestCloseResponseBodyMergesCloseError(t *testing.T) {
closeErr := errors.New("close failed")
var err error
closeResponseBody(&err, Server{Name: "web", Type: ServerTypeHTTP}, errorCloser{err: closeErr})
if !errors.Is(err, closeErr) {
t.Fatalf("closeResponseBody() error = %v, want wrapped close error", err)
}
if !strings.Contains(err.Error(), "close MCP http response from web") {
t.Fatalf("closeResponseBody() error = %q, want close context", err.Error())
}
baseErr := errors.New("decode failed")
err = baseErr
closeResponseBody(&err, Server{Name: "web", Type: ServerTypeHTTP}, errorCloser{err: closeErr})
if !errors.Is(err, baseErr) || !errors.Is(err, closeErr) {
t.Fatalf("closeResponseBody() merged error = %v, want base and close errors", err)
}
}
func TestConnectRejectsUnsupportedTransport(t *testing.T) {
_, err := Connect(context.Background(), Server{Name: "web", Type: ServerType("websocket")})
if err == nil {
t.Fatal("Connect() error = nil, want unsupported transport error")
}
if !strings.Contains(err.Error(), "unsupported MCP transport") {
t.Fatalf("error = %q, want unsupported transport", err.Error())
}
}
func TestClientRequestWaitsForMatchingResponseID(t *testing.T) {
// Pipes, not a pre-filled bytes.Buffer: the fake server responds only AFTER
// reading the outgoing request, matching production ordering. With responses
// pre-loaded, the client's reader goroutine could consume the matching
// response — and hit EOF — before request() registered its pending id,
// flaking as "request() error = EOF" on fast runners.
serverReader, clientWriter := io.Pipe() // client → server
clientReader, serverWriter := io.Pipe() // server → client
t.Cleanup(func() {
_ = clientWriter.Close()
_ = serverWriter.Close()
})
serverErr := make(chan error, 1)
go func() {
requests := newMessageReader(serverReader)
request, err := requests.read()
if err != nil {
serverErr <- err
return
}
responses := newMessageWriter(serverWriter)
for _, message := range []rpcMessage{
{Method: "notifications/progress"},
{ID: 99, Error: &rpcError{Code: -32000, Message: "wrong response"}},
{ID: request.ID, Result: mustRaw(map[string]any{"value": "matched"})},
} {
if err := responses.write(message); err != nil {
serverErr <- err
return
}
}
serverErr <- nil
}()
client := &Client{
reader: newMessageReader(clientReader),
writer: newMessageWriter(clientWriter),
nextID: 1,
}
var result struct {
Value string `json:"value"`
}
if err := client.request(context.Background(), "tools/list", map[string]any{}, &result); err != nil {
t.Fatalf("request() error = %v", err)
}
if result.Value != "matched" {
t.Fatalf("result.Value = %q, want matched response", result.Value)
}
if err := <-serverErr; err != nil {
t.Fatalf("fake server error: %v", err)
}
}
type errorCloser struct {
err error
}
func (closer errorCloser) Close() error {
return closer.err
}
func TestMCPStdioHelperProcess(t *testing.T) {
if os.Getenv("ZERO_MCP_STDIO_HELPER") != "1" {
return
}
reader := newMessageReader(os.Stdin)
writer := newMessageWriter(os.Stdout)
for {
message, err := reader.read()
if err != nil {
if strings.Contains(err.Error(), "EOF") {
os.Exit(0)
}
fmt.Fprintf(os.Stderr, "read helper message: %v\n", err)
os.Exit(1)
}
if message.Method == "notifications/initialized" {
continue
}
switch message.Method {
case "initialize":
_ = writer.write(rpcMessage{
JSONRPC: "2.0",
ID: message.ID,
Result: mustRaw(map[string]any{
"protocolVersion": "2024-11-05",
"capabilities": map[string]any{"tools": map[string]any{}},
"serverInfo": map[string]any{"name": "test-docs", "version": "1.0.0"},
}),
})
case "tools/list":
_ = writer.write(rpcMessage{
JSONRPC: "2.0",
ID: message.ID,
Result: mustRaw(map[string]any{
"tools": []map[string]any{{
"name": "lookup",
"description": "Lookup documentation",
"inputSchema": map[string]any{
"type": "object",
"additionalProperties": false,
"required": []string{"query"},
"properties": map[string]any{
"query": map[string]any{"type": "string", "description": "Search query"},
},
},
}},
}),
})
case "tools/call":
var params struct {
Name string `json:"name"`
Arguments map[string]any `json:"arguments"`
}
_ = json.Unmarshal(message.Params, &params)
_ = writer.write(rpcMessage{
JSONRPC: "2.0",
ID: message.ID,
Result: mustRaw(map[string]any{
"content": []map[string]any{{
"type": "text",
"text": "lookup: " + strings.TrimSpace(fmt.Sprint(params.Arguments["query"])),
}},
}),
})
default:
_ = writer.write(rpcMessage{
JSONRPC: "2.0",
ID: message.ID,
Error: &rpcError{Code: -32601, Message: "method not found"},
})
}
}
}
func mustRaw(value any) json.RawMessage {
data, err := json.Marshal(value)
if err != nil {
panic(err)
}
return data
}
func readHTTPRPCMessage(t *testing.T, request *http.Request) rpcMessage {
t.Helper()
defer func() {
if err := request.Body.Close(); err != nil {
t.Fatalf("close HTTP JSON-RPC request body: %v", err)
}
}()
var message rpcMessage
if err := json.NewDecoder(request.Body).Decode(&message); err != nil {
t.Fatalf("decode HTTP JSON-RPC request: %v", err)
}
return message
}
func writeHTTPRPCResponse(t *testing.T, response http.ResponseWriter, id any, result any) {
t.Helper()
response.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(response).Encode(rpcMessage{
JSONRPC: "2.0",
ID: id,
Result: mustRaw(result),
}); err != nil {
t.Fatalf("write HTTP JSON-RPC response: %v", err)
}
}
func writeHTTPRPCError(t *testing.T, response http.ResponseWriter, id any, message string) {
t.Helper()
response.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(response).Encode(rpcMessage{
JSONRPC: "2.0",
ID: id,
Error: &rpcError{Code: -32601, Message: message},
}); err != nil {
t.Fatalf("write HTTP JSON-RPC error: %v", err)
}
}
func formatSSERPCResponse(t *testing.T, id any, result any) string {
t.Helper()
message := rpcMessage{
JSONRPC: "2.0",
ID: id,
Result: mustRaw(result),
}
body, err := json.Marshal(message)
if err != nil {
t.Fatalf("marshal SSE JSON-RPC response: %v", err)
}
return fmt.Sprintf("event: message\ndata: %s\n\n", body)
}
func formatSSERPCError(t *testing.T, id any, message string) string {
t.Helper()
payload := rpcMessage{
JSONRPC: "2.0",
ID: id,
Error: &rpcError{Code: -32601, Message: message},
}
body, err := json.Marshal(payload)
if err != nil {
t.Fatalf("marshal SSE JSON-RPC error: %v", err)
}
return fmt.Sprintf("event: message\ndata: %s\n\n", body)
}
func TestSchemaFromMCPInputSchema(t *testing.T) {
schema := SchemaFromMCP(map[string]any{
"type": "object",
"additionalProperties": false,
"required": []any{"query"},
"properties": map[string]any{
"query": map[string]any{
"type": "string",
"description": "Search query",
"enum": []any{"zero", "docs"},
},
"limit": map[string]any{
"type": "integer",
"default": float64(5),
"minimum": float64(1),
"maximum": float64(10),
},
},
})
if schema.Type != "object" || schema.AdditionalProperties {
t.Fatalf("schema root = %#v", schema)
}
if len(schema.Required) != 1 || schema.Required[0] != "query" {
t.Fatalf("required = %#v, want query", schema.Required)
}
query := schema.Properties["query"]
if query.Type != "string" || len(query.Enum) != 2 {
t.Fatalf("query schema = %#v", query)
}
limit := schema.Properties["limit"]
if limit.Type != "integer" || limit.Minimum == nil || *limit.Minimum != 1 || limit.Maximum == nil || *limit.Maximum != 10 {
t.Fatalf("limit schema = %#v", limit)
}
}
func TestStdioClientServerFromConfig(t *testing.T) {
servers, err := NormalizeConfig(config.MCPConfig{Servers: map[string]config.MCPServerConfig{
"docs": {Type: "stdio", Command: "docs-mcp", Args: []string{"--root", "."}},
}})
if err != nil {
t.Fatal(err)
}
if servers[0].Type != ServerTypeStdio || servers[0].Command != "docs-mcp" {
t.Fatalf("server = %#v", servers[0])
}
}
func TestBoundedBufferCapsRetainedBytes(t *testing.T) {
b := &boundedBuffer{cap: 8}
// Each Write must report the full input length (no short writes), even past cap.
if n, err := b.Write([]byte("hello")); n != 5 || err != nil {
t.Fatalf("Write 1 = (%d,%v), want (5,nil)", n, err)
}
if n, err := b.Write([]byte("world!!!")); n != 8 || err != nil {
t.Fatalf("Write 2 = (%d,%v), want (8,nil)", n, err)
}
// Only the first cap bytes are retained; the overflow is discarded.
if got := b.String(); got != "hellowor" {
t.Fatalf("retained %q, want %q (capped at 8 bytes, head kept)", got, "hellowor")
}
}
func TestStdioClientIgnoresServerInitiatedRequestsInPendingResponses(t *testing.T) {
inReader, inWriter := io.Pipe()
outReader, outWriter := io.Pipe()
client := &Client{
reader: newMessageReader(inReader),
writer: newMessageWriter(outWriter),
pending: make(map[int]chan dispatchResult),
}
defer func() {
_ = inWriter.Close()
_ = outReader.Close()
}()
client.ensureReader()
// Register a pending response for ID 1
responses := make(chan dispatchResult, 1)
client.dispatchMu.Lock()
client.pending[1] = responses
client.dispatchMu.Unlock()
// Simulate server sending a request with ID 1 ("roots/list")
serverReq := `{"jsonrpc":"2.0","id":1,"method":"roots/list","params":{}}` + "\n"
if _, err := inWriter.Write([]byte(serverReq)); err != nil {
t.Fatalf("write server request: %v", err)
}
// The server request must take the separate request path: it receives a
// method-not-found response and cannot resolve the pending client call.
courtesyResult := make(chan dispatchResult, 1)
go func() {
message, err := newMessageReader(outReader).read()
courtesyResult <- dispatchResult{message: message, err: err}
}()
select {
case result := <-courtesyResult:
if result.err != nil {
t.Fatalf("read courtesy response: %v", result.err)
}
if !rpcIDMatches(result.message.ID, 1) || result.message.Error == nil || result.message.Error.Code != -32601 {
t.Fatalf("courtesy response = %#v, want id 1 and error -32601", result.message)
}
case <-time.After(time.Second):
t.Fatal("timed out waiting for method-not-found response")
}
select {
case res := <-responses:
t.Fatalf("pending request 1 received server request: %#v", res.message)
default:
}
// Now send the actual response for ID 1
serverResp := `{"jsonrpc":"2.0","id":1,"result":{"tools":[]}}` + "\n"
if _, err := inWriter.Write([]byte(serverResp)); err != nil {
t.Fatalf("write server response: %v", err)
}
select {
case res := <-responses:
if res.message.Method != "" || len(res.message.Result) == 0 {
t.Fatalf("expected valid response, got %#v", res.message)
}
case <-time.After(500 * time.Millisecond):
t.Fatal("timed out waiting for actual response")
}
}
// TestStdioClientUndrainedServerDoesNotStallReadLoop proves that when a server
// stops draining its stdin, an asynchronous courtesy -32601 reply does not stall
// the read loop from servicing legitimate responses.
func TestStdioClientUndrainedServerDoesNotStallReadLoop(t *testing.T) {
inReader, inWriter := io.Pipe()
outReader, outWriter := io.Pipe()
client := &Client{
reader: newMessageReader(inReader),
writer: newMessageWriter(outWriter),
pending: make(map[int]chan dispatchResult),
}
defer func() {
_ = inWriter.Close()
_ = outReader.Close()
}()
client.ensureReader()
// 1. Register a pending response for call ID 1
responses := make(chan dispatchResult, 1)
client.dispatchMu.Lock()
client.pending[1] = responses
client.dispatchMu.Unlock()
// 2. Server sends request ID 2 (we DO NOT drain outReader so server stdin pipe is blocked)
serverReq := `{"jsonrpc":"2.0","id":2,"method":"roots/list","params":{}}` + "\n"
go func() {
_, _ = inWriter.Write([]byte(serverReq))
// 3. Immediately send response for ID 1
serverResp := `{"jsonrpc":"2.0","id":1,"result":{"tools":[]}}` + "\n"
_, _ = inWriter.Write([]byte(serverResp))
}()
// 4. Verify the response for ID 1 is delivered without being stalled by the undrained pipe
select {
case res := <-responses:
if res.message.Method != "" || len(res.message.Result) == 0 {
t.Fatalf("expected valid response for ID 1, got %#v", res.message)
}
case <-time.After(1 * time.Second):
t.Fatal("readLoop stalled on undrained error write; pending response was not delivered")
}
}
func TestStdioClientDropsInvalidServerRequestIDs(t *testing.T) {
inReader, inWriter := io.Pipe()
outReader, outWriter := io.Pipe()
client := &Client{
reader: newMessageReader(inReader),
writer: newMessageWriter(outWriter),
pending: make(map[int]chan dispatchResult),
}
defer func() {
_ = inWriter.Close()
_ = outReader.Close()
}()
client.ensureReader()
// Server sends request with boolean ID (invalid JSON-RPC id)
serverReq := `{"jsonrpc":"2.0","id":true,"method":"roots/list","params":{}}` + "\n"
if _, err := inWriter.Write([]byte(serverReq)); err != nil {
t.Fatalf("write invalid server request: %v", err)
}
// A following valid request acts as an ordering barrier: receiving its
// response proves readLoop already processed the invalid request. Because the
// writer is serial, any incorrect reply to the boolean ID would arrive first.
validReq := `{"jsonrpc":"2.0","id":7,"method":"roots/list","params":{}}` + "\n"
if _, err := inWriter.Write([]byte(validReq)); err != nil {
t.Fatalf("write valid server request: %v", err)
}
readResult := make(chan dispatchResult, 1)
go func() {
message, err := newMessageReader(outReader).read()
readResult <- dispatchResult{message: message, err: err}
}()
select {
case result := <-readResult:
if result.err != nil {
t.Fatalf("read valid request response: %v", result.err)
}
if !rpcIDMatches(result.message.ID, 7) || result.message.Error == nil || result.message.Error.Code != -32601 {
t.Fatalf("response = %#v, want only valid id 7 and error -32601", result.message)
}
case <-time.After(time.Second):
t.Fatal("timed out waiting for valid request response")
}
}
func TestStdioClientRepliesToValidNonIntegerServerRequestIDs(t *testing.T) {
requests := []struct {
name string
wire string
want string
}{
{name: "fractional", wire: `{"jsonrpc":"2.0","id":1.5,"method":"roots/list","params":{}}` + "\n", want: "1.5"},
{name: "exponent", wire: `{"jsonrpc":"2.0","id":2e2,"method":"roots/list","params":{}}` + "\n", want: "2e2"},
}
for _, request := range requests {
t.Run(request.name, func(t *testing.T) {
inReader, inWriter := io.Pipe()
outReader, outWriter := io.Pipe()
client := &Client{
reader: newMessageReader(inReader),
writer: newMessageWriter(outWriter),
pending: make(map[int]chan dispatchResult),
}
t.Cleanup(func() {
_ = inWriter.Close()
_ = outReader.Close()
})
client.ensureReader()
if _, err := inWriter.Write([]byte(request.wire)); err != nil {
t.Fatalf("write server request: %v", err)
}
result := make(chan dispatchResult, 1)
go func() {
response, err := newMessageReader(outReader).read()
result <- dispatchResult{message: response, err: err}
}()
select {
case response := <-result:
if response.err != nil {
t.Fatalf("read method-not-found response: %v", response.err)
}
got, err := json.Marshal(response.message.ID)
if err != nil {
t.Fatalf("marshal id: %v", err)
}
if string(got) != request.want || response.message.Error == nil || response.message.Error.Code != -32601 {
t.Fatalf("response = %#v, want id %s and error -32601", response.message, request.want)
}
case <-time.After(time.Second):
t.Fatal("timed out waiting for method-not-found response")
}
})
}
}
func TestJSONRPCIDEchoableAcceptsFiniteJSONNumbers(t *testing.T) {
for _, test := range []struct {
name string
id any
want bool
}{
{name: "fractional float", id: 1.5, want: true},
{name: "exponent json number", id: json.Number("2e2"), want: true},
{name: "not a number", id: math.NaN(), want: false},
{name: "positive infinity", id: math.Inf(1), want: false},
{name: "invalid json number", id: json.Number("not-a-number"), want: false},
} {
t.Run(test.name, func(t *testing.T) {
if got := jsonRPCIDEchoable(test.id); got != test.want {
t.Fatalf("jsonRPCIDEchoable(%v) = %v, want %v", test.id, got, test.want)
}
})
}
}
type gatedCaptureWriter struct {
started chan struct{}
release chan struct{}
startedOnce sync.Once
releaseOnce sync.Once
mu sync.Mutex
buffer bytes.Buffer
}
func newGatedCaptureWriter() *gatedCaptureWriter {
return &gatedCaptureWriter{
started: make(chan struct{}),
release: make(chan struct{}),
}
}
func (writer *gatedCaptureWriter) Write(p []byte) (int, error) {
writer.startedOnce.Do(func() { close(writer.started) })
<-writer.release
writer.mu.Lock()
defer writer.mu.Unlock()
return writer.buffer.Write(p)
}
func (writer *gatedCaptureWriter) Release() {
writer.releaseOnce.Do(func() { close(writer.release) })
}
func (writer *gatedCaptureWriter) Bytes() []byte {
writer.mu.Lock()
defer writer.mu.Unlock()
return append([]byte(nil), writer.buffer.Bytes()...)
}
func TestStdioClientDoesNotWriteCanceledQueuedRequest(t *testing.T) {
reader := newBlockingReader()
defer reader.Close()
output := newGatedCaptureWriter()
defer output.Release()
client := &Client{
reader: newMessageReader(reader),
writer: newMessageWriter(output),
pending: make(map[int]chan dispatchResult),
}
blockerDone := make(chan error, 1)
go func() {
blockerDone <- client.writeMessage(context.Background(), rpcMessage{Method: "notifications/blocker"})
}()
select {
case <-output.started:
case <-time.After(time.Second):
t.Fatal("initial write did not reach the blocked transport")
}
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
defer cancel()
if err := client.request(ctx, "tools/call", map[string]any{"name": "side_effect"}, nil); !errors.Is(err, context.DeadlineExceeded) {
t.Fatalf("request() error = %v, want context deadline exceeded", err)
}
output.Release()
if err := <-blockerDone; err != nil {
t.Fatalf("release initial write: %v", err)
}
if err := client.writeMessage(context.Background(), rpcMessage{Method: "notifications/sentinel"}); err != nil {
t.Fatalf("write sentinel: %v", err)
}
messages := newMessageReader(bytes.NewReader(output.Bytes()))
first, err := messages.read()
if err != nil {
t.Fatalf("read initial message: %v", err)
}
if first.Method != "notifications/blocker" {
t.Fatalf("first method = %q, want notifications/blocker", first.Method)
}
second, err := messages.read()
if err != nil {
t.Fatalf("read sentinel message: %v", err)
}
if second.Method != "notifications/sentinel" {
t.Fatalf("second method = %q, want notifications/sentinel", second.Method)
}
if extra, err := messages.read(); !errors.Is(err, io.EOF) {
t.Fatalf("unexpected queued message %#v, read error = %v", extra, err)
}
}
// TestStdioClientUndrainedServerDoesNotBlockCallerWithDeadline verifies that
// when the server's input pipe is completely blocked and courtesy replies are
// queued/dropped, a caller invoking request() with a deadline aborts cleanly
// when the context expires rather than hanging indefinitely on a write or mutex.
func TestStdioClientUndrainedServerDoesNotBlockCallerWithDeadline(t *testing.T) {
inReader, inWriter := io.Pipe()
outReader, outWriter := io.Pipe()
client := &Client{
reader: newMessageReader(inReader),
writer: newMessageWriter(outWriter),
pending: make(map[int]chan dispatchResult),
}
defer func() {
_ = inWriter.Close()
_ = outReader.Close()
}()
client.ensureReader()
// Flood server requests to saturate write queue while outReader is NOT drained.
for i := 1; i <= 50; i++ {
serverReq := fmt.Sprintf(`{"jsonrpc":"2.0","id":%d,"method":"roots/list","params":{}}`+"\n", i+100)
_, err := inWriter.Write([]byte(serverReq))
if err != nil {
t.Fatalf("failed to write server request: %v", err)
}
}
// Caller with short deadline invokes request()
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
defer cancel()
start := time.Now()
err := client.request(ctx, "tools/list", map[string]any{}, nil)
elapsed := time.Since(start)
if !errors.Is(err, context.DeadlineExceeded) && !errors.Is(err, context.Canceled) {
t.Fatalf("expected deadline exceeded, got: %v", err)
}
if elapsed > 1*time.Second {
t.Fatalf("request took too long to abort on deadline: %v", elapsed)
}
}
func TestStdioClientEmptyMethodDoesNotCompletePending(t *testing.T) {
inReader, inWriter := io.Pipe()
outReader, outWriter := io.Pipe()
client := &Client{
reader: newMessageReader(inReader),
writer: newMessageWriter(outWriter),
pending: make(map[int]chan dispatchResult),
}
defer func() {
_ = inWriter.Close()
_ = outReader.Close()
}()
client.ensureReader()
responses := make(chan dispatchResult, 1)
client.dispatchMu.Lock()
client.pending[1] = responses
client.dispatchMu.Unlock()
if _, err := inWriter.Write([]byte(`{"jsonrpc":"2.0","id":1,"method":""}` + "\n")); err != nil {
t.Fatalf("write empty-method frame: %v", err)
}
courtesyResult := make(chan dispatchResult, 1)
go func() {
message, err := newMessageReader(outReader).read()
courtesyResult <- dispatchResult{message: message, err: err}
}()
select {
case result := <-courtesyResult:
if result.err != nil {
t.Fatalf("read courtesy response: %v", result.err)
}
if !rpcIDMatches(result.message.ID, 1) || result.message.Error == nil || result.message.Error.Code != -32601 {
t.Fatalf("courtesy response = %#v, want id 1 and error -32601", result.message)
}
case <-time.After(time.Second):
t.Fatal("timed out waiting for method-not-found response")
}
select {
case res := <-responses:
t.Fatalf("pending request 1 completed by empty-method frame: %#v", res.message)
default:
}
if _, err := inWriter.Write([]byte(`{"jsonrpc":"2.0","id":1,"result":{"tools":[]}}` + "\n")); err != nil {
t.Fatalf("write server response: %v", err)
}
select {
case res := <-responses:
if res.message.Method != "" || len(res.message.Result) == 0 {
t.Fatalf("expected valid response, got %#v", res.message)
}
case <-time.After(500 * time.Millisecond):
t.Fatal("timed out waiting for actual response")
}
}
func TestStdioClientNullMethodDoesNotCompletePending(t *testing.T) {
inReader, inWriter := io.Pipe()
outReader, outWriter := io.Pipe()
client := &Client{
reader: newMessageReader(inReader),
writer: newMessageWriter(outWriter),
pending: make(map[int]chan dispatchResult),
}
defer func() {
_ = inWriter.Close()
_ = outReader.Close()
}()
client.ensureReader()
responses := make(chan dispatchResult, 1)
client.dispatchMu.Lock()
client.pending[1] = responses
client.dispatchMu.Unlock()
if _, err := inWriter.Write([]byte(`{"jsonrpc":"2.0","id":1,"method":null}` + "\n")); err != nil {
t.Fatalf("write null-method frame: %v", err)
}
courtesyResult := make(chan dispatchResult, 1)
go func() {
message, err := newMessageReader(outReader).read()
courtesyResult <- dispatchResult{message: message, err: err}
}()
select {
case result := <-courtesyResult:
if result.err != nil {
t.Fatalf("read courtesy response: %v", result.err)
}
if !rpcIDMatches(result.message.ID, 1) || result.message.Error == nil || result.message.Error.Code != -32601 {
t.Fatalf("courtesy response = %#v, want id 1 and error -32601", result.message)
}
case <-time.After(time.Second):
t.Fatal("timed out waiting for method-not-found response")
}
select {
case res := <-responses:
t.Fatalf("pending request 1 completed by null-method frame: %#v", res.message)
default:
}
if _, err := inWriter.Write([]byte(`{"jsonrpc":"2.0","id":1,"result":{"tools":[]}}` + "\n")); err != nil {
t.Fatalf("write server response: %v", err)
}
select {
case res := <-responses:
if res.message.Method != "" || len(res.message.Result) == 0 {
t.Fatalf("expected valid response, got %#v", res.message)
}
case <-time.After(500 * time.Millisecond):
t.Fatal("timed out waiting for actual response")
}
}
func TestStdioClientCourtesyReplySurvivesFullWriteQueue(t *testing.T) {
inReader, inWriter := io.Pipe()
outReader, outWriter := io.Pipe()
client := &Client{
reader: newMessageReader(inReader),
writer: newMessageWriter(outWriter),
pending: make(map[int]chan dispatchResult),
}
defer func() {
_ = inWriter.Close()
_ = outReader.Close()
}()
client.ensureReader()
responses := make(chan dispatchResult, 1)
client.dispatchMu.Lock()
client.pending[1] = responses
client.dispatchMu.Unlock()
const extra = 8
n := writeQueueCapacity + extra
for i := 0; i < n; i++ {
frame := fmt.Sprintf(`{"jsonrpc":"2.0","id":%d,"method":"roots/list","params":{}}`+"\n", i+100)
if _, err := inWriter.Write([]byte(frame)); err != nil {
t.Fatalf("write server request %d: %v", i, err)
}
}
if _, err := inWriter.Write([]byte(`{"jsonrpc":"2.0","id":1,"result":{"ok":true}}` + "\n")); err != nil {
t.Fatalf("write pending response: %v", err)
}
select {
case res := <-responses:
if res.err != nil || len(res.message.Result) == 0 {
t.Fatalf("pending response = %#v err=%v", res.message, res.err)
}
case <-time.After(2 * time.Second):
t.Fatal("readLoop stalled while the write queue was full")
}
drainDone := make(chan []rpcMessage, 1)
go func() {
reader := newMessageReader(outReader)
var got []rpcMessage
for len(got) < n {
message, err := reader.read()
if err != nil {
drainDone <- got
return
}
got = append(got, message)
}
drainDone <- got
}()
select {
case got := <-drainDone:
if len(got) != n {
t.Fatalf("courtesy replies = %d, want %d (queue-full requests dropped)", len(got), n)
}
seen := make(map[int]bool, n)
for _, message := range got {
id, ok := rpcMessageID(message.ID)
if !ok || message.Error == nil || message.Error.Code != -32601 {
t.Fatalf("courtesy reply = %#v, want -32601", message)
}
seen[id] = true
}
for i := 0; i < n; i++ {
if !seen[i+100] {
t.Fatalf("missing courtesy reply for id %d after output resumed", i+100)
}
}
case <-time.After(3 * time.Second):
t.Fatal("timed out draining courtesy replies after output resumed")
}
}
func TestWriterLoopExitsOnRepeatedClose(t *testing.T) {
before := runtime.NumGoroutine()
for i := 0; i < 25; i++ {
inReader, inWriter := io.Pipe()
outReader, outWriter := io.Pipe()
copied := make(chan struct{})
go func() {
_, _ = io.Copy(io.Discard, outReader)
close(copied)
}()
client := &Client{
stdin: outWriter,
reader: newMessageReader(inReader),
writer: newMessageWriter(outWriter),
}
client.ensureWriter()
if err := client.writeMessage(context.Background(), rpcMessage{Method: "notifications/ping"}); err != nil {
t.Fatalf("writeMessage: %v", err)
}
if err := client.Close(); err != nil && !errors.Is(err, io.ErrClosedPipe) {
t.Fatalf("Close: %v", err)
}
_ = inWriter.Close()
<-copied
}
runtime.GC()
time.Sleep(20 * time.Millisecond)
after := runtime.NumGoroutine()
if after > before+8 {
t.Fatalf("goroutines leaked: before=%d after=%d", before, after)
}
}
func TestWriteMessageReleasedOnClose(t *testing.T) {
reader := newBlockingReader()
defer reader.Close()
output := newGatedCaptureWriter()
client := &Client{
reader: newMessageReader(reader),
writer: newMessageWriter(output),
}
blockerDone := make(chan error, 1)
go func() {
blockerDone <- client.writeMessage(context.Background(), rpcMessage{Method: "notifications/blocker"})
}()
select {
case <-output.started:
case <-time.After(time.Second):
t.Fatal("initial write did not reach the transport")
}
queuedDone := make(chan error, 1)
go func() {
queuedDone <- client.writeMessage(context.Background(), rpcMessage{Method: "notifications/queued"})
}()
deadline := time.Now().Add(time.Second)
for len(client.writeQueue) == 0 {
if time.Now().After(deadline) {
t.Fatal("queued write did not enqueue")
}
time.Sleep(time.Millisecond)
}
closeDone := make(chan error, 1)
go func() {
closeDone <- client.Close()
}()
select {
case <-client.writerStop:
case <-time.After(time.Second):
t.Fatal("Close did not begin writer shutdown")
}
output.Release()
select {
case err := <-closeDone:
if err != nil && !errors.Is(err, io.ErrClosedPipe) {
t.Fatalf("Close: %v", err)
}
case <-time.After(2 * time.Second):
t.Fatal("Close hung waiting for the writer worker")
}
select {
case err := <-queuedDone:
if err == nil {
t.Fatal("queued write succeeded after Close")
}
case <-time.After(2 * time.Second):
t.Fatal("queued write was not released on Close")
}
select {
case <-blockerDone:
case <-time.After(2 * time.Second):
t.Fatal("blocked write was not released on Close")
}
messages := newMessageReader(bytes.NewReader(output.Bytes()))
for {
message, err := messages.read()
if errors.Is(err, io.EOF) {
break
}
if err != nil {
t.Fatalf("read captured output: %v", err)
}
if message.Method == "notifications/queued" {
t.Fatal("queued request was written after Close")
}
}
}
func TestStdioClientPreservesLargeNumericRequestID(t *testing.T) {
inReader, inWriter := io.Pipe()
outReader, outWriter := io.Pipe()
client := &Client{
reader: newMessageReader(inReader),
writer: newMessageWriter(outWriter),
pending: make(map[int]chan dispatchResult),
}
defer func() {
_ = inWriter.Close()
_ = outReader.Close()
}()
client.ensureReader()
const rawID = "9007199254740993"
if _, err := inWriter.Write([]byte(`{"jsonrpc":"2.0","id":` + rawID + `,"method":"roots/list","params":{}}` + "\n")); err != nil {
t.Fatalf("write server request: %v", err)
}
lineDone := make(chan struct {
line string
err error
}, 1)
go func() {
line, err := bufio.NewReader(outReader).ReadString('\n')
lineDone <- struct {
line string
err error
}{line: line, err: err}
}()
select {
case got := <-lineDone:
if got.err != nil {
t.Fatalf("read courtesy response: %v", got.err)
}
if !strings.Contains(got.line, rawID) {
t.Fatalf("serialized courtesy response %q does not preserve id %s", got.line, rawID)
}
if !strings.Contains(got.line, `"-32601"`) && !strings.Contains(got.line, `-32601`) {
t.Fatalf("serialized courtesy response %q missing -32601", got.line)
}
case <-time.After(time.Second):
t.Fatal("timed out waiting for courtesy response")
}
}
func TestRPCMessageIDAcceptsExponentForm(t *testing.T) {
got, ok := rpcMessageID(json.Number("1e0"))
if !ok || got != 1 {
t.Fatalf("rpcMessageID(1e0) = %d, %v, want 1, true", got, ok)
}
got, ok = rpcMessageID(json.Number("2e2"))
if !ok || got != 200 {
t.Fatalf("rpcMessageID(2e2) = %d, %v, want 200, true", got, ok)
}
if _, ok := rpcMessageID(json.Number("1.5")); ok {
t.Fatal("fractional json.Number should not match")
}
if rpcIDMatches(json.Number("1e0"), 1) != true {
t.Fatal("rpcIDMatches(1e0, 1) = false")
}
}
func TestStdioClientMatchesExponentFormResponseID(t *testing.T) {
inReader, inWriter := io.Pipe()
outReader, outWriter := io.Pipe()
client := &Client{
reader: newMessageReader(inReader),
writer: newMessageWriter(outWriter),
pending: make(map[int]chan dispatchResult),
}
defer func() {
_ = inWriter.Close()
_ = outReader.Close()
}()
client.ensureReader()
responses := make(chan dispatchResult, 1)
client.dispatchMu.Lock()
client.pending[1] = responses
client.dispatchMu.Unlock()
if _, err := inWriter.Write([]byte(`{"jsonrpc":"2.0","id":1e0,"result":{"ok":true}}` + "\n")); err != nil {
t.Fatalf("write exponent-id response: %v", err)
}
select {
case res := <-responses:
if res.err != nil || len(res.message.Result) == 0 {
t.Fatalf("pending 1 not completed by id 1e0: %#v err=%v", res.message, res.err)
}
case <-time.After(time.Second):
t.Fatal("response id 1e0 did not resolve pending request 1")
}
}
func TestStdioClientCourtesyOverflowIsBounded(t *testing.T) {
inReader, inWriter := io.Pipe()
outReader, outWriter := io.Pipe()
client := &Client{
reader: newMessageReader(inReader),
writer: newMessageWriter(outWriter),
pending: make(map[int]chan dispatchResult),
}
defer func() {
_ = inWriter.Close()
_ = outReader.Close()
}()
client.ensureReader()
responses := make(chan dispatchResult, 1)
client.dispatchMu.Lock()
client.pending[1] = responses
client.dispatchMu.Unlock()
flood := writeQueueCapacity + courtesyOverflowCap + 64
for i := 0; i < flood; i++ {
frame := fmt.Sprintf(`{"jsonrpc":"2.0","id":%d,"method":"roots/list","params":{}}`+"\n", i+100)
if _, err := inWriter.Write([]byte(frame)); err != nil {
t.Fatalf("write server request %d: %v", i, err)
}
}
if _, err := inWriter.Write([]byte(`{"jsonrpc":"2.0","id":1,"result":{"ok":true}}` + "\n")); err != nil {
t.Fatalf("write pending response: %v", err)
}
select {
case res := <-responses:
if res.err != nil {
t.Fatalf("pending stalled: %v", res.err)
}
case <-time.After(2 * time.Second):
t.Fatal("readLoop stalled during courtesy flood")
}
client.writeMu.Lock()
n := len(client.courtesyOverflow)
client.writeMu.Unlock()
if n > courtesyOverflowCap {
t.Fatalf("courtesyOverflow = %d, want <= %d", n, courtesyOverflowCap)
}
}
func TestWriterLoopCloseBeforeScheduleDoesNotHang(t *testing.T) {
for i := 0; i < 50; i++ {
client := &Client{
writer: newMessageWriter(io.Discard),
}
if err := client.startWriter(); err != nil {
t.Fatalf("startWriter: %v", err)
}
done := make(chan error, 1)
go func() {
done <- client.Close()
}()
select {
case err := <-done:
if err != nil && !errors.Is(err, io.ErrClosedPipe) {
t.Fatalf("Close: %v", err)
}
case <-time.After(2 * time.Second):
t.Fatal("Close hung: writeLoop ranged over a nil queue")
}
}
}