Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions docs/protocol.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,20 @@ request. Servers implementing `2026-07-28` MUST implement it.
fails or the server does not support the latest version, the client falls back to the
legacy `initialize` handshake.

A server advertises and negotiates every protocol version the SDK supports;
set [`ServerOptions.SupportedProtocolVersions`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ServerOptions)
to narrow that set, for example to stop serving a revision your deployment has
retired.

```go
server := mcp.NewServer(&mcp.Implementation{Name: "server", Version: "v1.0.0"}, &mcp.ServerOptions{
SupportedProtocolVersions: []string{"2026-07-28", "2025-11-25"},
})
```

The set can only narrow support, never widen it; the field's documentation
covers how each protocol era answers a request at an excluded version.

### Per-request metadata keys

When the negotiated protocol version is `2026-07-28` or later, every request
Expand Down
14 changes: 14 additions & 0 deletions internal/docs/protocol.src.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,20 @@ request. Servers implementing `2026-07-28` MUST implement it.
fails or the server does not support the latest version, the client falls back to the
legacy `initialize` handshake.

A server advertises and negotiates every protocol version the SDK supports;
set [`ServerOptions.SupportedProtocolVersions`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ServerOptions)
to narrow that set, for example to stop serving a revision your deployment has
retired.

```go
server := mcp.NewServer(&mcp.Implementation{Name: "server", Version: "v1.0.0"}, &mcp.ServerOptions{
SupportedProtocolVersions: []string{"2026-07-28", "2025-11-25"},
})
```

The set can only narrow support, never widen it; the field's documentation
covers how each protocol era answers a request at an excluded version.

### Per-request metadata keys

When the negotiated protocol version is `2026-07-28` or later, every request
Expand Down
69 changes: 54 additions & 15 deletions mcp/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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)
}
Expand All @@ -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 }),
Expand Down Expand Up @@ -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
Expand All @@ -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
})
Expand All @@ -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)
}
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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{
Expand Down Expand Up @@ -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")
Expand All @@ -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,
Expand Down
161 changes: 161 additions & 0 deletions mcp/server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
7 changes: 7 additions & 0 deletions mcp/session.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`

Expand Down
Loading
Loading