Files
Cyrene_v2/internal/capability/mcp/stdio_test.go
T

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")
}