package mcp import ( "bufio" "bytes" "context" "encoding/json" "errors" "fmt" "io" "os" "os/exec" "sync" ) // maxLine bounds one JSON-RPC frame from a subprocess. A tool result bigger // than this is a misbehaving server, not something to buffer. // // The bound is enforced by bufio.Scanner's own buffer limit, not by measuring // the line after it was assembled. Measuring afterwards is not a bound: a // server that emits 500 MB with no newline would have all 500 MB in mavend's // heap before the check could reject it, which on the deploy target is an OOM // kill of the core daemon. const maxLine = 1 << 20 // 1 MiB // stdioTransport speaks newline-delimited JSON-RPC to a child process. This is // the local transport: the server runs on this box, under this user, and gets // no network guard because it never touches the network on our behalf. // // Args are argv, never a shell string — the same discipline internal/tool // keeps, for the same reason. // // Reading happens on its own goroutine, feeding frames down a channel. That is // what makes a call abandonable: bufio never observes a context, so a server // that accepts a request and then writes nothing at all would otherwise block // the reader forever with the transport lock held, and every other server in // the manager behind it. type stdioTransport struct { cmd *exec.Cmd in io.WriteCloser lines chan []byte stop chan struct{} // closed by Close, so the reader can give up // callMu serialises whole calls, so two callers cannot consume each // other's frames off the shared channel. It is deliberately NOT the lock // alive() takes: a hung call must not make the manager's health check // block on it. callMu sync.Mutex mu sync.Mutex dead bool readErr error } func newStdioTransport(ctx context.Context, argv []string, env []string, dir string) (*stdioTransport, error) { if len(argv) == 0 { return nil, errors.New("mcp: stdio server needs a command") } cmd := exec.Command(argv[0], argv[1:]...) cmd.Dir = dir if len(env) > 0 { cmd.Env = append(os.Environ(), env...) } cmd.Stderr = os.Stderr in, err := cmd.StdinPipe() if err != nil { return nil, fmt.Errorf("mcp: stdin pipe: %w", err) } out, err := cmd.StdoutPipe() if err != nil { return nil, fmt.Errorf("mcp: stdout pipe: %w", err) } if err := cmd.Start(); err != nil { return nil, fmt.Errorf("mcp: start %q: %w", argv[0], err) } t := &stdioTransport{ cmd: cmd, in: in, lines: make(chan []byte), stop: make(chan struct{}), } go t.readLoop(out) return t, nil } // readLoop pushes one frame per line onto t.lines until the pipe ends. The // scanner's own buffer limit is the frame bound: a line longer than maxLine // ends the scan with bufio.ErrTooLong having buffered at most maxLine, rather // than assembling the whole thing first and rejecting it afterwards. func (t *stdioTransport) readLoop(out io.Reader) { defer close(t.lines) sc := bufio.NewScanner(out) sc.Buffer(make([]byte, 0, 64<<10), maxLine) for sc.Scan() { line := bytes.TrimSpace(sc.Bytes()) if len(line) == 0 { continue } frame := append([]byte(nil), line...) select { case t.lines <- frame: case <-t.stop: return } } err := sc.Err() switch { case errors.Is(err, bufio.ErrTooLong): err = fmt.Errorf("mcp: frame exceeds %d bytes", maxLine) case err == nil: err = io.EOF } t.mu.Lock() t.readErr = err t.mu.Unlock() } func (t *stdioTransport) Call(ctx context.Context, req *rpcRequest) (*rpcResponse, error) { t.callMu.Lock() defer t.callMu.Unlock() if !t.alive() { return nil, ErrClosed } t.mu.Lock() err := t.write(req) t.mu.Unlock() if err != nil { _ = t.Close() return nil, err } // Read until the frame with our id turns up; anything else on the pipe is // a notification or a server-initiated request we do not answer. for { select { case <-ctx.Done(): // A server that took the request and answered nothing is not a // server this connection can be reused with: the next call would // read into a pipe whose state we no longer know. Drop it and let // the manager re-dial. _ = t.Close() return nil, ctx.Err() case line, ok := <-t.lines: if !ok { t.mu.Lock() rerr := t.readErr t.mu.Unlock() _ = t.Close() if rerr == nil { rerr = ErrClosed } return nil, fmt.Errorf("mcp: read: %w", rerr) } var resp rpcResponse if err := json.Unmarshal(line, &resp); err != nil { continue // not a response frame; ignore rather than break the turn } if resp.ID == nil || *resp.ID != req.ID { continue } return &resp, nil } } } func (t *stdioTransport) Notify(ctx context.Context, method string, params any) error { t.mu.Lock() defer t.mu.Unlock() if t.dead { return ErrClosed } return t.write(&rpcRequest{JSONRPC: "2.0", Method: method, Params: params}) } // write must be called with t.mu held. func (t *stdioTransport) write(req *rpcRequest) error { req.JSONRPC = "2.0" raw, err := json.Marshal(req) if err != nil { return err } if _, err := t.in.Write(append(raw, '\n')); err != nil { return fmt.Errorf("mcp: write %s: %w", req.Method, err) } return nil } // Close is idempotent: a call that abandoned a silent pipe calls it, and so // does the manager. func (t *stdioTransport) Close() error { t.mu.Lock() if t.dead { t.mu.Unlock() return nil } t.dead = true close(t.stop) if t.in != nil { _ = t.in.Close() } t.mu.Unlock() if t.cmd.Process != nil { _ = t.cmd.Process.Kill() _ = t.cmd.Wait() } return nil } // alive reports whether the transport can still carry a call. The manager uses // it to decide on a reconnect instead of retrying into a dead pipe. func (t *stdioTransport) alive() bool { t.mu.Lock() defer t.mu.Unlock() if t.dead { return false } return t.readErr == nil }