mcp: Implement retry on advised supportedVersions in UnsupportedProtocolVersion error (#989)

## Description

This PR introduces a retry mechanism for the discover method according
to SEP-2575 (Issue #966)
This commit is contained in:
Guglielmo Colombo 2026-06-08 15:09:07 +02:00 • committed by GitHub
commit d9714c61f1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 172 additions and 34 deletions

View file

@ -290,18 +290,41 @@ func (c *Client) Connect(ctx context.Context, t Transport, opts *ClientSessionOp
// signals it doesn't support it, fall back to the legacy initialize
// handshake.
discoverCtx := context.WithValue(ctx, protocolVersionContextKey{}, protocolVersion)
discRes, fallback, err := c.discover(discoverCtx, cs)
if err != nil {
// We try to discover the server's capabilities. If the server rejects the
// requested version but specifies which versions it supports, we negotiate
// a mutually supported version and try again.
for range 2 {
discRes, err := c.discover(discoverCtx, cs)
if err == nil {
cs.state.InitializeResult = discRes
if hc, ok := cs.mcpConn.(clientConnection); ok {
hc.sessionUpdated(cs.state)
}
return cs, nil
}
var werr *jsonrpc.Error
if !errors.As(err, &werr) {
return nil, err
}
// Try to negotiate a mutually supported version if the server
// reports an UnsupportedProtocolVersionError with a supported version.
if werr.Code == CodeUnsupportedProtocolVersion && werr.Data != nil {
var data UnsupportedProtocolVersionData
if err := json.Unmarshal(werr.Data, &data); err == nil {
if negotiatedVersion := negotiateMutuallySupportedVersion(data.Supported); negotiatedVersion != "" && negotiatedVersion >= protocolVersion20260630 {
discoverCtx = context.WithValue(ctx, protocolVersionContextKey{}, negotiatedVersion)
continue
}
}
}
// MethodNotFound and UnsupportedProtocolVersion trigger a fallback to legacy initialize.
if werr.Code == jsonrpc.CodeMethodNotFound || werr.Code == CodeUnsupportedProtocolVersion {
break
}
return nil, err
}
if !fallback {
cs.state.InitializeResult = discRes
if hc, ok := cs.mcpConn.(clientConnection); ok {
hc.sessionUpdated(cs.state)
}
return cs, nil
}
// Fallback to the legacy initialize handshake.
// Fallback to the legacy initialize handshake with the legacy protocol version.
protocolVersion = protocolVersion20251125
}
@ -338,15 +361,7 @@ func (c *Client) Connect(ctx context.Context, t Transport, opts *ClientSessionOp
// discover sends a SEP-2575 server/discover request to probe the server for
// stateless protocol support.
//
// The return values have three possible combinations:
// - (result, false, nil): discovery succeeded; caller should skip legacy initialization.
// - (nil, true, nil): the server explicitly signaled it doesn't support
// discovery (Method not found, or UnsupportedProtocolVersionError, or version mismatch);
// caller should fall back to the legacy initialize handshake.
// - (nil, false, err): any other failure (transport error, malformed response, etc.);
// caller should propagate the error.
func (c *Client) discover(ctx context.Context, cs *ClientSession) (*InitializeResult, bool, error) {
func (c *Client) discover(ctx context.Context, cs *ClientSession) (*InitializeResult, error) {
protocolVersion := protocolVersionFromContext(ctx)
caps := c.capabilities(protocolVersion)
params := &DiscoverParams{
@ -359,13 +374,7 @@ func (c *Client) discover(ctx context.Context, cs *ClientSession) (*InitializeRe
req := &DiscoverRequest{Session: cs, Params: params}
res, err := handleSend[*DiscoverResult](ctx, methodDiscover, req)
if err != nil {
// According to SEP-2575, only the two signals below (MethodNotFound
// and UnsupportedProtocolVersionError) should trigger a fallback.
var werr *jsonrpc.Error
if errors.As(err, &werr) && (werr.Code == jsonrpc.CodeMethodNotFound || werr.Code == CodeUnsupportedProtocolVersion) {
return nil, true, nil
}
return nil, false, err
return nil, err
}
// Pick the highest protocol version that both the server and this SDK support.
@ -375,17 +384,12 @@ func (c *Client) discover(ctx context.Context, cs *ClientSession) (*InitializeRe
if slices.Contains(res.SupportedVersions, protocolVersion) {
negotiated = protocolVersion
} else {
for _, v := range supportedProtocolVersions {
if slices.Contains(res.SupportedVersions, v) {
negotiated = v
break
}
}
negotiated = negotiateMutuallySupportedVersion(res.SupportedVersions)
}
if negotiated == "" || negotiated < protocolVersion20260630 {
// If there is no overlap, fall back to initialize so version
// negotiation can happen via the legacy path.
return nil, true, nil
return nil, jsonrpc2.ErrUnsupportedProtocolVersion
}
return &InitializeResult{
@ -393,7 +397,7 @@ func (c *Client) discover(ctx context.Context, cs *ClientSession) (*InitializeRe
Instructions: res.Instructions,
ProtocolVersion: negotiated,
ServerInfo: res.ServerInfo,
}, false, nil
}, nil
}
// A ClientSession is a logical connection with an MCP server. Its

View file

@ -6,6 +6,7 @@ package mcp
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"sync/atomic"
@ -841,3 +842,114 @@ func TestClientConnectDiscover_RequestContents(t *testing.T) {
t.Errorf("clientCapabilities.sampling missing (CreateMessageHandler was set); got %v", caps)
}
}
// If the server does not support the requested version, it returns an
// UnsupportedProtocolVersionError containing its list of supported
// versions. The client selects a mutually supported version from the list
// and retries.
func TestClientConnectDiscover_UnsupportedVersionNegotiation(t *testing.T) {
// Temporarily enable 2026-06-30 support in the SDK for this test so it
// is a candidate during negotiation.
oldSupported := supportedProtocolVersions
supportedProtocolVersions = append([]string{protocolVersion20260630}, supportedProtocolVersions...)
t.Cleanup(func() {
supportedProtocolVersions = oldSupported
})
ctx := context.Background()
const (
unsupportedClientVersion = "2099-12-31"
serverNegotiatedVersion = protocolVersion20260630
)
var (
discoverCalls atomic.Int32
gotInitialize atomic.Bool
firstRequestedVersion atomic.Value // string
secondRequestedVersion atomic.Value // string
)
s := NewServer(testImpl, nil)
s.AddReceivingMiddleware(func(next MethodHandler) MethodHandler {
return func(ctx context.Context, method string, req Request) (Result, error) {
switch method {
case methodDiscover:
sr, ok := req.(*ServerRequest[*DiscoverParams])
if !ok {
t.Errorf("discover req has unexpected type %T", req)
return nil, jsonrpc2.ErrMethodNotFound
}
requested, _ := sr.Params.GetMeta()[MetaKeyProtocolVersion].(string)
n := discoverCalls.Add(1)
switch n {
case 1:
firstRequestedVersion.Store(requested)
data, err := json.Marshal(UnsupportedProtocolVersionData{
Supported: []string{serverNegotiatedVersion},
Requested: requested,
})
if err != nil {
t.Fatalf("marshal error data: %v", err)
}
return nil, &jsonrpc.Error{
Code: CodeUnsupportedProtocolVersion,
Message: "unsupported protocol version",
Data: data,
}
case 2:
secondRequestedVersion.Store(requested)
return &DiscoverResult{
SupportedVersions: []string{serverNegotiatedVersion},
Capabilities: &ServerCapabilities{
Tools: &ToolCapabilities{ListChanged: true},
},
ServerInfo: &Implementation{Name: "discoverServer", Version: "v1.0.0"},
}, nil
default:
t.Errorf("unexpected discover call #%d", n)
return nil, jsonrpc2.ErrMethodNotFound
}
case methodInitialize:
gotInitialize.Store(true)
}
return next(ctx, method, req)
}
})
ct, st := NewInMemoryTransports()
ss, err := s.Connect(ctx, st, nil)
if err != nil {
t.Fatalf("server Connect: %v", err)
}
defer ss.Close()
c := NewClient(testImpl, nil)
cs, err := c.Connect(ctx, ct, &ClientSessionOptions{protocolVersion: unsupportedClientVersion})
if err != nil {
t.Fatalf("Connect: %v", err)
}
defer cs.Close()
if got, want := discoverCalls.Load(), int32(2); got != want {
t.Errorf("server/discover call count = %d, want %d", got, want)
}
if got, _ := firstRequestedVersion.Load().(string); got != unsupportedClientVersion {
t.Errorf("first discover requested version = %q, want %q", got, unsupportedClientVersion)
}
if got, _ := secondRequestedVersion.Load().(string); got != serverNegotiatedVersion {
t.Errorf("retry discover requested version = %q, want %q (server's advertised supported version)", got, serverNegotiatedVersion)
}
if gotInitialize.Load() {
t.Error("legacy initialize handshake ran, but negotiated discover should have succeeded")
}
ir := cs.InitializeResult()
if ir == nil {
t.Fatal("InitializeResult is nil after Connect")
}
if got, want := ir.ProtocolVersion, serverNegotiatedVersion; got != want {
t.Errorf("InitializeResult.ProtocolVersion = %q, want %q", got, want)
}
}

View file

@ -2103,3 +2103,14 @@ const (
// MetaKeyClientCapabilities carries the client's [ClientCapabilities].
MetaKeyClientCapabilities = "io.modelcontextprotocol/clientCapabilities"
)
// UnsupportedProtocolVersionData is the SEP-2575 payload carried in the
// `data` field of a JSON-RPC error response with code
// [CodeUnsupportedProtocolVersion]. The server uses it to advertise which
// versions it supports so the client can pick a mutually supported one.
type UnsupportedProtocolVersionData struct {
// Supported is the list of protocol versions the server supports.
Supported []string `json:"supported"`
// Requested is the protocol version the client asked for.
Requested string `json:"requested"`
}

View file

@ -64,6 +64,17 @@ func negotiatedVersion(clientVersion string) string {
return clientVersion
}
// 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.