mcp: allow restricting the protocol versions a server supports (#1199)

Fixes #945
This commit is contained in:
Guglielmo Colombo 2026-08-28 16:06:10 +02:00 • committed by GitHub
commit 21c18c6229
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 397 additions and 35 deletions

View file

@ -44,6 +44,10 @@ type Server struct {
// fixed at creation
impl *Implementation
opts ServerOptions
// protocolVersions is the descending-ordered list of protocol versions
// this server advertises and negotiates: [supportedProtocolVersions],
// narrowed by [ServerOptions.SupportedProtocolVersions].
protocolVersions []string
mu sync.Mutex
prompts *featureSet[*serverPrompt]
@ -169,6 +173,23 @@ type ServerOptions struct {
// GetSessionID is not consulted when [StreamableHTTPOptions.Stateless] is
// true, since stateless servers do not maintain sessions.
GetSessionID func() string
// SupportedProtocolVersions, if non-empty, restricts the protocol versions
// this server advertises. If empty, every version returned by
// [SupportedProtocolVersions] is used.
// The list can only narrow support, never widen it.
// A request using the >= 2026-07-28 protocol at an excluded version is
// rejected with error code [CodeUnsupportedProtocolVersion].
//
// The legacy initialize handshake never rejects. The lifecycle spec
// requires the server to answer a request it does not support with a
// version it does, so an excluded version is answered with the newest
// listed version that handshake can negotiate, and the client is expected
// to disconnect when it cannot speak it. A list holding no such version
// (one restricted to 2026-07-28 and later) is answered with 2025-11-25,
// the newest handshake-era version, so that the client disconnects rather
// than reading the answer as a negotiation into the new protocol.
SupportedProtocolVersions []string
}
// NewServer creates a new MCP server. The resulting server has no features:
@ -205,6 +226,18 @@ func NewServer(impl *Implementation, options *ServerOptions) *Server {
opts.GetSessionID = rand.Text
}
protocolVersions := SupportedProtocolVersions()
if len(opts.SupportedProtocolVersions) > 0 {
for _, v := range opts.SupportedProtocolVersions {
if !slices.Contains(supportedProtocolVersions, v) {
panic(fmt.Errorf("unsupported protocol version %q", v))
}
}
protocolVersions = slices.DeleteFunc(protocolVersions, func(v string) bool {
return !slices.Contains(opts.SupportedProtocolVersions, v)
})
}
if opts.Logger == nil { // ensure we have a logger
opts.Logger = ensureLogger(nil)
}
@ -215,6 +248,7 @@ func NewServer(impl *Implementation, options *ServerOptions) *Server {
s := &Server{
impl: impl,
opts: opts,
protocolVersions: protocolVersions,
prompts: newFeatureSet(func(p *serverPrompt) string { return p.prompt.Name }),
tools: newFeatureSet(func(t *serverTool) string { return t.tool.Name }),
resources: newFeatureSet(func(r *serverResource) string { return r.resource.URI }),
@ -886,7 +920,7 @@ func (s *Server) discover(_ context.Context, req *ServerRequest[*DiscoverParams]
versions := req.Session.supportedVersions
req.Session.mu.Unlock()
if versions == nil {
versions = slices.Clone(supportedProtocolVersions)
versions = slices.Clone(s.protocolVersions)
}
// Read the request-scoped identity/capabilities before acquiring the
// session lock: these accessors may fall back to Session.InitializeParams
@ -903,7 +937,7 @@ func (s *Server) discover(_ context.Context, req *ServerRequest[*DiscoverParams]
// is never surfaced to the client via Mcp-Session-Id; leaving
// InitializeParams nil lets serveStatefulPOST's safety-net cleanup
// close it instead of leaking.
if supportedVersion := negotiateMutuallySupportedVersion(versions); supportedVersion >= protocolVersion20260728 {
if slices.ContainsFunc(versions, func(v string) bool { return v >= protocolVersion20260728 }) {
req.Session.updateState(func(state *ServerSessionState) {
state.InitializeParams = init
})
@ -917,16 +951,16 @@ func (s *Server) discover(_ context.Context, req *ServerRequest[*DiscoverParams]
return res, nil
}
// filterSupportedVersions returns the subset of [supportedProtocolVersions]
// that the Transport can serve. If t does not implement [ProtocolVersionSupporter], every
// SDK-supported version is included.
func filterSupportedVersions(t Transport) []string {
// filterSupportedVersions returns the subset of versions that the Transport
// can serve. If t does not implement [ProtocolVersionSupporter], every version
// is included.
func filterSupportedVersions(t Transport, versions []string) []string {
pvs, ok := t.(ProtocolVersionSupporter)
if !ok {
return slices.Clone(supportedProtocolVersions)
return slices.Clone(versions)
}
out := make([]string, 0, len(supportedProtocolVersions))
for _, v := range supportedProtocolVersions {
out := make([]string, 0, len(versions))
for _, v := range versions {
if pvs.SupportsProtocolVersion(v) {
out = append(out, v)
}
@ -1398,7 +1432,7 @@ func (s *Server) Connect(ctx context.Context, t Transport, opts *ServerSessionOp
// but without the lock the Go memory model gives the read goroutine no
// guarantee of seeing this write, and -race flags it.
ss.mu.Lock()
ss.supportedVersions = filterSupportedVersions(t)
ss.supportedVersions = filterSupportedVersions(t, s.protocolVersions)
ss.mu.Unlock()
// Start keepalive before returning the session to avoid race conditions with Close.
@ -1492,7 +1526,7 @@ type ServerSession struct {
mcpConn Connection
keepaliveCancel context.CancelFunc
// supportedVersions is the subset of [supportedProtocolVersions] that the
// supportedVersions is the subset of [Server.protocolVersions] that the
// transport can actually serve, computed once at connection time from
// [ProtocolVersionSupporter] (if implemented by the transport) and used by
// the SEP-2575 server/discover handler.
@ -1896,9 +1930,9 @@ func (ss *ServerSession) handle(ctx context.Context, req *jsonrpc.Request) (any,
}
if validatedMeta.usesNewProtocol &&
!slices.Contains(supportedProtocolVersions, validatedMeta.initializeParams.ProtocolVersion) {
!slices.Contains(ss.server.protocolVersions, validatedMeta.initializeParams.ProtocolVersion) {
data, _ := json.Marshal(UnsupportedProtocolVersionData{
Supported: supportedProtocolVersions,
Supported: ss.server.protocolVersions,
Requested: validatedMeta.initializeParams.ProtocolVersion,
})
return nil, &jsonrpc.Error{
@ -2021,12 +2055,17 @@ func (ss *ServerSession) initialize(ctx context.Context, params *InitializeParam
if params == nil {
return nil, fmt.Errorf("%w: \"params\" must be be provided", jsonrpc2.ErrInvalidParams)
}
var wasInit bool
var (
wasInit bool
negotiated string
)
ss.updateState(func(state *ServerSessionState) {
wasInit = state.InitializeParams != nil
if !wasInit {
state.InitializeParams = params
state.NegotiatedProtocolVersion = negotiatedVersion(params.ProtocolVersion, ss.server.protocolVersions)
}
negotiated = state.NegotiatedProtocolVersion
})
if wasInit {
ss.server.opts.Logger.Error("duplicate initialize request")
@ -2037,7 +2076,7 @@ func (ss *ServerSession) initialize(ctx context.Context, params *InitializeParam
return &InitializeResult{
// TODO(rfindley): alter behavior when falling back to an older version:
// reject unsupported features.
ProtocolVersion: negotiatedVersion(params.ProtocolVersion),
ProtocolVersion: negotiated,
Capabilities: s.capabilities(),
Instructions: s.opts.Instructions,
ServerInfo: s.impl,

View file

@ -1691,3 +1691,164 @@ func TestServerSession_RejectsServerInitiated(t *testing.T) {
}
}
}
// TestServerSupportedProtocolVersions verifies that
// [ServerOptions.SupportedProtocolVersions] narrows both the versions the
// server advertises in server/discover and the versions it negotiates,
// and that it cannot widen SDK support.
func TestServerSupportedProtocolVersions(t *testing.T) {
ctx := context.Background()
t.Run("discover advertises the configured subset", func(t *testing.T) {
server := NewServer(testImpl, &ServerOptions{
// Deliberately unordered: the server must advertise newest first.
SupportedProtocolVersions: []string{protocolVersion20251125, protocolVersion20260728},
})
var advertised []string
server.AddReceivingMiddleware(func(next MethodHandler) MethodHandler {
return func(ctx context.Context, method string, req Request) (Result, error) {
res, err := next(ctx, method, req)
if method == methodDiscover && err == nil {
advertised = res.(*DiscoverResult).SupportedVersions
}
return res, err
}
})
ct, st := NewInMemoryTransports()
ss, err := server.Connect(ctx, st, nil)
if err != nil {
t.Fatalf("server.Connect: %v", err)
}
defer ss.Close()
cs, err := NewClient(testImpl, nil).Connect(ctx, ct, &ClientSessionOptions{
ProtocolVersion: protocolVersion20260728,
})
if err != nil {
t.Fatalf("client.Connect: %v", err)
}
defer cs.Close()
want := []string{protocolVersion20260728, protocolVersion20251125}
if diff := cmp.Diff(want, advertised); diff != "" {
t.Errorf("DiscoverResult.SupportedVersions mismatch (-want +got):\n%s", diff)
}
})
t.Run("initialize negotiates within the configured subset", func(t *testing.T) {
tests := []struct {
name string
supported []string
client string
want string
}{
{
name: "requested version supported",
supported: []string{protocolVersion20251125, protocolVersion20250618},
client: protocolVersion20250618,
want: protocolVersion20250618,
},
{
name: "requested version excluded",
supported: []string{protocolVersion20251125, protocolVersion20250618},
client: protocolVersion20241105,
want: protocolVersion20251125,
},
{
name: "fallback is the newest configured version",
supported: []string{protocolVersion20250326},
client: protocolVersion20241105,
want: protocolVersion20250326,
},
{
// Nothing the server supports can carry an initialize
// handshake, so the answer names a handshake-era version the
// client can act on rather than one it could mistake for the
// new protocol.
name: "server with no handshake version",
supported: []string{protocolVersion20260728},
client: protocolVersion20250618,
want: protocolVersion20251125,
},
{
name: "unrestricted server keeps the default behavior",
supported: nil,
client: protocolVersion20241105,
want: protocolVersion20241105,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
server := NewServer(testImpl, &ServerOptions{SupportedProtocolVersions: test.supported})
ct, st := NewInMemoryTransports()
ss, err := server.Connect(ctx, st, nil)
if err != nil {
t.Fatalf("server.Connect: %v", err)
}
defer ss.Close()
cs, err := NewClient(testImpl, nil).Connect(ctx, ct, &ClientSessionOptions{
ProtocolVersion: test.client,
})
if err != nil {
t.Fatalf("client.Connect: %v", err)
}
defer cs.Close()
if got := cs.InitializeResult().ProtocolVersion; got != test.want {
t.Errorf("negotiated protocol version = %q, want %q", got, test.want)
}
})
}
})
t.Run("unknown version panics", func(t *testing.T) {
defer func() {
if recover() == nil {
t.Error("NewServer did not panic on an unsupported protocol version")
}
}()
NewServer(testImpl, &ServerOptions{SupportedProtocolVersions: []string{"1999-01-01"}})
})
}
// TestServerSupportedProtocolVersions_NewProtocol verifies that a request
// using the SEP-2575 per-request protocol is rejected with
// [CodeUnsupportedProtocolVersion] when its version is excluded by
// [ServerOptions.SupportedProtocolVersions].
func TestServerSupportedProtocolVersions_NewProtocol(t *testing.T) {
ctx := context.Background()
server := NewServer(testImpl, &ServerOptions{
SupportedProtocolVersions: []string{protocolVersion20251125},
})
ct, st := NewInMemoryTransports()
ss, err := server.Connect(ctx, st, nil)
if err != nil {
t.Fatalf("server.Connect: %v", err)
}
defer ss.Close()
_ = ct
params := fmt.Sprintf(`{"_meta":{%q:%q,%q:{}}}`,
MetaKeyProtocolVersion, protocolVersion20260728, MetaKeyClientCapabilities)
_, err = ss.handle(ctx, &jsonrpc.Request{
ID: jsonrpc2.Int64ID(1),
Method: methodListTools,
Params: json.RawMessage(params),
})
var jerr *jsonrpc.Error
if !errors.As(err, &jerr) {
t.Fatalf("handle returned %v, want a *jsonrpc.Error", err)
}
if jerr.Code != CodeUnsupportedProtocolVersion {
t.Fatalf("error code = %d, want %d", jerr.Code, CodeUnsupportedProtocolVersion)
}
var data UnsupportedProtocolVersionData
if err := json.Unmarshal(jerr.Data, &data); err != nil {
t.Fatalf("unmarshal error data: %v", err)
}
if diff := cmp.Diff([]string{protocolVersion20251125}, data.Supported); diff != "" {
t.Errorf("UnsupportedProtocolVersionData.Supported mismatch (-want +got):\n%s", diff)
}
}

View file

@ -22,6 +22,13 @@ type ServerSessionState struct {
// InitializedParams are the parameters from 'notifications/initialized'.
InitializedParams *InitializedParams `json:"initializedParams"`
// NegotiatedProtocolVersion is the protocol version agreed during
// 'initialize', which may differ from the version the client requested if
// the server does not support it.
//
// It is empty for sessions that never ran the initialize handshake.
NegotiatedProtocolVersion string `json:"negotiatedProtocolVersion,omitempty"`
// LogLevel is the logging level for the session.
LogLevel LoggingLevel `json:"logLevel"`

View file

@ -63,18 +63,38 @@ var supportedProtocolVersions = []string{
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.
func negotiatedVersion(clientVersion string) string {
// In general, prefer to use the clientVersion, but if we don't support the
// client's version, use the latest version.
// 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(supportedProtocolVersions, clientVersion) && clientVersion < 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
}

View file

@ -24,7 +24,7 @@ import (
// stdio and Streamable HTTP (see #1112).
func TestSSEServerTransport_SupportedVersions(t *testing.T) {
var tr SSEServerTransport
versions := filterSupportedVersions(&tr)
versions := filterSupportedVersions(&tr, supportedProtocolVersions)
if slices.Contains(versions, protocolVersion20260728) {
t.Errorf("filterSupportedVersions(SSEServerTransport) = %v, must not include %q",
versions, protocolVersion20260728)

View file

@ -363,13 +363,21 @@ func (h *StreamableHTTPHandler) ServeHTTP(w http.ResponseWriter, req *http.Reque
}
// [§2.7] of the spec (2025-06-18): validate the MCP-Protocol-Version
// header. If provided, it must be a supported version. If absent, the
// version is unknown (the request may be an initialize for any version).
// header. If provided, it must be a version the server negotiates, which
// [ServerOptions.SupportedProtocolVersions] may have narrowed. If absent,
// the version is unknown (the request may be an initialize for any
// version).
//
// [§2.7]: https://modelcontextprotocol.io/specification/2025-06-18/basic/transports#protocol-version-header
protocolVersion := req.Header.Get(protocolVersionHeader)
if protocolVersion != "" && !slices.Contains(supportedProtocolVersions, protocolVersion) && protocolVersion < protocolVersion20260728 {
http.Error(w, fmt.Sprintf("Bad Request: Unsupported protocol version (supported versions: %s)", strings.Join(supportedProtocolVersions, ",")), http.StatusBadRequest)
var supportedVersions []string
if server := h.getServer(req); server != nil {
supportedVersions = server.protocolVersions
} else {
supportedVersions = supportedProtocolVersions
}
if protocolVersion != "" && !slices.Contains(supportedVersions, protocolVersion) && protocolVersion < protocolVersion20260728 {
http.Error(w, fmt.Sprintf("Bad Request: Unsupported protocol version (supported versions: %s)", strings.Join(supportedVersions, ",")), http.StatusBadRequest)
return
}
req = req.WithContext(context.WithValue(req.Context(), protocolVersionContextKey{}, protocolVersion))
@ -1541,7 +1549,11 @@ func (c *streamableServerConn) servePOST(w http.ResponseWriter, req *http.Reques
// Advertise only the legacy versions this stateful server accepts
// (i.e. exclude 2026-07-28, since that's what the request asked for
// and what we're rejecting).
legacyVersions := slices.DeleteFunc(slices.Clone(supportedProtocolVersions), func(v string) bool {
advertised := supportedProtocolVersions
if c.server != nil {
advertised = c.server.protocolVersions
}
legacyVersions := slices.DeleteFunc(slices.Clone(advertised), func(v string) bool {
return v >= protocolVersion20260728
})
data, _ := json.Marshal(UnsupportedProtocolVersionData{

View file

@ -4141,3 +4141,80 @@ func TestStreamableMaxRequestBodyBytes(t *testing.T) {
})
}
}
// TestStreamableSupportedProtocolVersions_Header verifies that the
// MCP-Protocol-Version header is validated against
// [ServerOptions.SupportedProtocolVersions], and not merely against the
// versions the SDK knows about.
func TestStreamableSupportedProtocolVersions_Header(t *testing.T) {
const initBody = `{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}`
newServer := func(t *testing.T, stateless bool) *httptest.Server {
server := NewServer(testImpl, &ServerOptions{
SupportedProtocolVersions: []string{protocolVersion20251125},
})
handler := NewStreamableHTTPHandler(func(*http.Request) *Server { return server },
&StreamableHTTPOptions{Stateless: stateless})
httpServer := httptest.NewServer(handler)
t.Cleanup(httpServer.Close)
return httpServer
}
post := func(t *testing.T, url, version, sessionID string) *http.Response {
t.Helper()
req, err := http.NewRequest(http.MethodPost, url, strings.NewReader(initBody))
if err != nil {
t.Fatal(err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json, text/event-stream")
req.Header.Set(protocolVersionHeader, version)
if sessionID != "" {
req.Header.Set(sessionIDHeader, sessionID)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
return resp
}
for _, stateless := range []bool{false, true} {
for _, test := range []struct {
version string
wantStatus int
}{
{protocolVersion20251125, http.StatusOK},
// Supported by the SDK, but excluded by the server.
{protocolVersion20250618, http.StatusBadRequest},
// Unknown to the SDK: rejected before any server is consulted.
{"1999-01-01", http.StatusBadRequest},
} {
t.Run(fmt.Sprintf("stateless=%v/%s", stateless, test.version), func(t *testing.T) {
resp := post(t, newServer(t, stateless).URL, test.version, "")
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode != test.wantStatus {
t.Errorf("status = %d, want %d; body = %s", resp.StatusCode, test.wantStatus, body)
}
})
}
}
t.Run("existing session", func(t *testing.T) {
httpServer := newServer(t, false)
resp := post(t, httpServer.URL, protocolVersion20251125, "")
io.Copy(io.Discard, resp.Body)
resp.Body.Close()
sessionID := resp.Header.Get(sessionIDHeader)
if sessionID == "" {
t.Fatalf("initialize response missing %s header", sessionIDHeader)
}
resp = post(t, httpServer.URL, protocolVersion20250618, sessionID)
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusBadRequest {
t.Errorf("status = %d, want %d; body = %s", resp.StatusCode, http.StatusBadRequest, body)
}
})
}

View file

@ -114,6 +114,8 @@ type clientConnection interface {
// TODO: should this interface be exported?
type serverConnection interface {
Connection
// sessionUpdated is called whenever the server session state changes.
sessionUpdated(ServerSessionState)
}
@ -525,17 +527,13 @@ func newIOConn(rwc io.ReadWriteCloser) *ioConn {
func (c *ioConn) SessionID() string { return "" }
func (c *ioConn) sessionUpdated(state ServerSessionState) {
protocolVersion := ""
if state.InitializeParams != nil {
protocolVersion = state.InitializeParams.ProtocolVersion
}
protocolVersion := state.NegotiatedProtocolVersion
if protocolVersion == "" {
// 2025-03-26 is used, because it's the last spec version
// where specifying the protocol version in the HTTP header
// was not required.
protocolVersion = protocolVersion20250326
}
protocolVersion = negotiatedVersion(protocolVersion)
c.sessionMu.Lock()
c.protocolVersion = protocolVersion
c.sessionMu.Unlock()

View file

@ -56,10 +56,15 @@ func TestBatchFraming(t *testing.T) {
func TestIOConnRead(t *testing.T) {
tests := []struct {
name string
input string
want string
name string
input string
want string
// protocolVersion is the version negotiated by 'initialize'; empty
// means no session state was pushed to the connection.
protocolVersion string
// requested is the version the client asked for, which differs from
// protocolVersion when the server counter-offered.
requested string
}{
{
name: "valid json input",
@ -100,6 +105,16 @@ func TestIOConnRead(t *testing.T) {
want: "JSON-RPC batching is not supported in 2025-06-18 and later (request version: 2025-06-18)",
protocolVersion: protocolVersion20250618,
},
{
// The client asked for a version the server does not negotiate, so
// the connection must follow the server's counter-offer (which
// forbids batching) rather than the client's request.
name: "batching at a counter-offered version",
input: `[{"jsonrpc":"2.0","id":1,"method":"test1"},{"jsonrpc":"2.0","id":2,"method":"test2"}]`,
want: "JSON-RPC batching is not supported in 2025-06-18 and later (request version: 2025-11-25)",
requested: protocolVersion20241105,
protocolVersion: protocolVersion20251125,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
@ -108,10 +123,15 @@ func TestIOConnRead(t *testing.T) {
})
t.Cleanup(func() { tr.Close() })
if tt.protocolVersion != "" {
requested := tt.requested
if requested == "" {
requested = tt.protocolVersion
}
tr.sessionUpdated(ServerSessionState{
InitializeParams: &InitializeParams{
ProtocolVersion: tt.protocolVersion,
ProtocolVersion: requested,
},
NegotiatedProtocolVersion: tt.protocolVersion,
})
}
_, err := tr.Read(context.Background())