go-sdk/mcp/transport.go
Jonathan Amsterdam 1ae23e7031 internal/mcp: implement resources
Implementation of resources.

This follows the design sketched in design/design.md, except for
Server.FileResourceHandler, which will be in a forthcoming CL.

As the spec recommends, the server verifies an incoming URI from the
client against its list of resources, to make sure the URI came from one
of them.

Some things that aren't in the design doc:
- I provide an exported function that creates a "resource not found"
  jsonrpc2.WireEror error for server authors to return.
  A wrinkle: the MCP spec defines the error code for
  "resource not found" as -32002 [1]. That code is reserved for use
  by implementations of JSON-RPC, not applications. That matters to us
  because our JSON-RPC implementation already uses that code for the
  "server closing" error. I filed a bug against MCP about it [2] and
  chose a different code for the "resource not found" error.

- As a slight convenience, I fill in some fields in the
  ReadResourceResult if the handler doesn't set them.

[1] https://modelcontextprotocol.io/specification/2025-03-26/server/resources#error-handling
[2] https://github.com/modelcontextprotocol/modelcontextprotocol/issues/509

Change-Id: I06153da982ea00748a3691ded923448a880cea2a
Reviewed-on: https://go-review.googlesource.com/c/tools/+/671363
LUCI-TryBot-Result: Go LUCI <golang-scoped@luci-project-accounts.iam.gserviceaccount.com>
Reviewed-by: Robert Findley <rfindley@google.com>
2025-05-13 11:39:49 -07:00

486 lines
14 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"
"net"
"os"
"sync"
"github.com/modelcontextprotocol/go-sdk/internal/jsonrpc2"
"github.com/modelcontextprotocol/go-sdk/mcp/protocol"
"github.com/modelcontextprotocol/go-sdk/internal/xcontext"
)
// 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")
// 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.Start].
type Transport interface {
// Connect returns the logical stream.
//
// It is called exactly once by [Connect].
Connect(ctx context.Context) (Stream, error)
}
// A Stream is a bidirectional jsonrpc2 Stream.
type Stream interface {
jsonrpc2.Reader
jsonrpc2.Writer
io.Closer
}
// ConnectionOptions configures the behavior of an individual client<->server
// connection.
type ConnectionOptions struct {
SessionID string // if set, the session ID
Logger io.Writer // if set, write RPC logs
}
// An IOTransport is a [Transport] that communicates using newline-delimited
// JSON over an io.ReadWriteCloser.
type IOTransport struct {
rwc io.ReadWriteCloser
}
func (t *IOTransport) Connect(context.Context) (Stream, error) {
return newIOStream(t.rwc), nil
}
// NewStdIOTransport constructs a transport that communicates over
// stdin/stdout.
func NewStdIOTransport() *IOTransport {
return &IOTransport{rwc{os.Stdin, os.Stdout}}
}
// NewLocalTransport returns two in-memory transports that connect to
// each other, for testing purposes.
func NewLocalTransport() (*IOTransport, *IOTransport) {
c1, c2 := net.Pipe()
return &IOTransport{c1}, &IOTransport{c2}
}
// handler is an unexported version of jsonrpc2.Handler.
type handler interface {
handle(ctx context.Context, req *jsonrpc2.Request) (result any, err error)
}
type binder[T handler] interface {
bind(*jsonrpc2.Connection) T
disconnect(T)
}
func connect[H handler](ctx context.Context, t Transport, opts *ConnectionOptions, b binder[H]) (H, error) {
if opts == nil {
opts = new(ConnectionOptions)
}
var zero H
stream, err := t.Connect(ctx)
if err != nil {
return zero, err
}
// If logging is configured, write message logs.
reader, writer := jsonrpc2.Reader(stream), jsonrpc2.Writer(stream)
if opts.Logger != nil {
reader = loggingReader(opts.Logger, reader)
writer = loggingWriter(opts.Logger, writer)
}
var (
h H
preempter canceller
)
bind := func(conn *jsonrpc2.Connection) jsonrpc2.Handler {
h = b.bind(conn)
preempter.conn = conn
return jsonrpc2.HandlerFunc(h.handle)
}
_ = jsonrpc2.NewConnection(ctx, jsonrpc2.ConnectionConfig{
Reader: reader,
Writer: writer,
Closer: stream,
Bind: bind,
Preempter: &preempter,
OnDone: func() {
b.disconnect(h)
},
})
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 *jsonrpc2.Request) (result any, err error) {
if req.Method == "notifications/cancelled" {
var params protocol.CancelledParams
if err := json.Unmarshal(req.Params, &params); 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, result any) error {
// TODO: the "%w"s in this function effectively make jsonrpc2.WireError part of the API.
// Consider alternatives.
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("calling %q: %w", method, ErrConnectionClosed)
case ctx.Err() != nil:
// Notify the peer of cancellation.
err := conn.Notify(xcontext.Detach(ctx), "notifications/cancelled", &protocol.CancelledParams{
Reason: ctx.Err().Error(),
RequestID: call.ID().Raw(),
})
return errors.Join(ctx.Err(), err)
case err != nil:
return fmt.Errorf("calling %q: %w", method, err)
}
return nil
}
// The helpers below are used to bind transports to jsonrpc2.
// A readerFunc implements jsonrpc2.Reader.Read.
type readerFunc func(context.Context) (jsonrpc2.Message, int64, error)
func (f readerFunc) Read(ctx context.Context) (jsonrpc2.Message, int64, error) {
return f(ctx)
}
// A writerFunc implements jsonrpc2.Writer.Write.
type writerFunc func(context.Context, jsonrpc2.Message) (int64, error)
func (f writerFunc) Write(ctx context.Context, msg jsonrpc2.Message) (int64, error) {
return f(ctx, msg)
}
// loggingReader is a stream middleware that logs incoming messages.
func loggingReader(w io.Writer, delegate jsonrpc2.Reader) jsonrpc2.Reader {
return readerFunc(func(ctx context.Context) (jsonrpc2.Message, int64, error) {
msg, n, err := delegate.Read(ctx)
if err != nil {
fmt.Fprintf(w, "read error: %v", err)
} else {
data, err := jsonrpc2.EncodeMessage(msg)
if err != nil {
fmt.Fprintf(w, "LoggingFramer: failed to marshal: %v", err)
}
fmt.Fprintf(w, "read: %s", string(data))
}
return msg, n, err
})
}
// loggingWriter is a stream middleware that logs outgoing messages.
func loggingWriter(w io.Writer, delegate jsonrpc2.Writer) jsonrpc2.Writer {
return writerFunc(func(ctx context.Context, msg jsonrpc2.Message) (int64, error) {
n, err := delegate.Write(ctx, msg)
if err != nil {
fmt.Fprintf(w, "write error: %v", err)
} else {
data, err := jsonrpc2.EncodeMessage(msg)
if err != nil {
fmt.Fprintf(w, "LoggingFramer: failed to marshal: %v", err)
}
fmt.Fprintf(w, "write: %s", string(data))
}
return n, err
})
}
// 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 {
return errors.Join(r.rc.Close(), r.wc.Close())
}
// An ioStream is a transport that delimits messages with newlines across
// a bidirectional stream, and supports JSONRPC2 message batching.
//
// See https://github.com/ndjson/ndjson-spec for discussion of newline
// delimited JSON.
//
// See [msgBatch] for more discussion of message batching.
type ioStream struct {
rwc io.ReadWriteCloser // the underlying stream
in *json.Decoder // a decoder bound to rwc
// If outgoiBatch has a positive capacity, it will be used to batch requests
// and notifications before sending.
outgoingBatch []jsonrpc2.Message
// Unread messages in the last batch. Since reads are serialized, there is no
// need to guard here.
queue []jsonrpc2.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
}
func newIOStream(rwc io.ReadWriteCloser) *ioStream {
return &ioStream{
rwc: rwc,
in: json.NewDecoder(rwc),
}
}
// Connect returns the receiver, as a streamTransport is a logical stream.
func (t *ioStream) Connect(ctx context.Context) (Stream, error) {
return t, nil
}
// 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 *ioStream) 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 *ioStream) updateBatch(resp *jsonrpc2.Response) ([]*jsonrpc2.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 JSONRPC2 calls.
//
// The JSONRPC2 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 []*jsonrpc2.Response
}
func (t *ioStream) Read(ctx context.Context) (jsonrpc2.Message, int64, error) {
return t.read(ctx, t.in)
}
func (t *ioStream) read(ctx context.Context, in *json.Decoder) (jsonrpc2.Message, int64, error) {
select {
case <-ctx.Done():
return nil, 0, ctx.Err()
default:
}
if len(t.queue) > 0 {
next := t.queue[0]
t.queue = t.queue[1:]
return next, 0, nil
}
var raw json.RawMessage
if err := in.Decode(&raw); err != nil {
return nil, 0, err
}
var rawBatch []json.RawMessage
if err := json.Unmarshal(raw, &rawBatch); err == nil {
msg, err := t.readBatch(rawBatch)
if err != nil {
return nil, 0, err
}
return msg, int64(len(raw)), nil
}
msg, err := jsonrpc2.DecodeMessage(raw)
return msg, int64(len(raw)), err
}
// readBatch reads a batch of jsonrpc2 messages, and records the batch
// in the framer so that responses can be collected and send back together.
func (t *ioStream) readBatch(rawBatch []json.RawMessage) (jsonrpc2.Message, error) {
if len(rawBatch) == 0 {
return nil, fmt.Errorf("empty batch")
}
// From the spec:
// "If the batch rpc call itself fails to be recognized as an valid JSON or
// as an Array with at least one value, the response from the Server MUST be
// a single Response object. If there are no Response objects contained
// within the Response array as it is to be sent to the client, the server
// MUST NOT return an empty Array and should return nothing at all."
//
// In our case, an error actually breaks the jsonrpc2 connection entirely,
// but defensively we collect batch information before recording it, so that
// we don't leave the framer in an inconsistent state.
var (
first jsonrpc2.Message // first message, to return
queue []jsonrpc2.Message // remaining messages
respBatch *msgBatch // tracks incoming requests in the batch
)
for i, raw := range rawBatch {
msg, err := jsonrpc2.DecodeMessage(raw)
if err != nil {
return nil, err
}
if i == 0 {
first = msg
} else {
queue = append(queue, msg)
}
if req, ok := msg.(*jsonrpc2.Request); ok {
if respBatch == nil {
respBatch = &msgBatch{
unresolved: make(map[jsonrpc2.ID]int),
}
}
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
}
}
t.queue = append(t.queue, queue...)
return first, nil
}
func (t *ioStream) Write(ctx context.Context, msg jsonrpc2.Message) (int64, error) {
select {
case <-ctx.Done():
return 0, ctx.Err()
default:
}
// 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.(*jsonrpc2.Response); ok {
if batch, ok := t.updateBatch(resp); ok {
if len(batch) > 0 {
data, err := marshalMessages(batch)
if err != nil {
return 0, err
}
data = append(data, '\n')
n, err := t.rwc.Write(data)
return int64(n), err
}
return 0, 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 0, err
}
data = append(data, '\n')
n, err := t.rwc.Write(data)
return int64(n), err
}
return 0, nil
}
data, err := jsonrpc2.EncodeMessage(msg)
if err != nil {
return 0, fmt.Errorf("marshaling message: %v", err)
}
data = append(data, '\n') // newline delimited
n, err := t.rwc.Write(data)
return int64(n), err
}
func (t *ioStream) Close() error {
return t.rwc.Close()
}
func marshalMessages[T jsonrpc2.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)
}