This is done to prevent concurrent reads and writes, which violate Go's memory model. The concurrent write may be triggered e.g. by setting the log level on the client side. Fixes #828
674 lines
20 KiB
Go
674 lines
20 KiB
Go
// Copyright 2025 The Go MCP SDK Authors. All rights reserved.
|
|
// Use of this source code is governed by an MIT-style
|
|
// license that can be found in the LICENSE file.
|
|
|
|
package mcp
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"log"
|
|
"net"
|
|
"os"
|
|
"sync"
|
|
|
|
internaljson "github.com/modelcontextprotocol/go-sdk/internal/json"
|
|
"github.com/modelcontextprotocol/go-sdk/internal/jsonrpc2"
|
|
"github.com/modelcontextprotocol/go-sdk/internal/xcontext"
|
|
"github.com/modelcontextprotocol/go-sdk/jsonrpc"
|
|
)
|
|
|
|
// ErrConnectionClosed is returned when sending a message to a connection that
|
|
// is closed or in the process of closing.
|
|
var ErrConnectionClosed = errors.New("connection closed")
|
|
|
|
// ErrSessionMissing is returned when the session is known to not be present on
|
|
// the server.
|
|
var ErrSessionMissing = errors.New("session not found")
|
|
|
|
// A Transport is used to create a bidirectional connection between MCP client
|
|
// and server.
|
|
//
|
|
// Transports should be used for at most one call to [Server.Connect] or
|
|
// [Client.Connect].
|
|
type Transport interface {
|
|
// Connect returns the logical JSON-RPC connection..
|
|
//
|
|
// It is called exactly once by [Server.Connect] or [Client.Connect].
|
|
Connect(ctx context.Context) (Connection, error)
|
|
}
|
|
|
|
// A Connection is a logical bidirectional JSON-RPC connection.
|
|
type Connection interface {
|
|
// Read reads the next message to process off the connection.
|
|
//
|
|
// Connections must allow Read to be called concurrently with Close. In
|
|
// particular, calling Close should unblock a Read waiting for input.
|
|
Read(context.Context) (jsonrpc.Message, error)
|
|
|
|
// Write writes a new message to the connection.
|
|
//
|
|
// Write may be called concurrently, as calls or responses may occur
|
|
// concurrently in user code.
|
|
Write(context.Context, jsonrpc.Message) error
|
|
|
|
// Close closes the connection. It is implicitly called whenever a Read or
|
|
// Write fails.
|
|
//
|
|
// Close may be called multiple times, potentially concurrently.
|
|
Close() error
|
|
|
|
// TODO(#148): remove SessionID from this interface.
|
|
SessionID() string
|
|
}
|
|
|
|
// A ClientConnection is a [Connection] that is specific to the MCP client.
|
|
//
|
|
// If client connections implement this interface, they may receive information
|
|
// about changes to the client session.
|
|
//
|
|
// TODO: should this interface be exported?
|
|
type clientConnection interface {
|
|
Connection
|
|
|
|
// sessionUpdated is called whenever the client session state changes.
|
|
sessionUpdated(clientSessionState)
|
|
}
|
|
|
|
// A serverConnection is a Connection that is specific to the MCP server.
|
|
//
|
|
// If server connections implement this interface, they receive information
|
|
// about changes to the server session.
|
|
//
|
|
// TODO: should this interface be exported?
|
|
type serverConnection interface {
|
|
Connection
|
|
sessionUpdated(ServerSessionState)
|
|
}
|
|
|
|
// A StdioTransport is a [Transport] that communicates over stdin/stdout using
|
|
// newline-delimited JSON.
|
|
type StdioTransport struct{}
|
|
|
|
// Connect implements the [Transport] interface.
|
|
func (*StdioTransport) Connect(context.Context) (Connection, error) {
|
|
return newIOConn(rwc{os.Stdin, nopCloserWriter{os.Stdout}}), nil
|
|
}
|
|
|
|
// nopCloserWriter is an io.WriteCloser with a trivial Close method.
|
|
type nopCloserWriter struct {
|
|
io.Writer
|
|
}
|
|
|
|
func (nopCloserWriter) Close() error { return nil }
|
|
|
|
// An IOTransport is a [Transport] that communicates over separate
|
|
// io.ReadCloser and io.WriteCloser using newline-delimited JSON.
|
|
type IOTransport struct {
|
|
Reader io.ReadCloser
|
|
Writer io.WriteCloser
|
|
}
|
|
|
|
// Connect implements the [Transport] interface.
|
|
func (t *IOTransport) Connect(context.Context) (Connection, error) {
|
|
return newIOConn(rwc{t.Reader, t.Writer}), nil
|
|
}
|
|
|
|
// An InMemoryTransport is a [Transport] that communicates over an in-memory
|
|
// network connection, using newline-delimited JSON.
|
|
//
|
|
// InMemoryTransports should be constructed using [NewInMemoryTransports],
|
|
// which returns two transports connected to each other.
|
|
type InMemoryTransport struct {
|
|
rwc io.ReadWriteCloser
|
|
}
|
|
|
|
// Connect implements the [Transport] interface.
|
|
func (t *InMemoryTransport) Connect(context.Context) (Connection, error) {
|
|
return newIOConn(t.rwc), nil
|
|
}
|
|
|
|
// NewInMemoryTransports returns two [InMemoryTransport] objects that connect
|
|
// to each other.
|
|
//
|
|
// The resulting transports are symmetrical: use either to connect to a server,
|
|
// and then the other to connect to a client. Servers must be connected before
|
|
// clients, as the client initializes the MCP session during connection.
|
|
func NewInMemoryTransports() (*InMemoryTransport, *InMemoryTransport) {
|
|
c1, c2 := net.Pipe()
|
|
return &InMemoryTransport{c1}, &InMemoryTransport{c2}
|
|
}
|
|
|
|
type binder[T handler, State any] interface {
|
|
// TODO(rfindley): the bind API has gotten too complicated. Simplify.
|
|
bind(Connection, *jsonrpc2.Connection, State, func()) T
|
|
disconnect(T)
|
|
}
|
|
|
|
type handler interface {
|
|
handle(ctx context.Context, req *jsonrpc.Request) (any, error)
|
|
}
|
|
|
|
func connect[H handler, State any](ctx context.Context, t Transport, b binder[H, State], s State, onClose func()) (H, error) {
|
|
var zero H
|
|
mcpConn, err := t.Connect(ctx)
|
|
if err != nil {
|
|
return zero, err
|
|
}
|
|
// If logging is configured, write message logs.
|
|
reader, writer := jsonrpc2.Reader(mcpConn), jsonrpc2.Writer(mcpConn)
|
|
var (
|
|
h H
|
|
preempter canceller
|
|
)
|
|
bind := func(conn *jsonrpc2.Connection) jsonrpc2.Handler {
|
|
h = b.bind(mcpConn, conn, s, onClose)
|
|
preempter.conn = conn
|
|
return jsonrpc2.HandlerFunc(h.handle)
|
|
}
|
|
_ = jsonrpc2.NewConnection(ctx, jsonrpc2.ConnectionConfig{
|
|
Reader: reader,
|
|
Writer: writer,
|
|
Closer: mcpConn,
|
|
Bind: bind,
|
|
Preempter: &preempter,
|
|
OnDone: func() {
|
|
b.disconnect(h)
|
|
},
|
|
OnInternalError: func(err error) { log.Printf("jsonrpc2 error: %v", err) },
|
|
})
|
|
assert(preempter.conn != nil, "unbound preempter")
|
|
return h, nil
|
|
}
|
|
|
|
// A canceller is a jsonrpc2.Preempter that cancels in-flight requests on MCP
|
|
// cancelled notifications.
|
|
type canceller struct {
|
|
conn *jsonrpc2.Connection
|
|
}
|
|
|
|
// Preempt implements [jsonrpc2.Preempter].
|
|
func (c *canceller) Preempt(ctx context.Context, req *jsonrpc.Request) (result any, err error) {
|
|
if req.Method == notificationCancelled {
|
|
var params CancelledParams
|
|
if err := internaljson.Unmarshal(req.Params, ¶ms); err != nil {
|
|
return nil, err
|
|
}
|
|
id, err := jsonrpc2.MakeID(params.RequestID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
go c.conn.Cancel(id)
|
|
}
|
|
return nil, jsonrpc2.ErrNotHandled
|
|
}
|
|
|
|
// call executes and awaits a jsonrpc2 call on the given connection,
|
|
// translating errors into the mcp domain.
|
|
func call(ctx context.Context, conn *jsonrpc2.Connection, method string, params Params, result Result) error {
|
|
// The "%w"s in this function expose jsonrpc.Error as part of the API.
|
|
call := conn.Call(ctx, method, params)
|
|
err := call.Await(ctx, result)
|
|
switch {
|
|
case errors.Is(err, jsonrpc2.ErrClientClosing), errors.Is(err, jsonrpc2.ErrServerClosing):
|
|
return fmt.Errorf("%w: calling %q: %v", ErrConnectionClosed, method, err)
|
|
case ctx.Err() != nil:
|
|
// Notify the peer of cancellation.
|
|
err := conn.Notify(xcontext.Detach(ctx), notificationCancelled, &CancelledParams{
|
|
Reason: ctx.Err().Error(),
|
|
RequestID: call.ID().Raw(),
|
|
})
|
|
// By default, the jsonrpc2 library waits for graceful shutdown when the
|
|
// connection is closed, meaning it expects all outgoing and incoming
|
|
// requests to complete. However, for MCP this expectation is unrealistic,
|
|
// and can lead to hanging shutdown. For example, if a streamable client is
|
|
// killed, the server will not be able to detect this event, except via
|
|
// keepalive pings (if they are configured), and so outgoing calls may hang
|
|
// indefinitely.
|
|
//
|
|
// Therefore, we choose to eagerly retire calls, removing them from the
|
|
// outgoingCalls map, when the caller context is cancelled: if the caller
|
|
// will never receive the response, there's no need to track it.
|
|
conn.Retire(call, ctx.Err())
|
|
return errors.Join(ctx.Err(), err)
|
|
case err != nil:
|
|
return fmt.Errorf("calling %q: %w", method, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// A LoggingTransport is a [Transport] that delegates to another transport,
|
|
// writing RPC logs to an io.Writer.
|
|
type LoggingTransport struct {
|
|
Transport Transport
|
|
Writer io.Writer
|
|
}
|
|
|
|
// Connect connects the underlying transport, returning a [Connection] that writes
|
|
// logs to the configured destination.
|
|
func (t *LoggingTransport) Connect(ctx context.Context) (Connection, error) {
|
|
delegate, err := t.Transport.Connect(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &loggingConn{delegate: delegate, w: t.Writer}, nil
|
|
}
|
|
|
|
type loggingConn struct {
|
|
delegate Connection
|
|
|
|
mu sync.Mutex
|
|
w io.Writer
|
|
}
|
|
|
|
func (c *loggingConn) SessionID() string { return c.delegate.SessionID() }
|
|
|
|
// Read is a stream middleware that logs incoming messages.
|
|
func (s *loggingConn) Read(ctx context.Context) (jsonrpc.Message, error) {
|
|
msg, err := s.delegate.Read(ctx)
|
|
|
|
if err != nil {
|
|
s.mu.Lock()
|
|
fmt.Fprintf(s.w, "read error: %v\n", err)
|
|
s.mu.Unlock()
|
|
} else {
|
|
data, err := jsonrpc2.EncodeMessage(msg)
|
|
s.mu.Lock()
|
|
if err != nil {
|
|
fmt.Fprintf(s.w, "LoggingTransport: failed to marshal: %v", err)
|
|
}
|
|
fmt.Fprintf(s.w, "read: %s\n", string(data))
|
|
s.mu.Unlock()
|
|
}
|
|
|
|
return msg, err
|
|
}
|
|
|
|
// Write is a stream middleware that logs outgoing messages.
|
|
func (s *loggingConn) Write(ctx context.Context, msg jsonrpc.Message) error {
|
|
err := s.delegate.Write(ctx, msg)
|
|
if err != nil {
|
|
s.mu.Lock()
|
|
fmt.Fprintf(s.w, "write error: %v\n", err)
|
|
s.mu.Unlock()
|
|
} else {
|
|
data, err := jsonrpc2.EncodeMessage(msg)
|
|
s.mu.Lock()
|
|
if err != nil {
|
|
fmt.Fprintf(s.w, "LoggingTransport: failed to marshal: %v", err)
|
|
}
|
|
fmt.Fprintf(s.w, "write: %s\n", string(data))
|
|
s.mu.Unlock()
|
|
}
|
|
return err
|
|
}
|
|
|
|
func (s *loggingConn) Close() error {
|
|
return s.delegate.Close()
|
|
}
|
|
|
|
// A rwc binds an io.ReadCloser and io.WriteCloser together to create an
|
|
// io.ReadWriteCloser.
|
|
type rwc struct {
|
|
rc io.ReadCloser
|
|
wc io.WriteCloser
|
|
}
|
|
|
|
func (r rwc) Read(p []byte) (n int, err error) {
|
|
return r.rc.Read(p)
|
|
}
|
|
|
|
func (r rwc) Write(p []byte) (n int, err error) {
|
|
return r.wc.Write(p)
|
|
}
|
|
|
|
func (r rwc) Close() error {
|
|
rcErr := r.rc.Close()
|
|
|
|
var wcErr error
|
|
if r.wc != nil { // we only allow a nil writer in unit tests
|
|
wcErr = r.wc.Close()
|
|
}
|
|
|
|
return errors.Join(rcErr, wcErr)
|
|
}
|
|
|
|
// An ioConn is a transport that delimits messages with newlines across
|
|
// a bidirectional stream, and supports jsonrpc.2 message batching.
|
|
//
|
|
// See https://github.com/ndjson/ndjson-spec for discussion of newline
|
|
// delimited JSON.
|
|
//
|
|
// See [msgBatch] for more discussion of message batching.
|
|
type ioConn struct {
|
|
// protocolVersion is the negotiated version of the protocol,
|
|
// set during session initialization.
|
|
// Since writes may be concurrent to reads, we need to guard this with a mutex.
|
|
sessionMu sync.Mutex
|
|
protocolVersion string
|
|
|
|
writeMu sync.Mutex // guards Write, which must be concurrency safe.
|
|
rwc io.ReadWriteCloser // the underlying stream
|
|
|
|
// incoming receives messages from the read loop started in [newIOConn].
|
|
incoming <-chan msgOrErr
|
|
|
|
// If outgoiBatch has a positive capacity, it will be used to batch requests
|
|
// and notifications before sending.
|
|
outgoingBatch []jsonrpc.Message
|
|
|
|
// Unread messages in the last batch. Since reads are serialized, there is no
|
|
// need to guard here.
|
|
queue []jsonrpc.Message
|
|
|
|
// batches correlate incoming requests to the batch in which they arrived.
|
|
// Since writes may be concurrent to reads, we need to guard this with a mutex.
|
|
batchMu sync.Mutex
|
|
batches map[jsonrpc2.ID]*msgBatch // lazily allocated
|
|
|
|
closeOnce sync.Once
|
|
closed chan struct{}
|
|
closeErr error
|
|
}
|
|
|
|
type msgOrErr struct {
|
|
msg json.RawMessage
|
|
err error
|
|
}
|
|
|
|
func newIOConn(rwc io.ReadWriteCloser) *ioConn {
|
|
var (
|
|
incoming = make(chan msgOrErr)
|
|
closed = make(chan struct{})
|
|
)
|
|
// Start a goroutine for reads, so that we can select on the incoming channel
|
|
// in [ioConn.Read] and unblock the read as soon as Close is called (see #224).
|
|
//
|
|
// This leaks a goroutine if rwc.Read does not unblock after it is closed,
|
|
// but that is unavoidable since AFAIK there is no (easy and portable) way to
|
|
// guarantee that reads of stdin are unblocked when closed.
|
|
go func() {
|
|
dec := json.NewDecoder(rwc)
|
|
for {
|
|
var raw json.RawMessage
|
|
err := dec.Decode(&raw)
|
|
// If decoding was successful, check for trailing data at the end of the stream.
|
|
if err == nil {
|
|
// Read the next byte to check if there is trailing data.
|
|
var tr [1]byte
|
|
if n, readErr := dec.Buffered().Read(tr[:]); n > 0 {
|
|
// If read byte is not a newline, it is an error.
|
|
// Support both Unix (\n) and Windows (\r\n) line endings.
|
|
if tr[0] != '\n' && tr[0] != '\r' {
|
|
err = fmt.Errorf("invalid trailing data at the end of stream")
|
|
}
|
|
} else if readErr != nil && readErr != io.EOF {
|
|
err = readErr
|
|
}
|
|
}
|
|
select {
|
|
case incoming <- msgOrErr{msg: raw, err: err}:
|
|
case <-closed:
|
|
return
|
|
}
|
|
if err != nil {
|
|
return
|
|
}
|
|
}
|
|
}()
|
|
return &ioConn{
|
|
rwc: rwc,
|
|
incoming: incoming,
|
|
closed: closed,
|
|
}
|
|
}
|
|
|
|
func (c *ioConn) SessionID() string { return "" }
|
|
|
|
func (c *ioConn) sessionUpdated(state ServerSessionState) {
|
|
protocolVersion := ""
|
|
if state.InitializeParams != nil {
|
|
protocolVersion = state.InitializeParams.ProtocolVersion
|
|
}
|
|
if protocolVersion == "" {
|
|
// 2025-03-26 is used, because it's the last spec version
|
|
// where specifying the protocol version in the HTTP header
|
|
// was not required.
|
|
protocolVersion = protocolVersion20250326
|
|
}
|
|
protocolVersion = negotiatedVersion(protocolVersion)
|
|
c.sessionMu.Lock()
|
|
c.protocolVersion = protocolVersion
|
|
c.sessionMu.Unlock()
|
|
}
|
|
|
|
// addBatch records a msgBatch for an incoming batch payload.
|
|
// It returns an error if batch is malformed, containing previously seen IDs.
|
|
//
|
|
// See [msgBatch] for more.
|
|
func (t *ioConn) addBatch(batch *msgBatch) error {
|
|
t.batchMu.Lock()
|
|
defer t.batchMu.Unlock()
|
|
for id := range batch.unresolved {
|
|
if _, ok := t.batches[id]; ok {
|
|
return fmt.Errorf("%w: batch contains previously seen request %v", jsonrpc2.ErrInvalidRequest, id.Raw())
|
|
}
|
|
}
|
|
for id := range batch.unresolved {
|
|
if t.batches == nil {
|
|
t.batches = make(map[jsonrpc2.ID]*msgBatch)
|
|
}
|
|
t.batches[id] = batch
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// updateBatch records a response in the message batch tracking the
|
|
// corresponding incoming call, if any.
|
|
//
|
|
// The second result reports whether resp was part of a batch. If this is true,
|
|
// the first result is nil if the batch is still incomplete, or the full set of
|
|
// batch responses if resp completed the batch.
|
|
func (t *ioConn) updateBatch(resp *jsonrpc.Response) ([]*jsonrpc.Response, bool) {
|
|
t.batchMu.Lock()
|
|
defer t.batchMu.Unlock()
|
|
|
|
if batch, ok := t.batches[resp.ID]; ok {
|
|
idx, ok := batch.unresolved[resp.ID]
|
|
if !ok {
|
|
panic("internal error: inconsistent batches")
|
|
}
|
|
batch.responses[idx] = resp
|
|
delete(batch.unresolved, resp.ID)
|
|
delete(t.batches, resp.ID)
|
|
if len(batch.unresolved) == 0 {
|
|
return batch.responses, true
|
|
}
|
|
return nil, true
|
|
}
|
|
return nil, false
|
|
}
|
|
|
|
// A msgBatch records information about an incoming batch of jsonrpc.2 calls.
|
|
//
|
|
// The jsonrpc.2 spec (https://www.jsonrpc.org/specification#batch) says:
|
|
//
|
|
// "The Server should respond with an Array containing the corresponding
|
|
// Response objects, after all of the batch Request objects have been
|
|
// processed. A Response object SHOULD exist for each Request object, except
|
|
// that there SHOULD NOT be any Response objects for notifications. The Server
|
|
// MAY process a batch rpc call as a set of concurrent tasks, processing them
|
|
// in any order and with any width of parallelism."
|
|
//
|
|
// Therefore, a msgBatch keeps track of outstanding calls and their responses.
|
|
// When there are no unresolved calls, the response payload is sent.
|
|
type msgBatch struct {
|
|
unresolved map[jsonrpc2.ID]int
|
|
responses []*jsonrpc.Response
|
|
}
|
|
|
|
func (t *ioConn) Read(ctx context.Context) (jsonrpc.Message, error) {
|
|
// As a matter of principle, enforce that reads on a closed context return an
|
|
// error.
|
|
select {
|
|
case <-ctx.Done():
|
|
return nil, ctx.Err()
|
|
default:
|
|
}
|
|
if len(t.queue) > 0 {
|
|
next := t.queue[0]
|
|
t.queue = t.queue[1:]
|
|
return next, nil
|
|
}
|
|
|
|
var raw json.RawMessage
|
|
select {
|
|
case <-ctx.Done():
|
|
return nil, ctx.Err()
|
|
|
|
case v := <-t.incoming:
|
|
if v.err != nil {
|
|
return nil, v.err
|
|
}
|
|
raw = v.msg
|
|
|
|
case <-t.closed:
|
|
return nil, io.EOF
|
|
}
|
|
|
|
msgs, batch, err := readBatch(raw)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var protocolVersion string
|
|
t.sessionMu.Lock()
|
|
protocolVersion = t.protocolVersion
|
|
t.sessionMu.Unlock()
|
|
if batch && protocolVersion >= protocolVersion20250618 {
|
|
return nil, fmt.Errorf("JSON-RPC batching is not supported in %s and later (request version: %s)", protocolVersion20250618, protocolVersion)
|
|
}
|
|
|
|
t.queue = msgs[1:]
|
|
|
|
if batch {
|
|
var respBatch *msgBatch // track incoming requests in the batch
|
|
for _, msg := range msgs {
|
|
if req, ok := msg.(*jsonrpc.Request); ok {
|
|
if respBatch == nil {
|
|
respBatch = &msgBatch{
|
|
unresolved: make(map[jsonrpc2.ID]int),
|
|
}
|
|
}
|
|
if _, ok := respBatch.unresolved[req.ID]; ok {
|
|
return nil, fmt.Errorf("duplicate message ID %q", req.ID)
|
|
}
|
|
respBatch.unresolved[req.ID] = len(respBatch.responses)
|
|
respBatch.responses = append(respBatch.responses, nil)
|
|
}
|
|
}
|
|
if respBatch != nil {
|
|
// The batch contains one or more incoming requests to track.
|
|
if err := t.addBatch(respBatch); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
}
|
|
return msgs[0], err
|
|
}
|
|
|
|
// readBatch reads batch data, which may be either a single JSON-RPC message,
|
|
// or an array of JSON-RPC messages.
|
|
func readBatch(data []byte) (msgs []jsonrpc.Message, isBatch bool, _ error) {
|
|
// Try to read an array of messages first.
|
|
var rawBatch []json.RawMessage
|
|
if err := internaljson.Unmarshal(data, &rawBatch); err == nil {
|
|
if len(rawBatch) == 0 {
|
|
return nil, true, fmt.Errorf("empty batch")
|
|
}
|
|
for _, raw := range rawBatch {
|
|
msg, err := jsonrpc2.DecodeMessage(raw)
|
|
if err != nil {
|
|
return nil, true, err
|
|
}
|
|
msgs = append(msgs, msg)
|
|
}
|
|
return msgs, true, nil
|
|
}
|
|
// Try again with a single message.
|
|
msg, err := jsonrpc2.DecodeMessage(data)
|
|
return []jsonrpc.Message{msg}, false, err
|
|
}
|
|
|
|
func (t *ioConn) Write(ctx context.Context, msg jsonrpc.Message) error {
|
|
// As in [ioConn.Read], enforce that Writes on a closed context are an error.
|
|
select {
|
|
case <-ctx.Done():
|
|
return ctx.Err()
|
|
default:
|
|
}
|
|
|
|
t.writeMu.Lock()
|
|
defer t.writeMu.Unlock()
|
|
|
|
// Batching support: if msg is a Response, it may have completed a batch, so
|
|
// check that first. Otherwise, it is a request or notification, and we may
|
|
// want to collect it into a batch before sending, if we're configured to use
|
|
// outgoing batches.
|
|
if resp, ok := msg.(*jsonrpc.Response); ok {
|
|
if batch, ok := t.updateBatch(resp); ok {
|
|
if len(batch) > 0 {
|
|
data, err := marshalMessages(batch)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
data = append(data, '\n')
|
|
_, err = t.rwc.Write(data)
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
} else if len(t.outgoingBatch) < cap(t.outgoingBatch) {
|
|
t.outgoingBatch = append(t.outgoingBatch, msg)
|
|
if len(t.outgoingBatch) == cap(t.outgoingBatch) {
|
|
data, err := marshalMessages(t.outgoingBatch)
|
|
t.outgoingBatch = t.outgoingBatch[:0]
|
|
if err != nil {
|
|
return err
|
|
}
|
|
data = append(data, '\n')
|
|
_, err = t.rwc.Write(data)
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
data, err := jsonrpc2.EncodeMessage(msg)
|
|
if err != nil {
|
|
return fmt.Errorf("marshaling message: %v", err)
|
|
}
|
|
data = append(data, '\n') // newline delimited
|
|
_, err = t.rwc.Write(data)
|
|
return err
|
|
}
|
|
|
|
func (t *ioConn) Close() error {
|
|
t.closeOnce.Do(func() {
|
|
t.closeErr = t.rwc.Close()
|
|
close(t.closed)
|
|
})
|
|
return t.closeErr
|
|
}
|
|
|
|
func marshalMessages[T jsonrpc.Message](msgs []T) ([]byte, error) {
|
|
var rawMsgs []json.RawMessage
|
|
for _, msg := range msgs {
|
|
raw, err := jsonrpc2.EncodeMessage(msg)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("encoding batch message: %w", err)
|
|
}
|
|
rawMsgs = append(rawMsgs, raw)
|
|
}
|
|
return json.Marshal(rawMsgs)
|
|
}
|