package mcp
import (
"bufio"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"strings"
"sync"
"testing"
"time"
"codey/internal/config"
)
// fakeServer is an MCP server that lives in the test process and speaks the
// wire protocol over pipes, so every ugly path — silence, a line of garbage,
// an exit mid-call — can be produced on demand without a real server
// installed. serve is called for each request the client sends.
type fakeServer struct {
in *bufio.Scanner
out io.WriteCloser
outMu sync.Mutex
mu sync.Mutex
received []incoming
replies []incoming // responses the client sent to our requests
closed bool
}
type serveFunc func(f *fakeServer, msg incoming)
// startFake wires a client to a fakeServer. The handshake is not run; tests
// that want it call handshake themselves so the ones about its failure
// modes can watch it fail.
func startFake(t *testing.T, serve serveFunc) (*client, *fakeServer) {
t.Helper()
toServer, clientStdin := io.Pipe()
clientStdout, fromServer := io.Pipe()
f := &fakeServer{in: bufio.NewScanner(toServer), out: fromServer}
go f.loop(serve)
kill := func() {
f.close()
_ = toServer.Close()
}
c := newClient("fake", clientStdin, clientStdout, kill, nil)
t.Cleanup(func() {
done := make(chan struct{})
go func() { _ = c.Close(); close(done) }()
select {
case <-done:
case <-time.After(3 * time.Second):
t.Error("client.Close hung")
}
})
return c, f
}
// loop serves until stdin closes, then closes stdout — which is what a real
// server does when the client closes its stdin, and what lets Close finish
// without waiting out the grace period.
func (f *fakeServer) loop(serve serveFunc) {
defer f.close()
for f.in.Scan() {
line := f.in.Bytes()
if len(strings.TrimSpace(string(line))) == 0 {
continue
}
var msg incoming
if err := json.Unmarshal(line, &msg); err != nil {
return
}
f.mu.Lock()
if msg.Method == "" {
f.replies = append(f.replies, msg)
} else {
f.received = append(f.received, msg)
}
f.mu.Unlock()
if msg.Method != "" && serve != nil {
serve(f, msg)
}
}
}
func (f *fakeServer) send(v any) {
body, _ := json.Marshal(v)
f.raw(string(body) + "\n")
}
// raw writes bytes as they are, for producing broken streams.
func (f *fakeServer) raw(s string) {
f.outMu.Lock()
defer f.outMu.Unlock()
if !f.closed {
_, _ = io.WriteString(f.out, s)
}
}
func (f *fakeServer) close() {
f.outMu.Lock()
defer f.outMu.Unlock()
if !f.closed {
f.closed = true
_ = f.out.Close()
}
}
func (f *fakeServer) respond(id json.RawMessage, result any) {
f.send(map[string]any{"jsonrpc": "2.0", "id": id, "result": result})
}
func (f *fakeServer) fail(id json.RawMessage, code int, message string) {
f.send(map[string]any{"jsonrpc": "2.0", "id": id, "error": map[string]any{"code": code, "message": message}})
}
func (f *fakeServer) request(id int, method string, params any) {
f.send(map[string]any{"jsonrpc": "2.0", "id": id, "method": method, "params": params})
}
func (f *fakeServer) methods() []string {
f.mu.Lock()
defer f.mu.Unlock()
out := make([]string, len(f.received))
for i, m := range f.received {
out[i] = m.Method
}
return out
}
func (f *fakeServer) last(method string) (incoming, bool) {
f.mu.Lock()
defer f.mu.Unlock()
for i := len(f.received) - 1; i >= 0; i-- {
if f.received[i].Method == method {
return f.received[i], true
}
}
return incoming{}, false
}
// waitReplies blocks until the client has answered n of our requests.
func (f *fakeServer) waitReplies(t *testing.T, n int) []incoming {
t.Helper()
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
f.mu.Lock()
got := append([]incoming(nil), f.replies...)
f.mu.Unlock()
if len(got) >= n {
return got
}
time.Sleep(5 * time.Millisecond)
}
t.Fatalf("client answered fewer than %d requests", n)
return nil
}
// standard is a serve function for a well-behaved server: it answers
// initialize with version, lists tools, and hands tools/call to call.
func standard(version string, tools []map[string]any, call func(f *fakeServer, msg incoming)) serveFunc {
if tools == nil {
tools = []map[string]any{}
}
return func(f *fakeServer, msg incoming) {
switch msg.Method {
case "initialize":
f.respond(msg.ID, map[string]any{
"protocolVersion": version,
"capabilities": map[string]any{"tools": map[string]any{}},
"serverInfo": map[string]any{"name": "fake", "version": "0"},
})
case "tools/list":
f.respond(msg.ID, map[string]any{"tools": tools})
case "tools/call":
if call != nil {
call(f, msg)
}
}
}
}
func echoTool() []map[string]any {
return []map[string]any{{
"name": "echo",
"description": "Says it back.",
"inputSchema": map[string]any{"type": "object", "properties": map[string]any{"text": map[string]any{"type": "string"}}, "required": []string{"text"}},
}}
}
func textResult(text string, isError bool) map[string]any {
return map[string]any{"content": []map[string]any{{"type": "text", "text": text}}, "isError": isError}
}
func ctxWith(t *testing.T, d time.Duration) context.Context {
ctx, cancel := context.WithTimeout(context.Background(), d)
t.Cleanup(cancel)
return ctx
}
func TestHandshakeSendsInitializeThenInitialized(t *testing.T) {
c, f := startFake(t, standard(protocolVersion, echoTool(), nil))
infos, err := handshake(ctxWith(t, 2*time.Second), c)
if err != nil {
t.Fatalf("handshake: %v", err)
}
if len(infos) != 1 || infos[0].Name != "echo" {
t.Fatalf("tools = %+v, want the one echo tool", infos)
}
want := []string{"initialize", "notifications/initialized", "tools/list"}
if got := f.methods(); strings.Join(got, ",") != strings.Join(want, ",") {
t.Errorf("methods = %v, want %v", got, want)
}
init, _ := f.last("initialize")
var p struct {
ProtocolVersion string `json:"protocolVersion"`
Capabilities json.RawMessage `json:"capabilities"`
ClientInfo struct {
Name string `json:"name"`
} `json:"clientInfo"`
}
if err := json.Unmarshal(init.Params, &p); err != nil {
t.Fatalf("initialize params: %v", err)
}
if p.ProtocolVersion != protocolVersion {
t.Errorf("protocolVersion = %q, want %q", p.ProtocolVersion, protocolVersion)
}
if p.ClientInfo.Name != "codey" {
t.Errorf("clientInfo.name = %q, want codey", p.ClientInfo.Name)
}
if len(p.Capabilities) == 0 {
t.Error("initialize sent no capabilities object; the spec requires one, even empty")
}
if c.serverVersion != protocolVersion {
t.Errorf("serverVersion = %q, want %q", c.serverVersion, protocolVersion)
}
}
func TestHandshakeAcceptsAnOlderVersionTheClientKnows(t *testing.T) {
c, _ := startFake(t, standard("2024-11-05", echoTool(), nil))
if _, err := handshake(ctxWith(t, 2*time.Second), c); err != nil {
t.Fatalf("handshake: %v", err)
}
if c.serverVersion != "2024-11-05" {
t.Errorf("serverVersion = %q", c.serverVersion)
}
}
func TestHandshakeRefusesAVersionItDoesNotSpeak(t *testing.T) {
c, _ := startFake(t, standard("2031-01-01", echoTool(), nil))
_, err := handshake(ctxWith(t, 2*time.Second), c)
if err == nil {
t.Fatal("handshake accepted an unknown protocol version")
}
for _, want := range []string{"2031-01-01", protocolVersion} {
if !strings.Contains(err.Error(), want) {
t.Errorf("error %q does not mention %q", err, want)
}
}
}
func TestHandshakeRefusesAServerWithoutTools(t *testing.T) {
c, _ := startFake(t, func(f *fakeServer, msg incoming) {
if msg.Method == "initialize" {
f.respond(msg.ID, map[string]any{"protocolVersion": protocolVersion, "capabilities": map[string]any{"prompts": map[string]any{}}})
}
})
_, err := handshake(ctxWith(t, 2*time.Second), c)
if err == nil || !strings.Contains(err.Error(), "tools capability") {
t.Fatalf("err = %v, want a complaint about the tools capability", err)
}
}
func TestHandshakeTimesOutOnASilentServer(t *testing.T) {
c, _ := startFake(t, nil)
start := time.Now()
_, err := handshake(ctxWith(t, 50*time.Millisecond), c)
if !errors.Is(err, context.DeadlineExceeded) {
t.Fatalf("err = %v, want a deadline", err)
}
if time.Since(start) > time.Second {
t.Errorf("handshake took %s to give up on a 50ms deadline", time.Since(start))
}
}
func TestDiscoveryNamespacesToolsAndPassesSchemasThrough(t *testing.T) {
schema := json.RawMessage(`{"type":"object","properties":{"q":{"type":"string","description":"query"}},"required":["q"],"additionalProperties":false}`)
list := []map[string]any{
{"name": "search", "description": "Find things.", "inputSchema": schema},
{"name": "weird.name/with:chars", "inputSchema": nil},
}
c, _ := startFake(t, standard(protocolVersion, list, nil))
infos, err := handshake(ctxWith(t, 2*time.Second), c)
if err != nil {
t.Fatalf("handshake: %v", err)
}
if len(infos) != 2 {
t.Fatalf("got %d tools, want 2", len(infos))
}
search, err := newTool(c, "my db", infos[0])
if err != nil {
t.Fatalf("newTool: %v", err)
}
if search.Name() != "mcp__my_db__search" {
t.Errorf("name = %q, want mcp__my_db__search", search.Name())
}
if search.Description() != "Find things." {
t.Errorf("description = %q", search.Description())
}
var got, want any
if err := json.Unmarshal(search.InputSchema(), &got); err != nil {
t.Fatalf("schema is not JSON: %v", err)
}
_ = json.Unmarshal(schema, &want)
if fmt.Sprint(got) != fmt.Sprint(want) {
t.Errorf("schema changed in transit:\n got %s\nwant %s", search.InputSchema(), schema)
}
weird, err := newTool(c, "my db", infos[1])
if err != nil {
t.Fatalf("newTool: %v", err)
}
if weird.Name() != "mcp__my_db__weird_name_with_chars" {
t.Errorf("name = %q, want the punctuation replaced", weird.Name())
}
if weird.Description() == "" || !strings.Contains(weird.Description(), "weird.name/with:chars") {
t.Errorf("a tool with no description should get one naming it; got %q", weird.Description())
}
if string(weird.InputSchema()) != string(emptySchema) {
t.Errorf("a tool with no schema should get the empty object schema; got %s", weird.InputSchema())
}
}
func TestDiscoveryRefusesANameTheAPIWouldReject(t *testing.T) {
c, _ := startFake(t, nil)
long := strings.Repeat("x", 70)
if _, err := newTool(c, "s", toolInfo{Name: long}); err == nil || !strings.Contains(err.Error(), "64") {
t.Errorf("err = %v, want a complaint about the 64-character limit", err)
}
if _, err := newTool(c, "s", toolInfo{Name: ""}); err == nil {
t.Error("a tool with no name was accepted")
}
}
func TestDiscoveryFollowsTheCursor(t *testing.T) {
c, _ := startFake(t, func(f *fakeServer, msg incoming) {
switch msg.Method {
case "initialize":
f.respond(msg.ID, map[string]any{"protocolVersion": protocolVersion, "capabilities": map[string]any{"tools": map[string]any{}}})
case "tools/list":
var p struct {
Cursor string `json:"cursor"`
}
_ = json.Unmarshal(msg.Params, &p)
if p.Cursor == "" {
f.respond(msg.ID, map[string]any{"tools": []map[string]any{{"name": "a"}}, "nextCursor": "page2"})
} else {
f.respond(msg.ID, map[string]any{"tools": []map[string]any{{"name": "b"}}})
}
}
})
infos, err := handshake(ctxWith(t, 2*time.Second), c)
if err != nil {
t.Fatalf("handshake: %v", err)
}
if len(infos) != 2 || infos[0].Name != "a" || infos[1].Name != "b" {
t.Errorf("tools = %+v, want a then b", infos)
}
}
func TestCallReturnsTextContent(t *testing.T) {
c, f := startFake(t, standard(protocolVersion, echoTool(), func(f *fakeServer, msg incoming) {
var p struct {
Name string `json:"name"`
Arguments json.RawMessage `json:"arguments"`
}
_ = json.Unmarshal(msg.Params, &p)
f.respond(msg.ID, map[string]any{"content": []map[string]any{
{"type": "text", "text": "called " + p.Name + " with " + string(p.Arguments)},
{"type": "text", "text": "second line"},
}})
}))
infos, err := handshake(ctxWith(t, 2*time.Second), c)
if err != nil {
t.Fatalf("handshake: %v", err)
}
tool, _ := newTool(c, "srv", infos[0])
out, err := tool.Execute(ctxWith(t, 2*time.Second), json.RawMessage(`{"text":"hi"}`))
if err != nil {
t.Fatalf("Execute: %v", err)
}
if want := "called echo with {\"text\":\"hi\"}\nsecond line"; out != want {
t.Errorf("out = %q, want %q", out, want)
}
if _, ok := f.last("tools/call"); !ok {
t.Error("server never saw tools/call")
}
}
func TestCallWithNoArgumentsSendsAnEmptyObject(t *testing.T) {
c, f := startFake(t, standard(protocolVersion, echoTool(), func(f *fakeServer, msg incoming) {
f.respond(msg.ID, textResult("ok", false))
}))
infos, _ := handshake(ctxWith(t, 2*time.Second), c)
tool, _ := newTool(c, "srv", infos[0])
if _, err := tool.Execute(ctxWith(t, 2*time.Second), nil); err != nil {
t.Fatalf("Execute: %v", err)
}
call, _ := f.last("tools/call")
var p struct {
Arguments json.RawMessage `json:"arguments"`
}
_ = json.Unmarshal(call.Params, &p)
if string(p.Arguments) != "{}" {
t.Errorf("arguments = %s, want {}", p.Arguments)
}
}
func TestCallReportsIsErrorAsAnError(t *testing.T) {
c, _ := startFake(t, standard(protocolVersion, echoTool(), func(f *fakeServer, msg incoming) {
f.respond(msg.ID, textResult("disk is full", true))
}))
infos, _ := handshake(ctxWith(t, 2*time.Second), c)
tool, _ := newTool(c, "srv", infos[0])
out, err := tool.Execute(ctxWith(t, 2*time.Second), json.RawMessage(`{"text":"x"}`))
if err == nil {
t.Fatalf("isError result came back as success: %q", out)
}
if !strings.Contains(err.Error(), "disk is full") {
t.Errorf("error %q does not carry the server's text", err)
}
}
func TestCallReportsAJSONRPCErrorAsAnError(t *testing.T) {
c, _ := startFake(t, standard(protocolVersion, echoTool(), func(f *fakeServer, msg incoming) {
f.fail(msg.ID, -32602, "text must be a string")
}))
infos, _ := handshake(ctxWith(t, 2*time.Second), c)
tool, _ := newTool(c, "srv", infos[0])
_, err := tool.Execute(ctxWith(t, 2*time.Second), json.RawMessage(`{"text":1}`))
if err == nil || !strings.Contains(err.Error(), "text must be a string") {
t.Fatalf("err = %v, want the server's message", err)
}
if c.Dead() {
t.Error("a JSON-RPC error is a normal reply and must not end the session")
}
}
func TestCallDescribesAnImageInsteadOfInliningIt(t *testing.T) {
blob := strings.Repeat("A", 8000) // ~6 KB decoded
c, _ := startFake(t, standard(protocolVersion, echoTool(), func(f *fakeServer, msg incoming) {
f.respond(msg.ID, map[string]any{"content": []map[string]any{
{"type": "text", "text": "screenshot taken"},
{"type": "image", "data": blob, "mimeType": "image/png"},
}})
}))
infos, _ := handshake(ctxWith(t, 2*time.Second), c)
tool, _ := newTool(c, "srv", infos[0])
out, err := tool.Execute(ctxWith(t, 2*time.Second), nil)
if err != nil {
t.Fatalf("Execute: %v", err)
}
if strings.Contains(out, blob[:100]) {
t.Error("base64 image data was inlined in the result")
}
if !strings.Contains(out, "image/png") || !strings.Contains(out, "5 KB") {
t.Errorf("image was not described: %q", out)
}
if !strings.HasPrefix(out, "screenshot taken\n") {
t.Errorf("text content should come through beside the image description: %q", out)
}
}
func TestRenderCoversTheOtherContentTypes(t *testing.T) {
r := callResult{Content: []contentItem{
{Type: "resource", Resource: &struct {
URI string `json:"uri"`
MimeType string `json:"mimeType"`
Text string `json:"text"`
Blob string `json:"blob"`
}{URI: "file:///a.txt", Text: "hello"}},
{Type: "resource_link", URI: "file:///b.txt", Name: "b", Text: "the other one"},
{Type: "video"},
}}
out := r.render()
for _, want := range []string{"file:///a.txt", "hello", "file:///b.txt — b: the other one", `unsupported type "video"`} {
if !strings.Contains(out, want) {
t.Errorf("render = %q, missing %q", out, want)
}
}
if got := (callResult{}).render(); got != "(no output)" {
t.Errorf("empty result rendered as %q", got)
}
if got := (callResult{StructuredContent: json.RawMessage(`{"n":1}`)}).render(); got != `{"n":1}` {
t.Errorf("structured-only result rendered as %q", got)
}
}
func TestCallFailsWhenTheServerSendsGarbage(t *testing.T) {
c, _ := startFake(t, standard(protocolVersion, echoTool(), func(f *fakeServer, msg incoming) {
f.raw("INFO: starting up\n")
}))
infos, _ := handshake(ctxWith(t, 2*time.Second), c)
tool, _ := newTool(c, "srv", infos[0])
_, err := tool.Execute(ctxWith(t, 2*time.Second), nil)
if !errors.Is(err, errServerDead) {
t.Fatalf("err = %v, want errServerDead", err)
}
if !strings.Contains(err.Error(), "invalid JSON") {
t.Errorf("error %q does not say what went wrong", err)
}
// And every later call fails the same way, at once, rather than waiting
// for a reply that cannot come.
start := time.Now()
if _, err := tool.Execute(context.Background(), nil); !errors.Is(err, errServerDead) {
t.Errorf("second call: err = %v, want errServerDead", err)
}
if time.Since(start) > time.Second {
t.Error("a call to a dead server should fail immediately")
}
}
func TestCallFailsWhenTheServerDiesMidCall(t *testing.T) {
c, _ := startFake(t, standard(protocolVersion, echoTool(), func(f *fakeServer, msg incoming) {
f.close()
}))
infos, _ := handshake(ctxWith(t, 2*time.Second), c)
tool, _ := newTool(c, "srv", infos[0])
_, err := tool.Execute(ctxWith(t, 2*time.Second), nil)
if !errors.Is(err, errServerDead) {
t.Fatalf("err = %v, want errServerDead", err)
}
if !strings.Contains(err.Error(), "exited") {
t.Errorf("error %q does not say the server exited", err)
}
if !c.Dead() {
t.Error("client did not notice the server exit")
}
}
func TestCallHonoursTheCallersContext(t *testing.T) {
c, _ := startFake(t, standard(protocolVersion, echoTool(), nil)) // never answers a call
infos, _ := handshake(ctxWith(t, 2*time.Second), c)
tool, _ := newTool(c, "srv", infos[0])
_, err := tool.Execute(ctxWith(t, 50*time.Millisecond), nil)
if !errors.Is(err, context.DeadlineExceeded) {
t.Fatalf("err = %v, want a deadline", err)
}
if c.Dead() {
t.Error("one slow call must not end the session for the rest")
}
c.mu.Lock()
pending := len(c.pending)
c.mu.Unlock()
if pending != 0 {
t.Errorf("%d requests still pending after the caller gave up", pending)
}
}
func TestPingIsAnsweredAndOtherRequestsRefused(t *testing.T) {
c, f := startFake(t, standard(protocolVersion, echoTool(), nil))
if _, err := handshake(ctxWith(t, 2*time.Second), c); err != nil {
t.Fatalf("handshake: %v", err)
}
f.request(100, "ping", nil)
f.request(101, "sampling/createMessage", map[string]any{})
replies := f.waitReplies(t, 2)
byID := map[string]incoming{}
for _, r := range replies {
byID[string(r.ID)] = r
}
if r := byID["100"]; r.Error != nil || string(r.Result) != "{}" {
t.Errorf("ping reply = %+v, want an empty result", r)
}
if r := byID["101"]; r.Error == nil || r.Error.Code != -32601 {
t.Errorf("sampling reply = %+v, want method not found", r)
}
}
func TestCloseIsIdempotentAndEndsTheSession(t *testing.T) {
c, _ := startFake(t, standard(protocolVersion, echoTool(), nil))
if _, err := handshake(ctxWith(t, 2*time.Second), c); err != nil {
t.Fatalf("handshake: %v", err)
}
_ = c.Close()
_ = c.Close()
if !c.Dead() {
t.Error("client not dead after Close")
}
if _, err := c.call(context.Background(), "tools/list", nil); !errors.Is(err, errServerDead) {
t.Errorf("call after Close: err = %v, want errServerDead", err)
}
}
// The remaining tests go through Connect and so through a real child
// process. None needs an MCP server installed: the test binary itself plays
// the server when re-executed with fakeServerEnv set, and the failure cases
// use programs that are not there or exit at once.
const fakeServerEnv = "CODEY_MCP_TEST_SERVER"
func TestMain(m *testing.M) {
if mode := os.Getenv(fakeServerEnv); mode != "" {
serveOnStdio(mode)
os.Exit(0)
}
os.Exit(m.Run())
}
// serveOnStdio is the child's half of TestConnect*: a well-behaved server on
// the real stdin and stdout. In "hang" mode it never answers, for the
// timeout-and-kill path.
func serveOnStdio(mode string) {
if mode == "hang" {
_, _ = io.Copy(io.Discard, os.Stdin)
return
}
f := &fakeServer{in: bufio.NewScanner(os.Stdin), out: os.Stdout}
f.loop(standard(protocolVersion, []map[string]any{{"name": "hello", "description": "Greets."}}, func(f *fakeServer, msg incoming) {
f.respond(msg.ID, textResult("hello from pid "+fmt.Sprint(os.Getpid()), false))
}))
}
func TestConnectWithNoServersIsANoOp(t *testing.T) {
ts, closer, errs := Connect(context.Background(), config.Config{})
if len(ts) != 0 || len(errs) != 0 || closer == nil {
t.Fatalf("Connect = %v, %v, %v; want nothing, a closer, no errors", ts, closer, errs)
}
_ = closer.Close()
}
func TestConnectRunsARealServerAndClosesIt(t *testing.T) {
cfg := config.Config{MCPServers: map[string]config.MCPServer{
"self": {Command: os.Args[0], Env: map[string]string{fakeServerEnv: "serve"}},
}}
ts, closer, errs := Connect(ctxWith(t, 10*time.Second), cfg)
if len(errs) != 0 {
t.Fatalf("Connect errors: %v", errs)
}
if len(ts) != 1 || ts[0].Name() != "mcp__self__hello" {
t.Fatalf("tools = %v, want mcp__self__hello", ts)
}
out, err := ts[0].Execute(ctxWith(t, 5*time.Second), nil)
if err != nil {
t.Fatalf("Execute: %v", err)
}
if !strings.HasPrefix(out, "hello from pid ") {
t.Errorf("out = %q", out)
}
done := make(chan struct{})
go func() { _ = closer.Close(); close(done) }()
select {
case <-done:
case <-time.After(5 * time.Second):
t.Fatal("Close hung")
}
if _, err := ts[0].Execute(context.Background(), nil); !errors.Is(err, errServerDead) {
t.Errorf("after Close: err = %v, want errServerDead", err)
}
}
func TestConnectKillsAServerThatNeverAnswers(t *testing.T) {
cfg := config.Config{MCPServers: map[string]config.MCPServer{
"mute": {Command: os.Args[0], Env: map[string]string{fakeServerEnv: "hang"}},
}}
// The handshake deadline is the shorter of ours and initTimeout.
ts, closer, errs := Connect(ctxWith(t, 200*time.Millisecond), cfg)
if len(ts) != 0 {
t.Errorf("tools = %v from a server that never answered", ts)
}
if len(errs) != 1 || !strings.Contains(errs[0].Error(), "mute") {
t.Fatalf("errs = %v, want one naming the server", errs)
}
_ = closer.Close()
}
func TestConnectReportsAServerThatIsNotInstalled(t *testing.T) {
cfg := config.Config{MCPServers: map[string]config.MCPServer{
"ghost": {Command: "codey-no-such-mcp-server-3f9a"},
"blank": {},
}}
ts, closer, errs := Connect(ctxWith(t, 5*time.Second), cfg)
if len(ts) != 0 {
t.Errorf("tools = %v, want none", ts)
}
if len(errs) != 2 {
t.Fatalf("errs = %v, want one per server", errs)
}
joined := errs[0].Error() + "\n" + errs[1].Error()
for _, want := range []string{"ghost", "blank", "no command"} {
if !strings.Contains(joined, want) {
t.Errorf("errors %q do not mention %q", joined, want)
}
}
_ = closer.Close()
}
func TestConnectCarriesStderrOfAServerThatExitsAtOnce(t *testing.T) {
cfg := config.Config{MCPServers: map[string]config.MCPServer{
"crash": {Command: "sh", Args: []string{"-c", "echo 'missing API key' >&2; exit 3"}},
}}
ts, closer, errs := Connect(ctxWith(t, 5*time.Second), cfg)
if len(ts) != 0 || len(errs) != 1 {
t.Fatalf("Connect = %v, %v; want no tools and one error", ts, errs)
}
if !strings.Contains(errs[0].Error(), "missing API key") {
t.Errorf("error %q does not carry the server's stderr", errs[0])
}
_ = closer.Close()
}
func TestConnectDropsAToolWhoseNameCollides(t *testing.T) {
// Two servers whose sanitized names are the same produce the same
// registered name; registering both would panic. Connect is exercised
// through the same assembly its real path uses, with fakes in place of
// processes.
a, _ := startFake(t, standard(protocolVersion, echoTool(), nil))
b, _ := startFake(t, standard(protocolVersion, echoTool(), nil))
ai, _ := handshake(ctxWith(t, 2*time.Second), a)
bi, _ := handshake(ctxWith(t, 2*time.Second), b)
ts, closer, errs := assemble([]started{
{name: "my db", c: a, tools: ai},
{name: "my_db", c: b, tools: bi},
})
if len(ts) != 1 || ts[0].Name() != "mcp__my_db__echo" {
t.Fatalf("tools = %v, want just the first server's", ts)
}
if len(errs) != 2 {
t.Fatalf("errs = %v, want the collision and the now-empty server reported", errs)
}
if !strings.Contains(errs[0].Error(), "mcp__my_db__echo") {
t.Errorf("collision error %q does not name the tool", errs[0])
}
if !b.Dead() {
t.Error("a server left with no tools should have been closed")
}
_ = closer.Close()
}