Implement the concurrency model described in #26: notifications are synchronous, but calls are asynchronous (except for 'initialize'). To achieve this, implement jsonrpc2.Async(ctx) to signal asynchronous handling. This is simpler to use than returning ErrAsyncResponse and calling Respond, and since this is an internal detail we don't need to worry too much about whether it's idiomatic. Add tests that verify both features, for both client and server. Also: - replace req.ID.IsValid with req.IsCall - remove the methodHandler type as we can just use MethodHandler Fixes #26
502 lines
16 KiB
Go
502 lines
16 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.
|
|
|
|
// This file contains code shared between client and server, including
|
|
// method handler and middleware definitions.
|
|
// TODO: much of this is here so that we can factor out commonalities using
|
|
// generics. Perhaps it can be simplified with reflection.
|
|
|
|
package mcp
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"log"
|
|
"reflect"
|
|
"slices"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/modelcontextprotocol/go-sdk/internal/jsonrpc2"
|
|
"github.com/modelcontextprotocol/go-sdk/jsonrpc"
|
|
)
|
|
|
|
// latestProtocolVersion is the latest protocol version that this version of the SDK supports.
|
|
// It is the version that the client sends in the initialization request.
|
|
const latestProtocolVersion = "2025-06-18"
|
|
|
|
var supportedProtocolVersions = []string{
|
|
latestProtocolVersion,
|
|
"2025-03-26",
|
|
"2024-11-05",
|
|
}
|
|
|
|
// A MethodHandler handles MCP messages.
|
|
// For methods, exactly one of the return values must be nil.
|
|
// For notifications, both must be nil.
|
|
type MethodHandler func(ctx context.Context, method string, req Request) (result Result, err error)
|
|
|
|
// A Session is either a [ClientSession] or a [ServerSession].
|
|
type Session interface {
|
|
// ID returns the session ID, or the empty string if there is none.
|
|
ID() string
|
|
|
|
sendingMethodInfos() map[string]methodInfo
|
|
receivingMethodInfos() map[string]methodInfo
|
|
sendingMethodHandler() MethodHandler
|
|
receivingMethodHandler() MethodHandler
|
|
getConn() *jsonrpc2.Connection
|
|
}
|
|
|
|
// Middleware is a function from [MethodHandler] to [MethodHandler].
|
|
type Middleware func(MethodHandler) MethodHandler
|
|
|
|
// addMiddleware wraps the handler in the middleware functions.
|
|
func addMiddleware(handlerp *MethodHandler, middleware []Middleware) {
|
|
for _, m := range slices.Backward(middleware) {
|
|
*handlerp = m(*handlerp)
|
|
}
|
|
}
|
|
|
|
func defaultSendingMethodHandler[S Session](ctx context.Context, method string, req Request) (Result, error) {
|
|
info, ok := req.GetSession().sendingMethodInfos()[method]
|
|
if !ok {
|
|
// This can be called from user code, with an arbitrary value for method.
|
|
return nil, jsonrpc2.ErrNotHandled
|
|
}
|
|
// Notifications don't have results.
|
|
if strings.HasPrefix(method, "notifications/") {
|
|
return nil, req.GetSession().getConn().Notify(ctx, method, req.GetParams())
|
|
}
|
|
// Create the result to unmarshal into.
|
|
// The concrete type of the result is the return type of the receiving function.
|
|
res := info.newResult()
|
|
if err := call(ctx, req.GetSession().getConn(), method, req.GetParams(), res); err != nil {
|
|
return nil, err
|
|
}
|
|
return res, nil
|
|
}
|
|
|
|
// Helper method to avoid typed nil.
|
|
func orZero[T any, P *U, U any](p P) T {
|
|
if p == nil {
|
|
var zero T
|
|
return zero
|
|
}
|
|
return any(p).(T)
|
|
}
|
|
|
|
func handleNotify(ctx context.Context, method string, req Request) error {
|
|
mh := req.GetSession().sendingMethodHandler()
|
|
_, err := mh(ctx, method, req)
|
|
return err
|
|
}
|
|
|
|
func handleSend[R Result](ctx context.Context, method string, req Request) (R, error) {
|
|
mh := req.GetSession().sendingMethodHandler()
|
|
// mh might be user code, so ensure that it returns the right values for the jsonrpc2 protocol.
|
|
res, err := mh(ctx, method, req)
|
|
if err != nil {
|
|
var z R
|
|
return z, err
|
|
}
|
|
return res.(R), nil
|
|
}
|
|
|
|
// defaultReceivingMethodHandler is the initial MethodHandler for servers and clients, before being wrapped by middleware.
|
|
func defaultReceivingMethodHandler[S Session](ctx context.Context, method string, req Request) (Result, error) {
|
|
info, ok := req.GetSession().receivingMethodInfos()[method]
|
|
if !ok {
|
|
// This can be called from user code, with an arbitrary value for method.
|
|
return nil, jsonrpc2.ErrNotHandled
|
|
}
|
|
return info.handleMethod(ctx, method, req)
|
|
}
|
|
|
|
func handleReceive[S Session](ctx context.Context, session S, jreq *jsonrpc.Request) (Result, error) {
|
|
info, err := checkRequest(jreq, session.receivingMethodInfos())
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
params, err := info.unmarshalParams(jreq.Params)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("handling '%s': %w", jreq.Method, err)
|
|
}
|
|
|
|
mh := session.receivingMethodHandler()
|
|
req := info.newRequest(session, params)
|
|
// mh might be user code, so ensure that it returns the right values for the jsonrpc2 protocol.
|
|
res, err := mh(ctx, jreq.Method, req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return res, nil
|
|
}
|
|
|
|
// checkRequest checks the given request against the provided method info, to
|
|
// ensure it is a valid MCP request.
|
|
//
|
|
// If valid, the relevant method info is returned. Otherwise, a non-nil error
|
|
// is returned describing why the request is invalid.
|
|
//
|
|
// This is extracted from request handling so that it can be called in the
|
|
// transport layer to preemptively reject bad requests.
|
|
func checkRequest(req *jsonrpc.Request, infos map[string]methodInfo) (methodInfo, error) {
|
|
info, ok := infos[req.Method]
|
|
if !ok {
|
|
return methodInfo{}, fmt.Errorf("%w: %q unsupported", jsonrpc2.ErrNotHandled, req.Method)
|
|
}
|
|
if info.flags¬ification != 0 && req.IsCall() {
|
|
return methodInfo{}, fmt.Errorf("%w: unexpected id for %q", jsonrpc2.ErrInvalidRequest, req.Method)
|
|
}
|
|
if info.flags¬ification == 0 && !req.IsCall() {
|
|
return methodInfo{}, fmt.Errorf("%w: missing id for %q", jsonrpc2.ErrInvalidRequest, req.Method)
|
|
}
|
|
// missingParamsOK is checked here to catch the common case where "params" is
|
|
// missing entirely.
|
|
//
|
|
// However, it's checked again after unmarshalling to catch the rare but
|
|
// possible case where "params" is JSON null (see https://go.dev/issue/33835).
|
|
if info.flags&missingParamsOK == 0 && len(req.Params) == 0 {
|
|
return methodInfo{}, fmt.Errorf("%w: missing required \"params\"", jsonrpc2.ErrInvalidRequest)
|
|
}
|
|
return info, nil
|
|
}
|
|
|
|
// methodInfo is information about sending and receiving a method.
|
|
type methodInfo struct {
|
|
// flags is a collection of flags controlling how the JSONRPC method is
|
|
// handled. See individual flag values for documentation.
|
|
flags methodFlags
|
|
// Unmarshal params from the wire into a Params struct.
|
|
// Used on the receive side.
|
|
unmarshalParams func(json.RawMessage) (Params, error)
|
|
newRequest func(Session, Params) Request
|
|
// Run the code when a call to the method is received.
|
|
// Used on the receive side.
|
|
handleMethod MethodHandler
|
|
// Create a pointer to a Result struct.
|
|
// Used on the send side.
|
|
newResult func() Result
|
|
}
|
|
|
|
// The following definitions support converting from typed to untyped method handlers.
|
|
// Type parameter meanings:
|
|
// - S: sessions
|
|
// - P: params
|
|
// - R: results
|
|
|
|
// A typedMethodHandler is like a MethodHandler, but with type information.
|
|
type (
|
|
typedClientMethodHandler[P Params, R Result] func(context.Context, *ClientRequest[P]) (R, error)
|
|
typedServerMethodHandler[P Params, R Result] func(context.Context, *ServerRequest[P]) (R, error)
|
|
)
|
|
|
|
type paramsPtr[T any] interface {
|
|
*T
|
|
Params
|
|
}
|
|
|
|
type methodFlags int
|
|
|
|
const (
|
|
notification methodFlags = 1 << iota // method is a notification, not request
|
|
missingParamsOK // params may be missing or null
|
|
)
|
|
|
|
func newClientMethodInfo[P paramsPtr[T], R Result, T any](d typedClientMethodHandler[P, R], flags methodFlags) methodInfo {
|
|
mi := newMethodInfo[P, R](flags)
|
|
mi.newRequest = func(s Session, p Params) Request {
|
|
r := &ClientRequest[P]{Session: s.(*ClientSession)}
|
|
if p != nil {
|
|
r.Params = p.(P)
|
|
}
|
|
return r
|
|
}
|
|
mi.handleMethod = MethodHandler(func(ctx context.Context, _ string, req Request) (Result, error) {
|
|
return d(ctx, req.(*ClientRequest[P]))
|
|
})
|
|
return mi
|
|
}
|
|
|
|
func newServerMethodInfo[P paramsPtr[T], R Result, T any](d typedServerMethodHandler[P, R], flags methodFlags) methodInfo {
|
|
mi := newMethodInfo[P, R](flags)
|
|
mi.newRequest = func(s Session, p Params) Request {
|
|
r := &ServerRequest[P]{Session: s.(*ServerSession)}
|
|
if p != nil {
|
|
r.Params = p.(P)
|
|
}
|
|
return r
|
|
}
|
|
mi.handleMethod = MethodHandler(func(ctx context.Context, _ string, req Request) (Result, error) {
|
|
rf := &ServerRequest[P]{Session: req.GetSession().(*ServerSession)}
|
|
if req.GetParams() != nil {
|
|
rf.Params = req.GetParams().(P)
|
|
}
|
|
return d(ctx, rf)
|
|
})
|
|
return mi
|
|
}
|
|
|
|
// newMethodInfo creates a methodInfo from a typedMethodHandler.
|
|
//
|
|
// If isRequest is set, the method is treated as a request rather than a
|
|
// notification.
|
|
func newMethodInfo[P paramsPtr[T], R Result, T any](flags methodFlags) methodInfo {
|
|
return methodInfo{
|
|
flags: flags,
|
|
unmarshalParams: func(m json.RawMessage) (Params, error) {
|
|
var p P
|
|
if m != nil {
|
|
if err := json.Unmarshal(m, &p); err != nil {
|
|
return nil, fmt.Errorf("unmarshaling %q into a %T: %w", m, p, err)
|
|
}
|
|
}
|
|
// We must check missingParamsOK here, in addition to checkRequest, to
|
|
// catch the edge cases where "params" is set to JSON null.
|
|
// See also https://go.dev/issue/33835.
|
|
//
|
|
// We need to ensure that p is non-null to guard against crashes, as our
|
|
// internal code or externally provided handlers may assume that params
|
|
// is non-null.
|
|
if flags&missingParamsOK == 0 && p == nil {
|
|
return nil, fmt.Errorf("%w: missing required \"params\"", jsonrpc2.ErrInvalidRequest)
|
|
}
|
|
return orZero[Params](p), nil
|
|
},
|
|
// newResult is used on the send side, to construct the value to unmarshal the result into.
|
|
// R is a pointer to a result struct. There is no way to "unpointer" it without reflection.
|
|
// TODO(jba): explore generic approaches to this, perhaps by treating R in
|
|
// the signature as the unpointered type.
|
|
newResult: func() Result { return reflect.New(reflect.TypeFor[R]().Elem()).Interface().(R) },
|
|
}
|
|
}
|
|
|
|
// serverMethod is glue for creating a typedMethodHandler from a method on Server.
|
|
func serverMethod[P Params, R Result](
|
|
f func(*Server, context.Context, *ServerRequest[P]) (R, error),
|
|
) typedServerMethodHandler[P, R] {
|
|
return func(ctx context.Context, req *ServerRequest[P]) (R, error) {
|
|
return f(req.Session.server, ctx, req)
|
|
}
|
|
}
|
|
|
|
// clientMethod is glue for creating a typedMethodHandler from a method on Client.
|
|
func clientMethod[P Params, R Result](
|
|
f func(*Client, context.Context, *ClientRequest[P]) (R, error),
|
|
) typedClientMethodHandler[P, R] {
|
|
return func(ctx context.Context, req *ClientRequest[P]) (R, error) {
|
|
return f(req.Session.client, ctx, req)
|
|
}
|
|
}
|
|
|
|
// serverSessionMethod is glue for creating a typedServerMethodHandler from a method on ServerSession.
|
|
func serverSessionMethod[P Params, R Result](f func(*ServerSession, context.Context, P) (R, error)) typedServerMethodHandler[P, R] {
|
|
return func(ctx context.Context, req *ServerRequest[P]) (R, error) {
|
|
return f(req.GetSession().(*ServerSession), ctx, req.Params)
|
|
}
|
|
}
|
|
|
|
// clientSessionMethod is glue for creating a typedMethodHandler from a method on ServerSession.
|
|
func clientSessionMethod[P Params, R Result](f func(*ClientSession, context.Context, P) (R, error)) typedClientMethodHandler[P, R] {
|
|
return func(ctx context.Context, req *ClientRequest[P]) (R, error) {
|
|
return f(req.GetSession().(*ClientSession), ctx, req.Params)
|
|
}
|
|
}
|
|
|
|
// Error codes
|
|
const (
|
|
// TODO: should these be unexported?
|
|
|
|
CodeResourceNotFound = -32002
|
|
// The error code if the method exists and was called properly, but the peer does not support it.
|
|
CodeUnsupportedMethod = -31001
|
|
)
|
|
|
|
// notifySessions calls Notify on all the sessions.
|
|
// Should be called on a copy of the peer sessions.
|
|
func notifySessions[S Session, P Params](sessions []S, method string, params P) {
|
|
if sessions == nil {
|
|
return
|
|
}
|
|
// TODO: make this timeout configurable, or call Notify asynchronously.
|
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
defer cancel()
|
|
for _, s := range sessions {
|
|
req := newRequest(s, params)
|
|
if err := handleNotify(ctx, method, req); err != nil {
|
|
// TODO(jba): surface this error better
|
|
log.Printf("calling %s: %v", method, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func newRequest[S Session, P Params](s S, p P) Request {
|
|
switch s := any(s).(type) {
|
|
case *ClientSession:
|
|
return &ClientRequest[P]{Session: s, Params: p}
|
|
case *ServerSession:
|
|
return &ServerRequest[P]{Session: s, Params: p}
|
|
default:
|
|
panic("bad session")
|
|
}
|
|
}
|
|
|
|
// Meta is additional metadata for requests, responses and other types.
|
|
type Meta map[string]any
|
|
|
|
// GetMeta returns metadata from a value.
|
|
func (m Meta) GetMeta() map[string]any { return m }
|
|
|
|
// SetMeta sets the metadata on a value.
|
|
func (m *Meta) SetMeta(x map[string]any) { *m = x }
|
|
|
|
const progressTokenKey = "progressToken"
|
|
|
|
func getProgressToken(p Params) any {
|
|
return p.GetMeta()[progressTokenKey]
|
|
}
|
|
|
|
func setProgressToken(p Params, pt any) {
|
|
switch pt.(type) {
|
|
// Support int32 and int64 for atomic.IntNN.
|
|
case int, int32, int64, string:
|
|
default:
|
|
panic(fmt.Sprintf("progress token %v is of type %[1]T, not int or string", pt))
|
|
}
|
|
m := p.GetMeta()
|
|
if m == nil {
|
|
m = map[string]any{}
|
|
}
|
|
m[progressTokenKey] = pt
|
|
}
|
|
|
|
// A Request is a method request with parameters and additional information, such as the session.
|
|
// Request is implemented by [*ClientRequest] and [*ServerRequest].
|
|
type Request interface {
|
|
isRequest()
|
|
GetSession() Session
|
|
GetParams() Params
|
|
}
|
|
|
|
// A ClientRequest is a request to a client.
|
|
type ClientRequest[P Params] struct {
|
|
Session *ClientSession
|
|
Params P
|
|
}
|
|
|
|
// A ServerRequest is a request to a server.
|
|
type ServerRequest[P Params] struct {
|
|
Session *ServerSession
|
|
Params P
|
|
}
|
|
|
|
func (*ClientRequest[P]) isRequest() {}
|
|
func (*ServerRequest[P]) isRequest() {}
|
|
|
|
func (r *ClientRequest[P]) GetSession() Session { return r.Session }
|
|
func (r *ServerRequest[P]) GetSession() Session { return r.Session }
|
|
|
|
func (r *ClientRequest[P]) GetParams() Params { return r.Params }
|
|
func (r *ServerRequest[P]) GetParams() Params { return r.Params }
|
|
|
|
func serverRequestFor[P Params](s *ServerSession, p P) *ServerRequest[P] {
|
|
return &ServerRequest[P]{Session: s, Params: p}
|
|
}
|
|
|
|
func clientRequestFor[P Params](s *ClientSession, p P) *ClientRequest[P] {
|
|
return &ClientRequest[P]{Session: s, Params: p}
|
|
}
|
|
|
|
// Params is a parameter (input) type for an MCP call or notification.
|
|
type Params interface {
|
|
// isParams discourages implementation of Params outside of this package.
|
|
isParams()
|
|
|
|
// GetMeta returns metadata from a value.
|
|
GetMeta() map[string]any
|
|
// SetMeta sets the metadata on a value.
|
|
SetMeta(map[string]any)
|
|
}
|
|
|
|
// RequestParams is a parameter (input) type for an MCP request.
|
|
type RequestParams interface {
|
|
Params
|
|
|
|
// GetProgressToken returns the progress token from the params' Meta field, or nil
|
|
// if there is none.
|
|
GetProgressToken() any
|
|
|
|
// SetProgressToken sets the given progress token into the params' Meta field.
|
|
// It panics if its argument is not an int or a string.
|
|
SetProgressToken(any)
|
|
}
|
|
|
|
// Result is a result of an MCP call.
|
|
type Result interface {
|
|
// isResult discourages implementation of Result outside of this package.
|
|
isResult()
|
|
|
|
// GetMeta returns metadata from a value.
|
|
GetMeta() map[string]any
|
|
// SetMeta sets the metadata on a value.
|
|
SetMeta(map[string]any)
|
|
}
|
|
|
|
// emptyResult is returned by methods that have no result, like ping.
|
|
// Those methods cannot return nil, because jsonrpc2 cannot handle nils.
|
|
type emptyResult struct{}
|
|
|
|
func (*emptyResult) isResult() {}
|
|
func (*emptyResult) GetMeta() map[string]any { panic("should never be called") }
|
|
func (*emptyResult) SetMeta(map[string]any) { panic("should never be called") }
|
|
|
|
type listParams interface {
|
|
// Returns a pointer to the param's Cursor field.
|
|
cursorPtr() *string
|
|
}
|
|
|
|
type listResult[T any] interface {
|
|
// Returns a pointer to the param's NextCursor field.
|
|
nextCursorPtr() *string
|
|
}
|
|
|
|
// keepaliveSession represents a session that supports keepalive functionality.
|
|
type keepaliveSession interface {
|
|
Ping(ctx context.Context, params *PingParams) error
|
|
Close() error
|
|
}
|
|
|
|
// startKeepalive starts the keepalive mechanism for a session.
|
|
// It assigns the cancel function to the provided cancelPtr and starts a goroutine
|
|
// that sends ping messages at the specified interval.
|
|
func startKeepalive(session keepaliveSession, interval time.Duration, cancelPtr *context.CancelFunc) {
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
// Assign cancel function before starting goroutine to avoid race condition.
|
|
// We cannot return it because the caller may need to cancel during the
|
|
// window between goroutine scheduling and function return.
|
|
*cancelPtr = cancel
|
|
|
|
go func() {
|
|
ticker := time.NewTicker(interval)
|
|
defer ticker.Stop()
|
|
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-ticker.C:
|
|
pingCtx, pingCancel := context.WithTimeout(context.Background(), interval/2)
|
|
err := session.Ping(pingCtx, nil)
|
|
pingCancel()
|
|
if err != nil {
|
|
// Ping failed, close the session
|
|
_ = session.Close()
|
|
return
|
|
}
|
|
}
|
|
}
|
|
}()
|
|
}
|