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>
This commit is contained in:
Rob Findley 2025-04-07 20:46:00 +00:00 • committed by Robert Findley
commit 34cc770852
4 changed files with 1880 additions and 11 deletions

View file

@ -41,9 +41,9 @@ type Writer interface {
// It is responsible for the framing and encoding of messages into wire form.
type Framer interface {
// Reader wraps a byte reader into a message reader.
Reader(rw io.Reader) Reader
Reader(io.Reader) Reader
// Writer wraps a byte writer into a message writer.
Writer(rw io.Writer) Writer
Writer(io.Writer) Writer
}
// RawFramer returns a new Framer.

View file

@ -153,7 +153,7 @@ func testConnection(t *testing.T, framer jsonrpc2.Framer) {
// also run all simple call tests in echo mode
(*echo)(call).Invoke(t, ctx, h)
}
}})
}}, nil)
if err != nil {
t.Fatal(err)
}

View file

@ -54,13 +54,15 @@ type Server struct {
// Handler provided by the Binder, and will release its own resources when the
// connection is broken, but the caller may Close it earlier to stop accepting
// (or sending) new requests.
func Dial(ctx context.Context, dialer Dialer, binder Binder) (*Connection, error) {
//
// If non-nil, the onDone function is called when the connection is closed.
func Dial(ctx context.Context, dialer Dialer, binder Binder, onDone func()) (*Connection, error) {
// dial a server
rwc, err := dialer.Dial(ctx)
if err != nil {
return nil, err
}
return newConnection(ctx, rwc, binder, nil), nil
return newConnection(ctx, rwc, binder, onDone), nil
}
// NewServer starts a new server listening for incoming connections and returns

View file

@ -47,7 +47,7 @@ func TestIdleTimeout(t *testing.T) {
// Exercise some connection/disconnection patterns, and then assert that when
// our timer fires, the server exits.
conn1, err := jsonrpc2.Dial(ctx, listener.Dialer(), jsonrpc2.ConnectionOptions{})
conn1, err := jsonrpc2.Dial(ctx, listener.Dialer(), jsonrpc2.ConnectionOptions{}, nil)
if err != nil {
if since := time.Since(idleStart); since < d {
t.Fatalf("conn1 failed to connect after %v: %v", since, err)
@ -71,7 +71,7 @@ func TestIdleTimeout(t *testing.T) {
// Since conn1 was successfully accepted and remains open, the server is
// definitely non-idle. Dialing another simultaneous connection should
// succeed.
conn2, err := jsonrpc2.Dial(ctx, listener.Dialer(), jsonrpc2.ConnectionOptions{})
conn2, err := jsonrpc2.Dial(ctx, listener.Dialer(), jsonrpc2.ConnectionOptions{}, nil)
if err != nil {
conn1.Close()
t.Fatalf("conn2 failed to connect while non-idle after %v: %v", time.Since(idleStart), err)
@ -96,7 +96,7 @@ func TestIdleTimeout(t *testing.T) {
t.Fatalf("conn2.Close failed with error: %v", err)
}
conn3, err := jsonrpc2.Dial(ctx, listener.Dialer(), jsonrpc2.ConnectionOptions{})
conn3, err := jsonrpc2.Dial(ctx, listener.Dialer(), jsonrpc2.ConnectionOptions{}, nil)
if err != nil {
if since := time.Since(idleStart); since < d {
t.Fatalf("conn3 failed to connect after %v: %v", since, err)
@ -205,7 +205,7 @@ func newFake(t *testing.T, ctx context.Context, l jsonrpc2.Listener) (*jsonrpc2.
l.Dialer(),
jsonrpc2.ConnectionOptions{
Handler: fakeHandler{},
})
}, nil)
if err != nil {
return nil, nil, err
}
@ -250,7 +250,7 @@ func TestIdleListenerAcceptCloseRace(t *testing.T) {
done := make(chan struct{})
go func() {
conn, err := jsonrpc2.Dial(ctx, listener.Dialer(), jsonrpc2.ConnectionOptions{})
conn, err := jsonrpc2.Dial(ctx, listener.Dialer(), jsonrpc2.ConnectionOptions{}, nil)
listener.Close()
if err == nil {
conn.Close()
@ -313,7 +313,7 @@ func TestCloseCallRace(t *testing.T) {
return jsonrpc2.ConnectionOptions{Handler: h}
}))
dialConn, err := jsonrpc2.Dial(ctx, listener.Dialer(), jsonrpc2.ConnectionOptions{})
dialConn, err := jsonrpc2.Dial(ctx, listener.Dialer(), jsonrpc2.ConnectionOptions{}, nil)
if err != nil {
listener.Close()
s.Wait()