go-sdk/mcp/transport.go
Rob Findley 34cc770852 internal/mcp: an MCP SDK prototype
This CL contains an minimal prototype of an MCP SDK, using the
"new" jsonrpc2_v2 package (similar to x/exp/jsonrpc2).

Much is still yet to do, but this initial version addressed the
following aspects:

- Support for newline delimited JSONRPC2 framing, and message logging.
- A generated protocol package, containing type definitions to be used
  in transport.
- A minimal jsonschema package to be used for both reading the MCP spec,
  and for serving Tool input schemas.
- A transport abstraction, and two transport implementations: stdio, and
  local (for testing).
- Client and Server types, to be configured with features and then
  connected using their Connect methods, which return (respectively) a
  ServerConnection and ClientConnection object.
- A minimal binding API for tools.

A catalog of things not yet done is in doc.go, but we should review this
CL eagerly, as it contains the fundamental building blocks of an
eventual SDK.

It is the intention that this package is more-or-less standalone, as it
may eventually be carved out into a separate repository. As such, its
only dependency within x/tools is jsonrpc2_v2, and the only change
required to that package is to expose a callback when the connection is
closed.

Change-Id: Ib66018961014f85a8884d56ab721d5642c98443c
Reviewed-on: https://go-review.googlesource.com/c/tools/+/667036
Reviewed-by: Jonathan Amsterdam <jba@google.com>
LUCI-TryBot-Result: Go LUCI <golang-scoped@luci-project-accounts.iam.gserviceaccount.com>
2025-04-22 14:37:47 -07:00

259 lines
6.8 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"
"github.com/modelcontextprotocol/go-sdk/internal/jsonrpc2"
)
// A JSONRPC2 error is an error defined by the JSONRPC2 spec.
type JSONRPC2Error = jsonrpc2.WireError
// 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.
type Transport struct {
dialer jsonrpc2.Dialer
}
// ConnectionOptions configures the behavior of an individual client<->server
// connection.
type ConnectionOptions struct {
Logger io.Writer
}
// NewStdIOTransport constructs a transport that communicates over
// stdin/stdout.
func NewStdIOTransport() *Transport {
dialer := dialerFunc(func(ctx context.Context) (io.ReadWriteCloser, error) {
return rwc{os.Stdin, os.Stdout}, nil
})
return &Transport{
dialer: dialer,
}
}
// NewLocalTransport returns two in-memory transports that connect to
// each other, for testing purposes.
func NewLocalTransport() (*Transport, *Transport) {
c1, c2 := net.Pipe()
t1 := &Transport{
dialer: dialerFunc(func(ctx context.Context) (io.ReadWriteCloser, error) {
return c1, nil
}),
}
t2 := &Transport{
dialer: dialerFunc(func(ctx context.Context) (io.ReadWriteCloser, error) {
return c2, nil
}),
}
return t1, t2
}
// handler is an unexported version of jsonrpc2.Handler, to be implemented by
// [ServerConnection] and [ClientConnection].
type handler interface {
handle(ctx context.Context, req *jsonrpc2.Request) (result any, err error)
comparable
}
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)
}
// Frame messages using newline delimited JSON.
//
// If logging is configured, write message logs.
var framer jsonrpc2.Framer = &ndjsonFramer{}
if opts.Logger != nil {
framer = &loggingFramer{opts.Logger, framer}
}
var h H
// Bind the server connection.
binder := jsonrpc2.BinderFunc(func(_ context.Context, conn *jsonrpc2.Connection) jsonrpc2.ConnectionOptions {
h = b.bind(conn)
return jsonrpc2.ConnectionOptions{
Framer: framer,
Handler: jsonrpc2.HandlerFunc(h.handle),
OnInternalError: func(err error) {
log.Printf("Internal error: %v", err)
},
}
})
// Clean up the connection when done.
onDone := func() {
b.disconnect(h)
}
var zero H
_, err := jsonrpc2.Dial(ctx, t.dialer, binder, onDone)
if err != nil {
return zero, err
}
assert(h != zero, "unbound connection")
return h, nil
}
// 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 {
err := conn.Call(ctx, method, params).Await(ctx, result)
switch {
case errors.Is(err, jsonrpc2.ErrClientClosing), errors.Is(err, jsonrpc2.ErrServerClosing):
return fmt.Errorf("calling %q: %w", method, ErrConnectionClosed)
case err != nil:
return fmt.Errorf("calling %q: %v", method, err)
}
return nil
}
// The helpers below are used to bind transports to jsonrpc2.
// A dialerFunc implements jsonrpc2.Dialer.Dial.
type dialerFunc func(context.Context) (io.ReadWriteCloser, error)
func (f dialerFunc) Dial(ctx context.Context) (io.ReadWriteCloser, error) {
return f(ctx)
}
// 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)
}
// A loggingFramer logs jsonrpc2 messages to its enclosed writer.
type loggingFramer struct {
w io.Writer
delegate jsonrpc2.Framer
}
func (f *loggingFramer) Reader(rw io.Reader) jsonrpc2.Reader {
delegate := f.delegate.Reader(rw)
return readerFunc(func(ctx context.Context) (jsonrpc2.Message, int64, error) {
msg, n, err := delegate.Read(ctx)
if err != nil {
fmt.Fprintf(f.w, "read error: %v", err)
} else {
data, err := jsonrpc2.EncodeMessage(msg)
if err != nil {
fmt.Fprintf(f.w, "LoggingFramer: failed to marshal: %v", err)
}
fmt.Fprintf(f.w, "read: %s", string(data))
}
return msg, n, err
})
}
func (f *loggingFramer) Writer(w io.Writer) jsonrpc2.Writer {
delegate := f.delegate.Writer(w)
return writerFunc(func(ctx context.Context, msg jsonrpc2.Message) (int64, error) {
n, err := delegate.Write(ctx, msg)
if err != nil {
fmt.Fprintf(f.w, "write error: %v", err)
} else {
data, err := jsonrpc2.EncodeMessage(msg)
if err != nil {
fmt.Fprintf(f.w, "LoggingFramer: failed to marshal: %v", err)
}
fmt.Fprintf(f.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) {
n, err = r.rc.Read(p)
return n, err
}
func (r rwc) Write(p []byte) (n int, err error) {
n, err = r.wc.Write(p)
return n, err
}
func (r rwc) Close() error {
return errors.Join(r.rc.Close(), r.wc.Close())
}
// A ndjsonFramer is a jsonrpc2.Framer that delimits messages with newlines.
//
// See also https://github.com/ndjson/ndjson-spec.
type ndjsonFramer struct{}
type rawReader struct{ in *json.Decoder } // relies on json.Decoder message boundaries
type ndjsonWriter struct{ out io.Writer } // writes newline message boundaries
func (ndjsonFramer) Reader(rw io.Reader) jsonrpc2.Reader {
return &rawReader{in: json.NewDecoder(rw)}
}
func (ndjsonFramer) Writer(rw io.Writer) jsonrpc2.Writer {
return &ndjsonWriter{out: rw}
}
func (r *rawReader) Read(ctx context.Context) (jsonrpc2.Message, int64, error) {
select {
case <-ctx.Done():
return nil, 0, ctx.Err()
default:
}
var raw json.RawMessage
if err := r.in.Decode(&raw); err != nil {
return nil, 0, err
}
msg, err := jsonrpc2.DecodeMessage(raw)
return msg, int64(len(raw)), err
}
func (w *ndjsonWriter) Write(ctx context.Context, msg jsonrpc2.Message) (int64, error) {
select {
case <-ctx.Done():
return 0, ctx.Err()
default:
}
data, err := jsonrpc2.EncodeMessage(msg)
if err != nil {
return 0, fmt.Errorf("marshaling message: %v", err)
}
data = append(data, '\n') // newline delimited
n, err := w.out.Write(data)
return int64(n), err
}