package mcp import ( "bufio" "bytes" "context" "errors" "io" "strings" "testing" ) // newLineRPC builds a lineRPC reading the supplied pre-encoded responses. func newLineRPC(responses string) *lineRPC { return &lineRPC{ in: &bytes.Buffer{}, out: bufio.NewReader(bytes.NewReader([]byte(responses))), log: defaultLogger(), } } func TestLineRPC_Call_Success(t *testing.T) { t.Parallel() out := `{"jsonrpc":"2.0","id":"1","result":{"ok":true}}` + "\n" l := newLineRPC(out) var res struct { OK bool `json:"ok"` } if err := l.call(context.Background(), "tools/call", map[string]any{}, &res); err != nil { t.Fatalf("call: %v", err) } if !res.OK { t.Fatal("res.OK = false, want true") } } func TestLineRPC_Call_SkipsNotificationThenMatches(t *testing.T) { t.Parallel() out := `{"jsonrpc":"2.0","method":"notifications/initialized"}` + "\n" + `{"jsonrpc":"2.0","id":"1","result":{}}` + "\n" l := newLineRPC(out) var res map[string]any if err := l.call(context.Background(), "initialize", map[string]any{}, &res); err != nil { t.Fatalf("call: %v", err) } } func TestLineRPC_Call_SkipsMismatchedID(t *testing.T) { t.Parallel() out := `{"jsonrpc":"2.0","id":"2","result":{}}` + "\n" + `{"jsonrpc":"2.0","id":"1","result":{"ok":true}}` + "\n" l := newLineRPC(out) var res struct { OK bool `json:"ok"` } if err := l.call(context.Background(), "tools/call", nil, &res); err != nil { t.Fatalf("call: %v", err) } if !res.OK { t.Fatal("res.OK = false, want true") } } func TestLineRPC_Call_RPCError(t *testing.T) { t.Parallel() out := `{"jsonrpc":"2.0","id":"1","error":{"code":-32000,"message":"boom"}}` + "\n" l := newLineRPC(out) err := l.call(context.Background(), "tools/call", nil, nil) if !errors.Is(err, ErrRPC) { t.Fatalf("err = %v, want ErrRPC", err) } var rpcErr *rpcError if !errors.As(err, &rpcErr) { t.Fatalf("err = %v, want *rpcError in chain", err) } if rpcErr.Message != "boom" { t.Fatalf("rpcErr.Message = %q, want boom", rpcErr.Message) } } func TestLineRPC_Call_LastResponseDecodeError(t *testing.T) { t.Parallel() // A matching id whose result does not fit the target type must surface a // decode error. out := `{"jsonrpc":"2.0","id":"1","result":{"ok":"not-a-bool"}}` + "\n" l := newLineRPC(out) var res struct { OK bool `json:"ok"` } err := l.call(context.Background(), "tools/list", nil, &res) if err == nil { t.Fatal("call with mismatched result succeeded, want decode error") } } func TestLineRPC_Call_EOF(t *testing.T) { t.Parallel() l := newLineRPC("") err := l.call(context.Background(), "tools/list", nil, nil) if !errors.Is(err, io.EOF) { t.Fatalf("err = %v, want io.EOF", err) } } func TestLineRPC_Call_MalformedFrameIgnored(t *testing.T) { t.Parallel() out := `not json` + "\n" + `{"jsonrpc":"2.0","id":"1","result":{"ok":true}}` + "\n" l := newLineRPC(out) var res struct { OK bool `json:"ok"` } if err := l.call(context.Background(), "tools/call", nil, &res); err != nil { t.Fatalf("call: %v", err) } if !res.OK { t.Fatal("res.OK = false, want true") } } func TestLineRPC_WriteError(t *testing.T) { t.Parallel() l := &lineRPC{in: failWriter{}, out: bufio.NewReader(bytes.NewReader(nil)), log: defaultLogger()} err := l.call(context.Background(), "tools/list", nil, nil) if err == nil { t.Fatal("call with failing writer succeeded, want error") } if !strings.Contains(err.Error(), "write") { t.Fatalf("err = %v, want write error", err) } } func TestLineRPC_Notify_WritesNoID(t *testing.T) { t.Parallel() var buf bytes.Buffer l := &lineRPC{in: &buf, out: bufio.NewReader(bytes.NewReader(nil)), log: defaultLogger()} if err := l.notify(context.Background(), "notifications/initialized", map[string]any{}); err != nil { t.Fatalf("notify: %v", err) } body := buf.String() if !strings.Contains(body, "notifications/initialized") { t.Fatalf("body = %q, want notifications/initialized", body) } if strings.Contains(body, `"id"`) { t.Fatalf("body = %q, must not contain id (notification)", body) } } func TestLineRPC_ContextCanceled(t *testing.T) { t.Parallel() ctx, cancel := context.WithCancel(context.Background()) cancel() l := newLineRPC(`{"jsonrpc":"2.0","id":"1","result":{}}` + "\n") err := l.call(ctx, "initialize", nil, nil) if !errors.Is(err, context.Canceled) { t.Fatalf("err = %v, want context.Canceled", err) } } func TestStdioTransport_Start_NoCommand(t *testing.T) { t.Parallel() s := newStdioTransport(MCPConfig{Name: "s"}, defaultLogger()) err := s.Start(context.Background()) if !errors.Is(err, ErrNoTransport) { t.Fatalf("Start error = %v, want ErrNoTransport", err) } } func TestStdioTransport_Start_InvalidCommand(t *testing.T) { t.Parallel() s := newStdioTransport(MCPConfig{Name: "s", Command: "cyrene-no-such-binary-xyz"}, defaultLogger()) if err := s.Start(context.Background()); err == nil { t.Fatal("Start with bogus command succeeded, want error") } } func TestStdioTransport_Call_NotStarted(t *testing.T) { t.Parallel() s := newStdioTransport(MCPConfig{Name: "s", Command: "x"}, defaultLogger()) err := s.Call(context.Background(), "tools/list", nil, nil) if !errors.Is(err, ErrNotConnected) { t.Fatalf("Call error = %v, want ErrNotConnected", err) } } func TestStdioTransport_Close_NoCmd(t *testing.T) { t.Parallel() s := newStdioTransport(MCPConfig{Name: "s"}, defaultLogger()) if err := s.Close(); err != nil { t.Fatalf("Close: %v", err) } } // failWriter returns an error on every write. type failWriter struct{} // Write always fails. func (failWriter) Write(_ []byte) (int, error) { return 0, errors.New("write boom") }