package mcp import ( "bufio" "context" "encoding/json" "errors" "fmt" "io" "os" "path/filepath" "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; in "cwd" mode its one tool says where it is running. func serveOnStdio(mode string) { if mode == "hang" { _, _ = io.Copy(io.Discard, os.Stdin) return } f := &fakeServer{in: bufio.NewScanner(os.Stdin), out: os.Stdout} if mode == "cwd" { f.loop(standard(protocolVersion, []map[string]any{{"name": "where", "description": "Says where."}}, func(f *fakeServer, msg incoming) { wd, _ := os.Getwd() f.respond(msg.ID, textResult(wd, false)) })) return } 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 TestConnectStartsServersInTheGivenDirectory(t *testing.T) { dir := t.TempDir() cfg := config.Config{MCPServers: map[string]config.MCPServer{ "self": {Command: os.Args[0], Env: map[string]string{fakeServerEnv: "cwd"}}, }} ts, closer, errs := Connect(ctxWith(t, 10*time.Second), dir, cfg) defer closer.Close() if len(errs) != 0 || len(ts) != 1 { t.Fatalf("Connect = %v, %v; want one tool, no errors", ts, errs) } out, err := ts[0].Execute(ctxWith(t, 5*time.Second), nil) if err != nil { t.Fatalf("Execute: %v", err) } want, _ := filepath.EvalSymlinks(dir) if got, _ := filepath.EvalSymlinks(out); got != want { t.Errorf("server ran in %q, want %q", out, dir) } } 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() }