200 lines
5.5 KiB
Go
200 lines
5.5 KiB
Go
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")
|
|
}
|