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