Vectrum
Sign In
253 lines · 7.7 KB · Go
package mcp

import (
	"context"
	"fmt"
	"io"
	"os"
	"os/exec"
	"runtime/debug"
	"sort"
	"strings"
	"sync"
	"syscall"
	"time"

	"codey/internal/config"
	"codey/internal/tools"
)

// clientVersion is what initialize reports as clientInfo.version. It is
// read from the build rather than copied from cmd/codey, which keeps this
// package from holding a second copy of the version string; "(devel)" is
// what a go build outside a module release gets, and is the truth.
var clientVersion = func() string {
	if bi, ok := debug.ReadBuildInfo(); ok && bi.Main.Version != "" {
		return bi.Main.Version
	}
	return "unknown"
}()

// stderrTail is how much of a server's stderr is kept for the error a
// failed start reports. A server that will not initialize usually says why
// on stderr — a missing API key, a module it cannot find — and without it
// the user is left with "timed out" and nowhere to look. Four kilobytes is
// the end of a stack trace, which is the part that names the cause.
const stderrTail = 4 << 10

// Connect starts every server in cfg.MCPServers, discovers its tools, and
// returns them ready to register. Servers are started together rather than
// one after another, so five broken servers cost one handshake timeout and
// not five; the tools come back sorted by name so the registry's order, and
// with it the prompt cache, does not depend on which server answered first.
//
// Errors are per server and per tool, never fatal: a server that is not
// installed, will not start, or speaks a protocol this client does not is
// reported and its tools are simply not there. The closer is always
// returned, even when nothing started, so the caller has one thing to defer.
//
// Every server runs in dir, the working root, as it would under Claude Code:
// a filesystem or git server started with no path of its own serves the
// directory it was started in, and that should be the one the agent works in
// rather than wherever Codey was launched from. An empty dir leaves the
// server in Codey's own.
func Connect(ctx context.Context, dir string, cfg config.Config) ([]tools.Tool, io.Closer, []error) {
	names := make([]string, 0, len(cfg.MCPServers))
	for name := range cfg.MCPServers {
		names = append(names, name)
	}
	sort.Strings(names)

	results := make([]started, len(names))
	var wg sync.WaitGroup
	for i, name := range names {
		wg.Add(1)
		go func() {
			defer wg.Done()
			c, infos, err := start(ctx, dir, name, cfg.MCPServers[name])
			results[i] = started{name: name, c: c, tools: infos, err: err}
		}()
	}
	wg.Wait()
	return assemble(results)
}

// started is what starting one server came to: its client, what it listed,
// or why it did neither. The client is set even on failure so it can be
// closed; nil means the process never started.
type started struct {
	name  string
	c     *client
	tools []toolInfo
	err   error
}

// assemble turns started servers into registrable tools. It is separate from
// Connect so the tests can feed it in-process fakes; Connect's only other
// work is starting processes.
func assemble(results []started) ([]tools.Tool, *servers, []error) {
	all := &servers{}
	var out []tools.Tool
	var errs []error
	// Registration panics on a duplicate, and two servers whose sanitized
	// names collide — "my db" and "my_db" — would produce one. The first
	// keeps the name; the second's tool is dropped and reported.
	seen := map[string]bool{}
	for _, r := range results {
		if r.err != nil {
			errs = append(errs, fmt.Errorf("mcp: %s: %w", r.name, r.err))
			if r.c != nil {
				_ = r.c.Close()
			}
			continue
		}
		kept := 0
		for _, info := range r.tools {
			t, err := newTool(r.c, r.name, info)
			if err != nil {
				errs = append(errs, fmt.Errorf("mcp: %s: %w", r.name, err))
				continue
			}
			if seen[t.Name()] {
				errs = append(errs, fmt.Errorf("mcp: %s: tool %q would register as %q, which another tool already has", r.name, info.Name, t.Name()))
				continue
			}
			seen[t.Name()] = true
			out = append(out, t)
			kept++
		}
		if kept == 0 {
			// A server with nothing to offer is a process with nothing to do;
			// it is stopped now rather than carried to the end of the session.
			errs = append(errs, fmt.Errorf("mcp: %s: server offers no usable tools", r.name))
			_ = r.c.Close()
			continue
		}
		all.clients = append(all.clients, r.c)
	}
	sort.Slice(out, func(i, j int) bool { return out[i].Name() < out[j].Name() })
	return out, all, errs
}

// start runs one server through to a listed set of tools. The client is
// returned even on failure so the caller can close it; a nil client means
// the process never started.
func start(ctx context.Context, dir, name string, spec config.MCPServer) (*client, []toolInfo, error) {
	command := strings.TrimSpace(spec.Command)
	if command == "" {
		return nil, nil, fmt.Errorf("no command configured")
	}
	cmd := exec.Command(command, spec.Args...)
	cmd.Dir = dir
	cmd.Env = append(os.Environ(), envList(spec.Env)...)
	// A new process group, so a server started through npx or uvx can be
	// killed whole. Killing only the wrapper leaves the real server holding
	// the pipes, which is how the bash tool learned the same lesson.
	cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
	// WaitDelay bounds Wait when a grandchild keeps stderr open after the
	// server has been killed; without it Close could hang on a pipe.
	cmd.WaitDelay = 2 * time.Second
	stderr := &tail{max: stderrTail}
	cmd.Stderr = stderr
	stdin, err := cmd.StdinPipe()
	if err != nil {
		return nil, nil, err
	}
	stdout, err := cmd.StdoutPipe()
	if err != nil {
		return nil, nil, err
	}
	if err := cmd.Start(); err != nil {
		return nil, nil, fmt.Errorf("starting %s: %w", command, err)
	}
	var waitOnce sync.Once
	c := newClient(name, stdin, stdout,
		func() {
			if cmd.Process != nil {
				_ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
			}
		},
		func() { waitOnce.Do(func() { _ = cmd.Wait() }) })

	ctx, cancel := context.WithTimeout(ctx, initTimeout)
	defer cancel()
	infos, err := handshake(ctx, c)
	if err != nil {
		if s := strings.TrimSpace(stderr.String()); s != "" {
			err = fmt.Errorf("%w\nserver stderr:\n%s", err, s)
		}
		return c, nil, err
	}
	return c, infos, nil
}

// handshake is initialize, initialized and tools/list under one deadline. It
// is separate from start so the tests can drive it over pipes.
func handshake(ctx context.Context, c *client) ([]toolInfo, error) {
	if err := c.initialize(ctx); err != nil {
		return nil, err
	}
	return c.listTools(ctx)
}

// envList is env as KEY=VALUE pairs, sorted so two runs with the same config
// give the server the same environment in the same order.
func envList(env map[string]string) []string {
	out := make([]string, 0, len(env))
	for k, v := range env {
		out = append(out, k+"="+v)
	}
	sort.Strings(out)
	return out
}

// servers is the closer Connect returns: every client that made it through
// the handshake, closed together.
type servers struct {
	mu      sync.Mutex
	clients []*client
}

// Close kills every server and its process group. Safe to call more than
// once; the second call finds nothing to do.
func (s *servers) Close() error {
	s.mu.Lock()
	clients := s.clients
	s.clients = nil
	s.mu.Unlock()
	var wg sync.WaitGroup
	for _, c := range clients {
		wg.Add(1)
		go func() {
			defer wg.Done()
			_ = c.Close()
		}()
	}
	wg.Wait()
	return nil
}

// tail keeps the last max bytes written to it. It is what a server's stderr
// goes into: enough to say why it failed, bounded so a chatty server that
// runs all session cannot grow the harness.
type tail struct {
	mu  sync.Mutex
	max int
	buf []byte
}

func (t *tail) Write(p []byte) (int, error) {
	t.mu.Lock()
	defer t.mu.Unlock()
	t.buf = append(t.buf, p...)
	if len(t.buf) > t.max {
		t.buf = t.buf[len(t.buf)-t.max:]
	}
	return len(p), nil
}

func (t *tail) String() string {
	t.mu.Lock()
	defer t.mu.Unlock()
	return string(t.buf)
}