Vectrum
Sign In
770 lines · 25.0 KB · Go
package mcp

import (
	"bufio"
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"os"
	"path/filepath"
	"strings"
	"sync"
	"testing"
	"time"

	"github.com/johannSo/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()
}