go-sdk/mcp/shared.go
José M. Requena Plens 3f43bcf012
mcp: add HasParams so middleware can tell whether params are present (#1269)
This adds `mcp.HasParams(req Request) bool`, so a receiving middleware
can ask whether the message it was handed carried a `params` member.

The member is optional for several methods: `serverMethodInfos` sets
`missingParamsOK` for `server/discover`, `ping`, the four list methods
and the `cancelled`, `initialized` and `roots/list_changed`
notifications, so `{"jsonrpc":"2.0","id":1,"method":"tools/list"}` is a
complete request. For one of those, `unmarshalParams` returns a nil
`Params`, `newRequest` leaves `ServerRequest.Params` at the zero value
of `P`, and `GetParams` hands the middleware a non-nil interface holding
a typed nil pointer. Comparing that against nil says params are present,
and the first method called on it dereferences: `GetMeta` is promoted
from the embedded `Meta`. `Params` already declares `isNil` for
precisely this case, and it is unexported, so from outside the package
the accessor named after the question is the one thing that cannot be
reached.

`HasParams` is a function over `Request` rather than an exported method
on `Params` for two reasons. A middleware holds a `Request`, and this
way the caller never has to hold the value that panics. More
importantly, `isNil` cannot answer the question for every `Params`
value, so exporting it would ship a predicate that is itself unsafe: a
params type declared the documented way for a custom method, `struct{
mcp.ParamsBase }`, promotes `isNil` through a field selector, and that
dereferences the nil outer pointer before the method body runs. The
predicate therefore has to be computed from outside the value, and
`reflect.Value.IsNil` is what works for all of them.

Three judgement calls, stated so nobody has to find them.

First, `getRequestMeta` now goes through `HasParams`. That is a second
fix rather than a refactor: `AddReceivingCustomMethod` registers with
`missingParamsOK` too, so a client omitting `params` for a custom method
made `ServerRequest.ProtocolVersion`, `ClientInfo` and
`ClientCapabilities` panic in the promoted `isNil`. They now return
`""`, `nil` and `nil`. I could have left it out to keep the diff to one
function, but then the SDK would export a predicate that says to ask
before touching params while three of its own accessors still panicked
on the same value. If you would rather see it as a separate commit, say
so and I will split it.

Second, I did not add the predicate to the `Request` interface.
`Request` is sealed by `isRequest`, so a method would have been a
compatible change and would read slightly better at the call site, but a
free function leaves the interface alone and serves every `Request` just
as well.

Third, this reflects once per call of `getRequestMeta`. It is one
`reflect.ValueOf` on a value that is already an interface, on a path
where `newResult` already reflects per request.

Tests, both in `mcp/shared_test.go`.
`TestServerRequest_PerRequestAccessors_MissingParams` is the one that
fails before the change and passes after: on `main` it panics inside
`getRequestMeta` with `invalid memory address or nil pointer
dereference` from `(*customParams).isNil`, and here it asserts the three
accessors return zero values. `TestHasParams` covers the exported
behaviour: it builds exactly what `handleReceive` hands the middleware
chain for `tools/list`, `prompts/list`, `resources/list` and
`notifications/initialized` with no `params` member, asserts that
`GetParams` still returns a non-nil interface so the case under test is
still the typed-nil one, asserts `HasParams` reports false, and asserts
it reports true when params are present.

Behaviour an existing user would see change: only the three accessors
above, and only for a custom-method params type built on `ParamsBase`
whose `params` member was omitted, where a panic becomes a zero value.
Nothing about the wire format changes, no exported signature moves, and
no interface gains a method. A middleware that still writes
`req.GetParams() != nil` and then dereferences still panics; this gives
a caller the way to ask, it does not rescue a caller that does not.

Also here: one sentence on the doc comments of both
`AddReceivingMiddleware` methods saying a received message need not
carry params, since that is where a middleware author is reading, and
`examples/server/middleware/main.go` now logs `has_params` with
`mcp.HasParams` instead of the comparison that cannot work. The
example's printed output is unchanged, because the SDK client always
sends a `params` member for the methods it drives.

`go test ./...` and `gofmt -l .` are clean.

One thing to say plainly, since it is yours to decide and not mine: this
adds an exported symbol. `HasParams` is a new name in the public API of
`mcp`, and everything else here is doc comments, an example and the
three accessors. If you would rather not grow the surface for this, the
smaller answer is to keep the `getRequestMeta` fix and the doc-comment
sentence and drop the exported function, and a middleware author then
has the guarantee written down where they are reading even though they
still cannot ask the question in code. I prefer the function because the
guarantee alone leaves every middleware writing the same three lines of
`reflect`, and the one that does not write them panics on an ordinary
`tools/list`, but I will cut it to whichever shape you want.


Fixes #1261.

Co-authored-by: Guglielmo Colombo <guglielmoc@google.com>
2026-09-14 18:45:48 +02:00

973 lines
34 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.
//
// Much of this is here so that we can factor out commonalities using
// generics. If this becomes unwieldy, it can perhaps be simplified with
// reflection.
package mcp
import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"net/http"
"reflect"
"slices"
"strings"
"time"
"github.com/modelcontextprotocol/go-sdk/auth"
internaljson "github.com/modelcontextprotocol/go-sdk/internal/json"
"github.com/modelcontextprotocol/go-sdk/internal/jsonrpc2"
"github.com/modelcontextprotocol/go-sdk/internal/mcpgodebug"
"github.com/modelcontextprotocol/go-sdk/jsonrpc"
)
// nowrapinvalidparams is a compatibility parameter that restores the previous
// behavior of [methodInfo.unmarshalParams]. When unset (the default), a
// params-decoding failure is wrapped with [jsonrpc2.ErrInvalidParams] so the
// wire response carries error code -32602 ("invalid params") rather than the
// zero-value code 0. See:
// https://github.com/modelcontextprotocol/go-sdk/issues/976#issuecomment-4829124838.
//
// See the documentation for the mcpgodebug package for instructions how to enable it.
// The option will be removed in a future version of the SDK.
var nowrapinvalidparams = mcpgodebug.Value("nowrapinvalidparams")
const (
// 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, and
// the default version used by the server.
latestProtocolVersion = protocolVersion20260728
protocolVersion20260728 = "2026-07-28"
protocolVersion20251125 = "2025-11-25"
protocolVersion20250618 = "2025-06-18"
protocolVersion20250326 = "2025-03-26"
protocolVersion20241105 = "2024-11-05"
)
var supportedProtocolVersions = []string{
protocolVersion20260728,
protocolVersion20251125,
protocolVersion20250618,
protocolVersion20250326,
protocolVersion20241105,
}
// SupportedProtocolVersions returns the protocol versions supported by this
// version of the SDK, newest first.
//
// Use it to discover the values accepted by
// [ServerOptions.SupportedProtocolVersions]. The returned slice is a copy:
// modifying it does not change what the SDK supports.
func SupportedProtocolVersions() []string { return slices.Clone(supportedProtocolVersions) }
// negotiatedVersion returns the effective protocol version to use, given a
// client version and the versions the server supports, newest first.
func negotiatedVersion(clientVersion string, supported []string) string {
// In general, prefer to use the clientVersion, but if not supported
// by the server, use the latest version the server supports.
//
// Cap the supported versions at the legacy protocolVersion20251125, as this
// method is used by the initialize method which is deprecated in
// version protocolVersion20260728.
if slices.Contains(supported, clientVersion) && clientVersion < protocolVersion20260728 {
return clientVersion
}
for _, v := range supported {
if v < protocolVersion20260728 {
return v
}
}
// If the server was enforced to support only protocolVersion20260728 and
// later, the initialize function should not be called.
// If a client calls the initialize function, return the protocolVersion20251125.
// As according to spec (https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle#version-negotiation):
// "If the server supports the requested protocol version, it MUST respond with the same version."
// "Otherwise, the server MUST respond with another protocol version it supports."
// "This SHOULD be the latest version supported by the server."
return protocolVersion20251125
}
// negotiateMutuallySupportedVersion returns a protocol version that is supported
// by both the client and the server.
func negotiateMutuallySupportedVersion(supported []string) string {
for _, ver := range supportedProtocolVersions {
if slices.Contains(supported, ver) {
return ver
}
}
return ""
}
// 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(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
}
params := req.GetParams()
if initParams, ok := params.(*InitializeParams); ok {
// Fix the marshaling of initialize params, to work around #607.
//
// The initialize params we produce should never be nil, nor have nil
// capabilities, so any panic here is a bug.
params = initParams.toV2()
}
// Notifications don't have results.
if strings.HasPrefix(method, "notifications/") {
return nil, req.GetSession().getConn().Notify(ctx, method, params)
}
// Create the result to unmarshal into.
// The concrete type of the result is the return type of the receiving function.
res := info.newResult()
if method == methodSubscriptionsListen {
callSubscriptionsListen(ctx, req.GetSession().getConn(), method, params)
} else {
if err := call(ctx, req.GetSession().getConn(), method, params, 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()
re, _ := jreq.Extra.(*RequestExtra)
req := info.newRequest(session, params, re)
// 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&notification != 0 && req.IsCall() {
return methodInfo{}, fmt.Errorf("%w: unexpected id for %q", jsonrpc2.ErrInvalidRequest, req.Method)
}
if info.flags&notification == 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, *RequestExtra) 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, _ *RequestExtra) 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, re *RequestExtra) Request {
r := &ServerRequest[P]{Session: s.(*ServerSession), Extra: re}
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.(*ServerRequest[P]))
})
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 := internaljson.Unmarshal(m, &p); err != nil {
// Legacy behavior: pre-fix versions surfaced this as a
// plain wrapped error, which caused the wire response to
// carry code 0 instead of -32602. Restore via
// MCPGODEBUG=nowrapinvalidparams=1.
if nowrapinvalidparams == "1" {
return nil, fmt.Errorf("unmarshaling %q into a %T: %w", m, p, err)
}
// Wrap jsonrpc2.ErrInvalidParams so toWireError surfaces
// code -32602 ("invalid params") while preserving the
// descriptive message.
return nil, fmt.Errorf("%w: unmarshaling %q into a %T: %w", jsonrpc2.ErrInvalidParams, 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)
}
}
// MCP-specific error codes.
const (
// CodeHeaderMismatch indicates that HTTP headers do not match the corresponding values
// in the request body, or that required headers are missing or malformed.
CodeHeaderMismatch = -32020
// CodeMissingRequiredClientCapabilities is the JSON-RPC error code defined by
// SEP-2575 for MissingRequiredClientCapabilitiesError.
CodeMissingRequiredClientCapabilities = -32021
// CodeUnsupportedProtocolVersion is the JSON-RPC error code defined by
// SEP-2575 for UnsupportedProtocolVersionError.
CodeUnsupportedProtocolVersion = -32022
// CodeURLElicitationRequired indicates that the server requires URL elicitation
// before processing the request. The client should execute the elicitation handler
// with the elicitations provided in the error data.
CodeURLElicitationRequired = -32042
)
// CodeResourceNotFound indicates that a requested resource could not be found.
//
// By default, the value is -32602 (Invalid Params), as specified in the
// MCP specification (SEP-2164). To restore the pre-1.7.0 release behavior where the
// error code was -32002, set MCPGODEBUG=customresnotfounderrcode=1.
//
// Deprecated: Use [jsonrpc.CodeInvalidParams] directly. This variable will be
// removed in a future version.
var CodeResourceNotFound int64 = jsonrpc.CodeInvalidParams
// URLElicitationRequiredError returns an error indicating that URL elicitation is required
// before the request can be processed. The elicitations parameter should contain the
// elicitation requests that must be completed.
func URLElicitationRequiredError(elicitations []*ElicitParams) error {
// Validate that all elicitations are URL mode
for _, elicit := range elicitations {
mode := elicit.Mode
if mode == "" {
mode = "form" // default mode
}
if mode != "url" {
panic(fmt.Sprintf("URLElicitationRequiredError requires all elicitations to be URL mode, got %q", mode))
}
}
data, err := json.Marshal(map[string]any{
"elicitations": elicitations,
})
if err != nil {
// This should never happen with valid ElicitParams
panic(fmt.Sprintf("failed to marshal elicitations: %v", err))
}
return &jsonrpc.Error{
Code: CodeURLElicitationRequired,
Message: "URL elicitation required",
Data: json.RawMessage(data),
}
}
// Internal error codes
const (
// The error code if the method exists and was called properly, but the peer does not support it.
//
// TODO(rfindley): this code is wrong, and we should fix it to be
// consistent with other SDKs.
codeUnsupportedMethod = -31001
)
// notifySessions calls Notify on all the sessions.
// Should be called on a copy of the peer sessions.
// The logger must be non-nil.
func notifySessions[S Session, P Params](sessions []S, method string, params P, logger *slog.Logger) {
if sessions == nil {
return
}
// Notify with the background context, so the messages are sent on the
// standalone stream.
// TODO: make this timeout configurable, or call handleNotify asynchronously.
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
// TODO: there's a potential spec violation here, when the feature list
// changes before the session (client or server) is initialized.
for _, s := range sessions {
req := newRequest(s, params)
if err := handleNotify(ctx, method, req); err != nil {
logger.Warn(fmt.Sprintf("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{}
p.SetMeta(m)
}
m[progressTokenKey] = pt
}
// extractRequestMeta performs a lightweight partial unmarshal of the `_meta`
// field from a JSON-RPC request's raw params.
func extractRequestMeta(rawParams json.RawMessage) Meta {
if len(rawParams) == 0 {
return nil
}
var meta struct {
Meta Meta `json:"_meta"`
}
if err := internaljson.Unmarshal(rawParams, &meta); err != nil {
return nil
}
return meta.Meta
}
type validatedMeta struct {
usesNewProtocol bool
initializeParams *InitializeParams
logLevel LoggingLevel
}
// validateRequestMeta inspects a JSON-RPC request to detect whether it follows
// the >= 2026-07-28 protocol via the `_meta` field.
// If the request has no _meta, no protocolVersion in _meta, or a protocolVersion
// naming a supported version that predates 2026-07-28, it returns a non-nil
// validatedMeta with usesNewProtocol set to false, and a nil error: such a request
// belongs to the initialize handshake.
// If the protocolVersion names a version that is not supported at all, it returns
// usesNewProtocol set to true with initializeParams carrying only that version, so
// that the caller answers with [CodeUnsupportedProtocolVersion]. The rest of the
// `_meta` triple is not validated in that case, since the revision defining it is
// unknown.
// Otherwise, it validates the presence of clientCapabilities in _meta. If it is
// missing or invalid, it returns nil and a non-nil error. clientInfo is optional;
// if present but invalid, an error is returned. Otherwise, it returns
// usesNewProtocol set to true and the populated initializeParams.
// Notifications always report usesNewProtocol false and never error.
func validateRequestMeta(req *jsonrpc.Request) (*validatedMeta, error) {
// SEP-2575 defines the `_meta` triple for calls only: NotificationParams
// declares `_meta` optional with no protocolVersion, so a notification
// neither selects the new protocol nor is discarded for carrying an
// incomplete triple.
if !req.IsCall() {
return &validatedMeta{usesNewProtocol: false, initializeParams: nil}, nil
}
meta := extractRequestMeta(req.Params)
if meta == nil {
return &validatedMeta{usesNewProtocol: false, initializeParams: nil}, nil
}
protocolVersion, ok := meta[MetaKeyProtocolVersion].(string)
// An absent or empty value declares no version at all, which is also how the
// streamable handler reads `_meta.protocolVersion`.
if !ok || protocolVersion == "" {
return &validatedMeta{usesNewProtocol: false, initializeParams: nil}, nil
}
// Classify by membership rather than by ordering: a version the SDK does not
// know sorts arbitrarily against 2026-07-28, and one that sorts below it would
// be served as a legacy handshake instead of reaching the
// CodeUnsupportedProtocolVersion check in ServerSession.handle. The
// streamable handler already applies the same membership rule to the
// Mcp-Protocol-Version header.
supported := slices.Contains(supportedProtocolVersions, protocolVersion)
if supported && protocolVersion < protocolVersion20260728 {
return &validatedMeta{usesNewProtocol: false, initializeParams: nil}, nil
}
if !supported {
return &validatedMeta{usesNewProtocol: true, initializeParams: &InitializeParams{
ProtocolVersion: protocolVersion,
}}, nil
}
var clientInfo *Implementation
if _, present := meta[MetaKeyClientInfo]; present {
var ok bool
clientInfo, ok = decodeMetaValue[*Implementation](meta, MetaKeyClientInfo)
if !ok {
return nil, &jsonrpc.Error{
Code: jsonrpc.CodeInvalidParams,
Message: fmt.Sprintf("invalid _meta field %q", MetaKeyClientInfo),
}
}
}
capabilities, ok := decodeMetaValue[*clientCapabilitiesV2](meta, MetaKeyClientCapabilities)
if !ok {
return nil, &jsonrpc.Error{
Code: jsonrpc.CodeInvalidParams,
Message: fmt.Sprintf("missing or invalid _meta field %q", MetaKeyClientCapabilities),
}
}
logLevel, _ := decodeMetaValue[LoggingLevel](meta, MetaKeyLogLevel)
return &validatedMeta{usesNewProtocol: true, initializeParams: &InitializeParams{
ProtocolVersion: protocolVersion,
Capabilities: capabilities.toV1(),
ClientInfo: clientInfo,
}, logLevel: logLevel}, nil
}
// 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
// GetExtra returns the Extra field for ServerRequests, and nil for ClientRequests.
GetExtra() *RequestExtra
}
// 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
Extra *RequestExtra
}
// RequestExtra is extra information included in requests, typically from
// the transport layer.
type RequestExtra struct {
TokenInfo *auth.TokenInfo // bearer token info (e.g. from OAuth) if any
Header http.Header // header from HTTP request, if any
// If set, CloseSSEStream explicitly closes the current SSE request stream.
//
// [SEP-1699] introduced server-side SSE stream disconnection: for
// long-running requests, servers may opt to close the SSE stream and
// ask the client to retry at a later time. CloseSSEStream implements this
// feature; if RetryAfter is set, an event is sent with a `retry:` field
// to configure the reconnection delay.
//
// [SEP-1699]: https://github.com/modelcontextprotocol/modelcontextprotocol/issues/1699
// This mechanism is deprecated in protocol version 2026-07-28 as the resumability
// feature is removed.
CloseSSEStream func(CloseSSEStreamArgs)
}
// CloseSSEStreamArgs are arguments for [RequestExtra.CloseSSEStream].
type CloseSSEStreamArgs struct {
// RetryAfter configures the reconnection delay sent to the client via the
// SSE retry field. If zero, no retry field is sent.
RetryAfter time.Duration
}
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 (r *ClientRequest[P]) GetExtra() *RequestExtra { return nil }
func (r *ServerRequest[P]) GetExtra() *RequestExtra { return r.Extra }
// HasParams reports whether req carries params.
//
// The "params" member of a message may be omitted for several methods, among
// them the list methods, ping and notifications/initialized. For such a request
// [Request.GetParams] returns a non-nil [Params] holding a nil pointer, so
// comparing its result against nil reports that params are present, while
// calling a method on that result panics. Middleware that inspects params must
// ask this first:
//
// if mcp.HasParams(req) {
// log.Println(method, req.GetParams().GetMeta())
// }
func HasParams(req Request) bool {
params := req.GetParams()
if params == nil {
return false
}
// isNil cannot answer this for every value: a params type embedding
// ParamsBase promotes isNil through a field selector, which dereferences
// the nil outer pointer before the method body runs.
v := reflect.ValueOf(params)
return v.Kind() != reflect.Pointer || !v.IsNil()
}
// ProtocolVersion returns the protocol version negotiated for this request.
//
// For requests following the >= 2026-07-28 protocol, the value is read from
// the per-request `_meta` field. For older protocol requests, the value falls
// back to the session-level [InitializeParams] established during the
// initialize handshake.
func (r *ServerRequest[P]) ProtocolVersion() string {
if m := getRequestMeta(r); m != nil {
if v, ok := m[MetaKeyProtocolVersion].(string); ok {
return v
}
}
if r.Session != nil {
if p := r.Session.InitializeParams(); p != nil {
return p.ProtocolVersion
}
}
return ""
}
// ClientInfo returns the [Implementation] identifying the calling client.
//
// For requests following the >= 2026-07-28 protocol, the value is read from
// the per-request `_meta` field. For older protocol requests, the value falls
// back to the session-level [InitializeParams].
func (r *ServerRequest[P]) ClientInfo() *Implementation {
if m := getRequestMeta(r); m != nil {
if v, ok := decodeMetaValue[*Implementation](m, MetaKeyClientInfo); ok {
return v
}
}
if r.Session != nil {
if p := r.Session.InitializeParams(); p != nil {
return p.ClientInfo
}
}
return nil
}
// ClientCapabilities returns the [ClientCapabilities] of the calling client.
//
// For requests following the >= 2026-07-28 protocol, the value is read from
// the per-request `_meta` field. For older protocol requests, the value falls
// back to the session-level [InitializeParams].
func (r *ServerRequest[P]) ClientCapabilities() *ClientCapabilities {
if m := getRequestMeta(r); m != nil {
if v, ok := decodeMetaValue[*clientCapabilitiesV2](m, MetaKeyClientCapabilities); ok {
return v.toV1()
}
}
if r.Session != nil {
if p := r.Session.InitializeParams(); p != nil {
return p.Capabilities
}
}
return nil
}
// getRequestMeta returns the raw `_meta` map from the request's params, or
// nil if the params are absent.
func getRequestMeta[P Params](r *ServerRequest[P]) map[string]any {
if !HasParams(r) {
return nil
}
return r.Params.GetMeta()
}
// decodeMetaValue decodes a typed value out of a `_meta` map. Values may
// arrive either as the typed Go value (when constructed in-process) or as
// the generic JSON map produced by encoding/json after wire transit. In the
// latter case, the value is re-encoded and decoded into the target type.
func decodeMetaValue[T any](m map[string]any, key string) (T, bool) {
var zero T
raw, ok := m[key]
if !ok || raw == nil {
return zero, false
}
if v, ok := raw.(T); ok {
return v, true
}
var v T
if err := remarshal(raw, &v); err != nil {
return zero, false
}
return v, true
}
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 {
// GetMeta returns metadata from a value.
GetMeta() map[string]any
// SetMeta sets the metadata on a value.
SetMeta(map[string]any)
// isParams discourages implementation of Params outside of this package.
isParams()
// isNil returns true if the underlying value is nil.
isNil() bool
}
// ParamsBase can be embedded in custom parameter structs to satisfy the
// [Params] interface. It provides the required [Meta] field and the unexported
// isParams marker method.
//
// type SearchParams struct {
// mcp.ParamsBase
// Query string `json:"query"`
// }
type ParamsBase struct {
Meta `json:"_meta,omitempty"`
}
func (*ParamsBase) isParams() {}
func (p *ParamsBase) isNil() bool { return p == nil }
// 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()
// isNil returns true if the underlying value is nil.
isNil() bool
// GetMeta returns metadata from a value.
GetMeta() map[string]any
// SetMeta sets the metadata on a value.
SetMeta(map[string]any)
}
// ResultBase can be embedded in custom result structs to satisfy the
// [Result] interface. It provides the required [Meta] field and the unexported
// isResult marker method.
//
// type SearchResult struct {
// mcp.ResultBase
// Hits []string `json:"hits"`
// }
type ResultBase struct {
Meta `json:"_meta,omitempty"`
}
func (*ResultBase) isResult() {}
func (x *ResultBase) isNil() bool { return x == nil }
// 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 (x *emptyResult) isNil() bool { return x == nil }
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.
//
// failureThreshold is the number of consecutive ping failures tolerated before
// the session is closed; a value below 1 is treated as 1 (close on the first
// failure). A successful ping resets the counter. This mirrors the spec's
// "multiple failed pings MAY trigger a connection reset" language, letting a
// transient miss pass without tearing down an otherwise live session.
//
// logger must be non-nil; ping failures (both the tolerated ones and the final
// one that closes the session) are reported via logger so they are not silently
// dropped.
func startKeepalive(session keepaliveSession, interval time.Duration, failureThreshold int, cancelPtr *context.CancelFunc, logger *slog.Logger) {
if failureThreshold < 1 {
failureThreshold = 1
}
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()
consecutiveFailures := 0
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 {
consecutiveFailures = 0
continue
}
if errors.Is(err, jsonrpc2.ErrMethodNotFound) {
// Peer doesn't support ping, stop the keepalive process.
return
}
consecutiveFailures++
if consecutiveFailures < failureThreshold {
// Tolerate transient failures below the threshold; log so
// the misses are still observable to operators. See #218.
logger.Warn("keepalive ping failed; tolerating below threshold",
"error", err,
"consecutiveFailures", consecutiveFailures,
"failureThreshold", failureThreshold)
continue
}
// Threshold reached; log before closing the session so the
// failure is observable to operators. See #218.
logger.Error("keepalive ping failed; closing session",
"error", err,
"consecutiveFailures", consecutiveFailures,
"failureThreshold", failureThreshold)
_ = session.Close()
return
}
}
}()
}