Vectrum
Sign In
462 lines · 14.4 KB · Go
// Package mcp is the client side of the Model Context Protocol: it starts the
// servers the config names, asks each what tools it has, and offers those
// tools to the agent as if they were Codey's own.
//
// Only the stdio transport is spoken. A server is a child process with
// JSON-RPC 2.0 on its stdin and stdout, one message per line — the same
// arrangement as the language servers in package diag, save that the LSP
// frames its messages with a Content-Length header and MCP with a newline.
// This file is the sibling of diag/lsp.go for that reason, and where the two
// look alike it is because the same thing goes wrong in both.
package mcp

import (
	"bufio"
	"bytes"
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"sync"
	"time"
)

// protocolVersion is what the client asks for. supportedVersions are the
// answers it will take: every one of them has the same initialize, tools/list
// and tools/call shapes over stdio, and those are the only methods this client
// uses, so a server answering with an older one is a server it can still
// talk to. A version outside the list is one whose messages may mean
// something else, and the spec's instruction is to disconnect rather than
// guess.
const protocolVersion = "2025-06-18"

var supportedVersions = []string{"2025-06-18", "2025-03-26", "2024-11-05"}

// initTimeout bounds the whole handshake: initialize, initialized and the
// first tools/list. A server started with npx may spend most of it
// downloading itself on first run, which is why it is longer than a call. A
// server that has not answered by then is not going to, and every one of its
// tools would otherwise be advertised to the model and then fail.
const initTimeout = 30 * time.Second

// callTimeout is how long one tools/call may take. It matches what the bash
// tool gives a command by default: an MCP tool is a command the model runs
// with different plumbing, and the model's patience is the same either way.
const callTimeout = 60 * time.Second

// maxMessageBytes is the largest line the client will read. Sixteen megabytes
// is far past any tool result the model could use and short of the point
// where a server that streams a file at us shows up in the harness's memory.
// bufio.Scanner enforces it by failing the read, which ends the session; a
// stream that has lost sync cannot be recovered anyway.
const maxMessageBytes = 16 << 20

// errServerDead is what a call gets from a server that has exited or been
// closed. The server is not restarted: a tool that fails this way once fails
// this way for the rest of the session, and the message says so.
var errServerDead = errors.New("MCP server is not running")

// client is one running server and the connection to it.
type client struct {
	name string

	// stdin is written under writeMu so two tool calls from a parallel batch
	// cannot interleave their lines.
	writeMu sync.Mutex
	stdin   io.WriteCloser
	// kill ends the process and wait reaps it; both are no-ops for the
	// in-process fake the tests use.
	kill func()
	wait func()

	// dead is closed when the connection is gone for any reason; deadErr says
	// why, for the error a tool call then gets.
	dead    chan struct{}
	dieOnce sync.Once
	deadErr error

	mu      sync.Mutex
	nextID  int64
	pending map[int64]chan rpcResponse

	// serverVersion is what the server answered initialize with, kept for
	// error messages: "the server speaks 2024-11-05" is a better complaint
	// than "protocol error".
	serverVersion string
	// hasTools is whether the server declared the tools capability. Without
	// it tools/list is a method the server may not have, and the message it
	// sends back for that is less clear than saying so ourselves.
	hasTools bool
}

// rpcResponse is the part of a response the client uses.
type rpcResponse struct {
	Result json.RawMessage
	Error  *rpcError
}

// rpcError is a JSON-RPC error object.
type rpcError struct {
	Code    int    `json:"code"`
	Message string `json:"message"`
}

func (e *rpcError) Error() string { return fmt.Sprintf("%s (%d)", e.Message, e.Code) }

// newClient wires a client over an already-open connection and starts the
// reader. It does not run the handshake: that is the caller's, under the
// caller's deadline. It is the seam the tests use to speak to an in-process
// fake server through pipes.
func newClient(name string, stdin io.WriteCloser, stdout io.Reader, kill, wait func()) *client {
	if kill == nil {
		kill = func() {}
	}
	if wait == nil {
		wait = func() {}
	}
	c := &client{
		name:    name,
		stdin:   stdin,
		kill:    kill,
		wait:    wait,
		dead:    make(chan struct{}),
		pending: map[int64]chan rpcResponse{},
	}
	go c.readLoop(stdout)
	return c
}

// Dead reports whether the connection is gone.
func (c *client) Dead() bool {
	select {
	case <-c.dead:
		return true
	default:
		return false
	}
}

// initialize runs the handshake. The server's protocol version is checked
// against supportedVersions and its capabilities read for tools; the
// initialized notification follows, after which the server may be asked for
// its tools.
func (c *client) initialize(ctx context.Context) error {
	params := map[string]any{
		"protocolVersion": protocolVersion,
		// No roots, no sampling, no elicitation: the client offers the server
		// nothing it can ask of Codey. A server that asks anyway is told the
		// method is not found, which the spec allows it to expect.
		"capabilities": map[string]any{},
		"clientInfo": map[string]any{
			"name":    "codey",
			"version": clientVersion,
		},
	}
	raw, err := c.call(ctx, "initialize", params)
	if err != nil {
		return fmt.Errorf("initialize: %w", err)
	}
	var res struct {
		ProtocolVersion string `json:"protocolVersion"`
		Capabilities    struct {
			Tools json.RawMessage `json:"tools"`
		} `json:"capabilities"`
	}
	if err := json.Unmarshal(raw, &res); err != nil {
		return fmt.Errorf("initialize: server sent a malformed result: %w", err)
	}
	if res.ProtocolVersion == "" {
		return fmt.Errorf("initialize: server did not say which protocol version it speaks")
	}
	if !supported(res.ProtocolVersion) {
		return fmt.Errorf("initialize: server speaks MCP %s, which this client does not (it speaks %s)",
			res.ProtocolVersion, protocolVersion)
	}
	c.mu.Lock()
	c.serverVersion = res.ProtocolVersion
	c.hasTools = len(res.Capabilities.Tools) > 0 && string(res.Capabilities.Tools) != "null"
	c.mu.Unlock()
	if err := c.notify("notifications/initialized", nil); err != nil {
		return fmt.Errorf("initialized: %w", err)
	}
	return nil
}

func supported(v string) bool {
	for _, s := range supportedVersions {
		if s == v {
			return true
		}
	}
	return false
}

// toolInfo is one entry of tools/list as the server describes it.
type toolInfo struct {
	Name        string          `json:"name"`
	Description string          `json:"description"`
	InputSchema json.RawMessage `json:"inputSchema"`
}

// listTools asks for every tool the server has, following the cursor until
// the server stops sending one. The spec allows a server to page; none of the
// common ones do, but one that did would otherwise silently show up with
// only its first page.
func (c *client) listTools(ctx context.Context) ([]toolInfo, error) {
	c.mu.Lock()
	hasTools := c.hasTools
	c.mu.Unlock()
	if !hasTools {
		return nil, fmt.Errorf("server offers no tools (it did not declare the tools capability)")
	}
	var out []toolInfo
	cursor := ""
	for {
		params := map[string]any{}
		if cursor != "" {
			params["cursor"] = cursor
		}
		raw, err := c.call(ctx, "tools/list", params)
		if err != nil {
			return nil, fmt.Errorf("tools/list: %w", err)
		}
		var res struct {
			Tools      []toolInfo `json:"tools"`
			NextCursor string     `json:"nextCursor"`
		}
		if err := json.Unmarshal(raw, &res); err != nil {
			return nil, fmt.Errorf("tools/list: server sent a malformed result: %w", err)
		}
		out = append(out, res.Tools...)
		if res.NextCursor == "" || res.NextCursor == cursor {
			return out, nil
		}
		cursor = res.NextCursor
	}
}

// callTool runs one tool and returns the raw result for the adapter to
// render. The deadline is the caller's or callTimeout, whichever comes first.
func (c *client) callTool(ctx context.Context, name string, args json.RawMessage) (json.RawMessage, error) {
	ctx, cancel := context.WithTimeout(ctx, callTimeout)
	defer cancel()
	if len(args) == 0 {
		args = json.RawMessage("{}")
	}
	return c.call(ctx, "tools/call", map[string]any{"name": name, "arguments": args})
}

// Close ends the server. Stdio MCP has no shutdown request: the client
// closes stdin, which is the server's signal to exit, and gives it a moment
// before the process group is killed. The group rather than the leader,
// because npx and uvx wrappers start the real server as a child and a killed
// wrapper leaves that child holding the pipes.
func (c *client) Close() error {
	if !c.Dead() {
		_ = c.stdin.Close()
		select {
		case <-c.dead:
		case <-time.After(shutdownGrace):
		}
	}
	c.die(errServerDead)
	c.wait()
	return nil
}

// shutdownGrace is how long a server has to exit on its own after stdin is
// closed. Half a second is what the language servers get, for the same
// reason: one that has not managed it by then is not going to.
const shutdownGrace = 500 * time.Millisecond

// die marks the connection gone, fails every request still waiting on it,
// and ends the process. Everything that can end a client funnels through
// here so it happens once.
func (c *client) die(err error) {
	c.dieOnce.Do(func() {
		c.mu.Lock()
		c.deadErr = err
		pending := c.pending
		c.pending = map[int64]chan rpcResponse{}
		c.mu.Unlock()
		for _, ch := range pending {
			close(ch)
		}
		close(c.dead)
		c.kill()
		_ = c.stdin.Close()
	})
}

// deadError is the error a call gets after death: what killed the server,
// wrapped so callers can still test for errServerDead.
func (c *client) deadError() error {
	c.mu.Lock()
	defer c.mu.Unlock()
	if c.deadErr == nil || c.deadErr == errServerDead {
		return fmt.Errorf("%w: %s", errServerDead, c.name)
	}
	return fmt.Errorf("%w: %s: %v", errServerDead, c.name, c.deadErr)
}

// call sends a request and waits for its response, for ctx, or for the
// server to die.
func (c *client) call(ctx context.Context, method string, params any) (json.RawMessage, error) {
	c.mu.Lock()
	if c.Dead() {
		c.mu.Unlock()
		return nil, c.deadError()
	}
	c.nextID++
	id := c.nextID
	ch := make(chan rpcResponse, 1)
	c.pending[id] = ch
	c.mu.Unlock()

	msg := map[string]any{"jsonrpc": "2.0", "id": id, "method": method}
	if params != nil {
		msg["params"] = params
	}
	if err := c.write(msg); err != nil {
		c.mu.Lock()
		delete(c.pending, id)
		c.mu.Unlock()
		return nil, err
	}
	select {
	case resp, ok := <-ch:
		if !ok {
			return nil, c.deadError()
		}
		if resp.Error != nil {
			return nil, resp.Error
		}
		return resp.Result, nil
	case <-ctx.Done():
		c.mu.Lock()
		delete(c.pending, id)
		c.mu.Unlock()
		return nil, ctx.Err()
	}
}

// notify sends a notification: a request with no id and no reply.
func (c *client) notify(method string, params any) error {
	msg := map[string]any{"jsonrpc": "2.0", "method": method}
	if params != nil {
		msg["params"] = params
	}
	return c.write(msg)
}

// reply answers a request the server made of us. It writes from its own
// goroutine rather than from the read loop that called it, for the reason
// diag/lsp.go gives: a server blocked writing its next request while we are
// blocked writing this answer is a deadlock the pipe buffer hides until a
// message outgrows it.
func (c *client) reply(id json.RawMessage, result any, err *rpcError) {
	msg := map[string]any{"jsonrpc": "2.0", "id": id}
	if err != nil {
		msg["error"] = err
	} else {
		msg["result"] = result
	}
	go func() { _ = c.write(msg) }()
}

// write frames msg as one line and writes it in one call. json.Marshal never
// emits a literal newline — it escapes them inside strings — so the
// trailing one is the only one on the line, which is what makes the framing
// hold.
func (c *client) write(msg any) error {
	if c.Dead() {
		return c.deadError()
	}
	body, err := json.Marshal(msg)
	if err != nil {
		return err
	}
	body = append(body, '\n')
	c.writeMu.Lock()
	defer c.writeMu.Unlock()
	if _, err := c.stdin.Write(body); err != nil {
		// A write that fails is a pipe with nobody on the other end.
		c.die(fmt.Errorf("write: %w", err))
		return c.deadError()
	}
	return nil
}

// incoming is the superset of the three JSON-RPC message shapes; which one
// it is depends on which fields are set.
type incoming struct {
	ID     json.RawMessage `json:"id"`
	Method string          `json:"method"`
	Params json.RawMessage `json:"params"`
	Result json.RawMessage `json:"result"`
	Error  *rpcError       `json:"error"`
}

// readLoop reads lines until the connection fails, dispatching responses to
// their callers. A line that is not JSON ends the session: a server that
// prints its logging to stdout instead of stderr has broken the framing for
// good, and the model is better told its tools are gone than left waiting on
// a reply that will never be recognised.
func (c *client) readLoop(r io.Reader) {
	sc := bufio.NewScanner(r)
	sc.Buffer(make([]byte, 64<<10), maxMessageBytes)
	for sc.Scan() {
		line := sc.Bytes()
		if len(bytes.TrimSpace(line)) == 0 {
			continue
		}
		var msg incoming
		if err := json.Unmarshal(line, &msg); err != nil {
			c.die(fmt.Errorf("server sent invalid JSON: %w", err))
			return
		}
		switch {
		case msg.Method == "" && len(msg.ID) > 0:
			c.dispatchResponse(msg)
		case msg.Method != "" && len(msg.ID) > 0 && string(msg.ID) != "null":
			c.handleRequest(msg)
		default:
			// Notifications — list_changed, logging, progress — carry nothing
			// this client acts on. Tools are listed once at startup.
		}
	}
	if err := sc.Err(); err != nil {
		c.die(fmt.Errorf("read: %w", err))
		return
	}
	c.die(errors.New("server exited"))
}

func (c *client) dispatchResponse(msg incoming) {
	var id int64
	if err := json.Unmarshal(msg.ID, &id); err != nil {
		return
	}
	c.mu.Lock()
	ch, ok := c.pending[id]
	delete(c.pending, id)
	c.mu.Unlock()
	if ok {
		ch <- rpcResponse{Result: msg.Result, Error: msg.Error}
	}
}

// handleRequest answers what a server asks of its client. ping gets its
// empty answer because a server that pings and hears nothing may take the
// silence for a dead client and exit. Everything else — roots, sampling,
// elicitation — is a capability the client did not offer in initialize, and
// the spec's answer for that is method-not-found.
func (c *client) handleRequest(msg incoming) {
	switch msg.Method {
	case "ping":
		c.reply(msg.ID, map[string]any{}, nil)
	default:
		c.reply(msg.ID, nil, &rpcError{Code: -32601, Message: "method not found"})
	}
}