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>
388 lines
9.8 KiB
Go
388 lines
9.8 KiB
Go
// Copyright 2018 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 jsonrpc2_test
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"path"
|
|
"reflect"
|
|
"testing"
|
|
|
|
"golang.org/x/tools/internal/event/export/eventtest"
|
|
"github.com/modelcontextprotocol/go-sdk/internal/jsonrpc2"
|
|
"golang.org/x/tools/internal/stack/stacktest"
|
|
)
|
|
|
|
var callTests = []invoker{
|
|
call{"no_args", nil, true},
|
|
call{"one_string", "fish", "got:fish"},
|
|
call{"one_number", 10, "got:10"},
|
|
call{"join", []string{"a", "b", "c"}, "a/b/c"},
|
|
sequence{"notify", []invoker{
|
|
notify{"set", 3},
|
|
notify{"add", 5},
|
|
call{"get", nil, 8},
|
|
}},
|
|
sequence{"preempt", []invoker{
|
|
async{"a", "wait", "a"},
|
|
notify{"unblock", "a"},
|
|
collect{"a", true, false},
|
|
}},
|
|
sequence{"basic cancel", []invoker{
|
|
async{"b", "wait", "b"},
|
|
cancel{"b"},
|
|
collect{"b", nil, true},
|
|
}},
|
|
sequence{"queue", []invoker{
|
|
async{"a", "wait", "a"},
|
|
notify{"set", 1},
|
|
notify{"add", 2},
|
|
notify{"add", 3},
|
|
notify{"add", 4},
|
|
call{"peek", nil, 0}, // accumulator will not have any adds yet
|
|
notify{"unblock", "a"},
|
|
collect{"a", true, false},
|
|
call{"get", nil, 10}, // accumulator now has all the adds
|
|
}},
|
|
sequence{"fork", []invoker{
|
|
async{"a", "fork", "a"},
|
|
notify{"set", 1},
|
|
notify{"add", 2},
|
|
notify{"add", 3},
|
|
notify{"add", 4},
|
|
call{"get", nil, 10}, // fork will not have blocked the adds
|
|
notify{"unblock", "a"},
|
|
collect{"a", true, false},
|
|
}},
|
|
sequence{"concurrent", []invoker{
|
|
async{"a", "fork", "a"},
|
|
notify{"unblock", "a"},
|
|
async{"b", "fork", "b"},
|
|
notify{"unblock", "b"},
|
|
collect{"a", true, false},
|
|
collect{"b", true, false},
|
|
}},
|
|
}
|
|
|
|
type binder struct {
|
|
framer jsonrpc2.Framer
|
|
runTest func(*handler)
|
|
}
|
|
|
|
type handler struct {
|
|
conn *jsonrpc2.Connection
|
|
accumulator int
|
|
waiters chan map[string]chan struct{}
|
|
calls map[string]*jsonrpc2.AsyncCall
|
|
}
|
|
|
|
type invoker interface {
|
|
Name() string
|
|
Invoke(t *testing.T, ctx context.Context, h *handler)
|
|
}
|
|
|
|
type notify struct {
|
|
method string
|
|
params any
|
|
}
|
|
|
|
type call struct {
|
|
method string
|
|
params any
|
|
expect any
|
|
}
|
|
|
|
type async struct {
|
|
name string
|
|
method string
|
|
params any
|
|
}
|
|
|
|
type collect struct {
|
|
name string
|
|
expect any
|
|
fails bool
|
|
}
|
|
|
|
type cancel struct {
|
|
name string
|
|
}
|
|
|
|
type sequence struct {
|
|
name string
|
|
tests []invoker
|
|
}
|
|
|
|
type echo call
|
|
|
|
type cancelParams struct{ ID int64 }
|
|
|
|
func TestConnectionRaw(t *testing.T) {
|
|
testConnection(t, jsonrpc2.RawFramer())
|
|
}
|
|
|
|
func TestConnectionHeader(t *testing.T) {
|
|
testConnection(t, jsonrpc2.HeaderFramer())
|
|
}
|
|
|
|
func testConnection(t *testing.T, framer jsonrpc2.Framer) {
|
|
stacktest.NoLeak(t)
|
|
ctx := eventtest.NewContext(context.Background(), t)
|
|
listener, err := jsonrpc2.NetPipeListener(ctx)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
server := jsonrpc2.NewServer(ctx, listener, binder{framer, nil})
|
|
defer func() {
|
|
listener.Close()
|
|
server.Wait()
|
|
}()
|
|
|
|
for _, test := range callTests {
|
|
t.Run(test.Name(), func(t *testing.T) {
|
|
client, err := jsonrpc2.Dial(ctx,
|
|
listener.Dialer(), binder{framer, func(h *handler) {
|
|
defer h.conn.Close()
|
|
ctx := eventtest.NewContext(ctx, t)
|
|
test.Invoke(t, ctx, h)
|
|
if call, ok := test.(*call); ok {
|
|
// also run all simple call tests in echo mode
|
|
(*echo)(call).Invoke(t, ctx, h)
|
|
}
|
|
}}, nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
client.Wait()
|
|
})
|
|
}
|
|
}
|
|
|
|
func (test notify) Name() string { return test.method }
|
|
func (test notify) Invoke(t *testing.T, ctx context.Context, h *handler) {
|
|
if err := h.conn.Notify(ctx, test.method, test.params); err != nil {
|
|
t.Fatalf("%v:Notify failed: %v", test.method, err)
|
|
}
|
|
}
|
|
|
|
func (test call) Name() string { return test.method }
|
|
func (test call) Invoke(t *testing.T, ctx context.Context, h *handler) {
|
|
results := newResults(test.expect)
|
|
if err := h.conn.Call(ctx, test.method, test.params).Await(ctx, results); err != nil {
|
|
t.Fatalf("%v:Call failed: %v", test.method, err)
|
|
}
|
|
verifyResults(t, test.method, results, test.expect)
|
|
}
|
|
|
|
func (test echo) Invoke(t *testing.T, ctx context.Context, h *handler) {
|
|
results := newResults(test.expect)
|
|
if err := h.conn.Call(ctx, "echo", []any{test.method, test.params}).Await(ctx, results); err != nil {
|
|
t.Fatalf("%v:Echo failed: %v", test.method, err)
|
|
}
|
|
verifyResults(t, test.method, results, test.expect)
|
|
}
|
|
|
|
func (test async) Name() string { return test.name }
|
|
func (test async) Invoke(t *testing.T, ctx context.Context, h *handler) {
|
|
h.calls[test.name] = h.conn.Call(ctx, test.method, test.params)
|
|
}
|
|
|
|
func (test collect) Name() string { return test.name }
|
|
func (test collect) Invoke(t *testing.T, ctx context.Context, h *handler) {
|
|
o := h.calls[test.name]
|
|
results := newResults(test.expect)
|
|
err := o.Await(ctx, results)
|
|
switch {
|
|
case test.fails && err == nil:
|
|
t.Fatalf("%v:Collect was supposed to fail", test.name)
|
|
case !test.fails && err != nil:
|
|
t.Fatalf("%v:Collect failed: %v", test.name, err)
|
|
}
|
|
verifyResults(t, test.name, results, test.expect)
|
|
}
|
|
|
|
func (test cancel) Name() string { return test.name }
|
|
func (test cancel) Invoke(t *testing.T, ctx context.Context, h *handler) {
|
|
o := h.calls[test.name]
|
|
if err := h.conn.Notify(ctx, "cancel", &cancelParams{o.ID().Raw().(int64)}); err != nil {
|
|
t.Fatalf("%v:Collect failed: %v", test.name, err)
|
|
}
|
|
}
|
|
|
|
func (test sequence) Name() string { return test.name }
|
|
func (test sequence) Invoke(t *testing.T, ctx context.Context, h *handler) {
|
|
for _, child := range test.tests {
|
|
child.Invoke(t, ctx, h)
|
|
}
|
|
}
|
|
|
|
// newResults makes a new empty copy of the expected type to put the results into
|
|
func newResults(expect any) any {
|
|
switch e := expect.(type) {
|
|
case []any:
|
|
var r []any
|
|
for _, v := range e {
|
|
r = append(r, reflect.New(reflect.TypeOf(v)).Interface())
|
|
}
|
|
return r
|
|
case nil:
|
|
return nil
|
|
default:
|
|
return reflect.New(reflect.TypeOf(expect)).Interface()
|
|
}
|
|
}
|
|
|
|
// verifyResults compares the results to the expected values
|
|
func verifyResults(t *testing.T, method string, results any, expect any) {
|
|
if expect == nil {
|
|
if results != nil {
|
|
t.Errorf("%v:Got results %+v where none expected", method, expect)
|
|
}
|
|
return
|
|
}
|
|
val := reflect.Indirect(reflect.ValueOf(results)).Interface()
|
|
if !reflect.DeepEqual(val, expect) {
|
|
t.Errorf("%v:Results are incorrect, got %+v expect %+v", method, val, expect)
|
|
}
|
|
}
|
|
|
|
func (b binder) Bind(ctx context.Context, conn *jsonrpc2.Connection) jsonrpc2.ConnectionOptions {
|
|
h := &handler{
|
|
conn: conn,
|
|
waiters: make(chan map[string]chan struct{}, 1),
|
|
calls: make(map[string]*jsonrpc2.AsyncCall),
|
|
}
|
|
h.waiters <- make(map[string]chan struct{})
|
|
if b.runTest != nil {
|
|
go b.runTest(h)
|
|
}
|
|
return jsonrpc2.ConnectionOptions{
|
|
Framer: b.framer,
|
|
Preempter: h,
|
|
Handler: h,
|
|
}
|
|
}
|
|
|
|
func (h *handler) waiter(name string) chan struct{} {
|
|
waiters := <-h.waiters
|
|
defer func() { h.waiters <- waiters }()
|
|
waiter, found := waiters[name]
|
|
if !found {
|
|
waiter = make(chan struct{})
|
|
waiters[name] = waiter
|
|
}
|
|
return waiter
|
|
}
|
|
|
|
func (h *handler) Preempt(ctx context.Context, req *jsonrpc2.Request) (any, error) {
|
|
switch req.Method {
|
|
case "unblock":
|
|
var name string
|
|
if err := json.Unmarshal(req.Params, &name); err != nil {
|
|
return nil, fmt.Errorf("%w: %s", jsonrpc2.ErrParse, err)
|
|
}
|
|
close(h.waiter(name))
|
|
return nil, nil
|
|
case "peek":
|
|
if len(req.Params) > 0 {
|
|
return nil, fmt.Errorf("%w: expected no params", jsonrpc2.ErrInvalidParams)
|
|
}
|
|
return h.accumulator, nil
|
|
case "cancel":
|
|
var params cancelParams
|
|
if err := json.Unmarshal(req.Params, ¶ms); err != nil {
|
|
return nil, fmt.Errorf("%w: %s", jsonrpc2.ErrParse, err)
|
|
}
|
|
h.conn.Cancel(jsonrpc2.Int64ID(params.ID))
|
|
return nil, nil
|
|
default:
|
|
return nil, jsonrpc2.ErrNotHandled
|
|
}
|
|
}
|
|
|
|
func (h *handler) Handle(ctx context.Context, req *jsonrpc2.Request) (any, error) {
|
|
switch req.Method {
|
|
case "no_args":
|
|
if len(req.Params) > 0 {
|
|
return nil, fmt.Errorf("%w: expected no params", jsonrpc2.ErrInvalidParams)
|
|
}
|
|
return true, nil
|
|
case "one_string":
|
|
var v string
|
|
if err := json.Unmarshal(req.Params, &v); err != nil {
|
|
return nil, fmt.Errorf("%w: %s", jsonrpc2.ErrParse, err)
|
|
}
|
|
return "got:" + v, nil
|
|
case "one_number":
|
|
var v int
|
|
if err := json.Unmarshal(req.Params, &v); err != nil {
|
|
return nil, fmt.Errorf("%w: %s", jsonrpc2.ErrParse, err)
|
|
}
|
|
return fmt.Sprintf("got:%d", v), nil
|
|
case "set":
|
|
var v int
|
|
if err := json.Unmarshal(req.Params, &v); err != nil {
|
|
return nil, fmt.Errorf("%w: %s", jsonrpc2.ErrParse, err)
|
|
}
|
|
h.accumulator = v
|
|
return nil, nil
|
|
case "add":
|
|
var v int
|
|
if err := json.Unmarshal(req.Params, &v); err != nil {
|
|
return nil, fmt.Errorf("%w: %s", jsonrpc2.ErrParse, err)
|
|
}
|
|
h.accumulator += v
|
|
return nil, nil
|
|
case "get":
|
|
if len(req.Params) > 0 {
|
|
return nil, fmt.Errorf("%w: expected no params", jsonrpc2.ErrInvalidParams)
|
|
}
|
|
return h.accumulator, nil
|
|
case "join":
|
|
var v []string
|
|
if err := json.Unmarshal(req.Params, &v); err != nil {
|
|
return nil, fmt.Errorf("%w: %s", jsonrpc2.ErrParse, err)
|
|
}
|
|
return path.Join(v...), nil
|
|
case "echo":
|
|
var v []any
|
|
if err := json.Unmarshal(req.Params, &v); err != nil {
|
|
return nil, fmt.Errorf("%w: %s", jsonrpc2.ErrParse, err)
|
|
}
|
|
var result any
|
|
err := h.conn.Call(ctx, v[0].(string), v[1]).Await(ctx, &result)
|
|
return result, err
|
|
case "wait":
|
|
var name string
|
|
if err := json.Unmarshal(req.Params, &name); err != nil {
|
|
return nil, fmt.Errorf("%w: %s", jsonrpc2.ErrParse, err)
|
|
}
|
|
select {
|
|
case <-h.waiter(name):
|
|
return true, nil
|
|
case <-ctx.Done():
|
|
return nil, ctx.Err()
|
|
}
|
|
case "fork":
|
|
var name string
|
|
if err := json.Unmarshal(req.Params, &name); err != nil {
|
|
return nil, fmt.Errorf("%w: %s", jsonrpc2.ErrParse, err)
|
|
}
|
|
waitFor := h.waiter(name)
|
|
go func() {
|
|
select {
|
|
case <-waitFor:
|
|
h.conn.Respond(req.ID, true, nil)
|
|
case <-ctx.Done():
|
|
h.conn.Respond(req.ID, nil, ctx.Err())
|
|
}
|
|
}()
|
|
return nil, jsonrpc2.ErrAsyncResponse
|
|
default:
|
|
return nil, jsonrpc2.ErrNotHandled
|
|
}
|
|
}
|