Skip to content
Merged
16 changes: 16 additions & 0 deletions mcp/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -2048,6 +2048,22 @@ func (ss *ServerSession) handle(ctx context.Context, req *jsonrpc.Request) (any,
}
}

// A server that supports only 2026-07-28 and later serves no legacy call,
// initialize included.
if !validatedMeta.usesNewProtocol && req.IsCall() &&
!slices.ContainsFunc(ss.server.protocolVersions, func(v string) bool {
return v < protocolVersion20260728
}) {
data, _ := json.Marshal(UnsupportedProtocolVersionData{
Supported: ss.server.protocolVersions,
})
return nil, &jsonrpc.Error{
Code: CodeUnsupportedProtocolVersion,
Message: "legacy protocol version requests are not supported by this server",
Data: data,
}
}

switch req.Method {
case methodInitialize, methodPing, notificationInitialized, notificationRootsListChanged, methodSetLevel, methodSubscribe, methodUnsubscribe:
if validatedMeta.usesNewProtocol {
Expand Down
98 changes: 87 additions & 11 deletions mcp/server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2128,16 +2128,6 @@ func TestServerSupportedProtocolVersions(t *testing.T) {
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,
Expand Down Expand Up @@ -2220,7 +2210,93 @@ func TestServerSupportedProtocolVersions_NewProtocol(t *testing.T) {
}
}

// TestNotifySessionsIsolatesStalledPeer verifies that a session whose write
// TestServerSupportedProtocolVersions_InitializeLegacy verifies that a legacy
// initialize request is rejected with [CodeUnsupportedProtocolVersion], naming
// the supported versions, when [ServerOptions.SupportedProtocolVersions]
// leaves the server no version that has an initialize handshake.
func TestServerSupportedProtocolVersions_InitializeLegacy(t *testing.T) {
ctx := context.Background()
server := NewServer(testImpl, &ServerOptions{
SupportedProtocolVersions: []string{protocolVersion20260728},
})
ct, st := NewInMemoryTransports()
ss, err := server.Connect(ctx, st, nil)
if err != nil {
t.Fatalf("server.Connect: %v", err)
}
defer ss.Close()
_ = ct

params, err := json.Marshal(&InitializeParams{
ProtocolVersion: protocolVersion20251125,
Capabilities: &ClientCapabilities{},
ClientInfo: &Implementation{Name: "test", Version: "1"},
})
if err != nil {
t.Fatal(err)
}
_, err = ss.handle(ctx, &jsonrpc.Request{
ID: jsonrpc2.Int64ID(1),
Method: methodInitialize,
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{protocolVersion20260728}, data.Supported); diff != "" {
t.Errorf("UnsupportedProtocolVersionData.Supported mismatch (-want +got):\n%s", diff)
}
if ss.InitializeParams() != nil {
t.Errorf("session initialized after a rejected initialize: %+v", ss.InitializeParams())
}
}

// TestServerSupportedProtocolVersions_LegacyDispatch verifies that a legacy
// request (no _meta) is rejected with [CodeUnsupportedProtocolVersion] when
// the server only supports protocol versions >= 2026-07-28 via
// [ServerOptions.SupportedProtocolVersions].
func TestServerSupportedProtocolVersions_LegacyDispatch(t *testing.T) {
ctx := context.Background()
server := NewServer(testImpl, &ServerOptions{
SupportedProtocolVersions: []string{protocolVersion20260728},
})
ct, st := NewInMemoryTransports()
ss, err := server.Connect(ctx, st, nil)
if err != nil {
t.Fatalf("server.Connect: %v", err)
}
defer ss.Close()
_ = ct

_, err = ss.handle(ctx, &jsonrpc.Request{
ID: jsonrpc2.Int64ID(1),
Method: methodListTools,
Params: json.RawMessage(`{}`),
})
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{protocolVersion20260728}, data.Supported); diff != "" {
t.Errorf("UnsupportedProtocolVersionData.Supported mismatch (-want +got):\n%s", diff)
}
}

// stalls — here a peer that never reads its end of the pipe — does not delay
// or fail delivery to the other sessions in the same broadcast.
func TestNotifySessionsIsolatesStalledPeer(t *testing.T) {
Expand Down
12 changes: 4 additions & 8 deletions mcp/shared.go
Original file line number Diff line number Diff line change
Expand Up @@ -89,14 +89,10 @@ func negotiatedVersion(clientVersion string, supported []string) string {
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
// A server that supports no version before protocolVersion20260728 has
// nothing to negotiate. [ServerSession.handle] rejects initialize on such
// a server with CodeUnsupportedProtocolVersion before it gets here.
return ""
}

// negotiateMutuallySupportedVersion returns a protocol version that is supported
Expand Down
18 changes: 14 additions & 4 deletions mcp/streamable.go
Original file line number Diff line number Diff line change
Expand Up @@ -1212,10 +1212,10 @@ func (s *stream) keepAlive(ctx context.Context, interval time.Duration, committe
// - InvalidParams (-32602), UnsupportedProtocolVersion (-32022) and
// CodeMissingRequiredClientCapabilities (-32021) MUST
// return HTTP 400.
//
// UnsupportedProtocolVersion is the one code mapped for every protocol
// version.
func extractErrorStatus(ctx context.Context, msg jsonrpc.Message) int {
if protocolVersionFromContext(ctx) < protocolVersion20260728 {
return 0
}
resp, ok := msg.(*jsonrpc.Response)
if !ok || resp.Error == nil {
return 0
Expand All @@ -1224,10 +1224,20 @@ func extractErrorStatus(ctx context.Context, msg jsonrpc.Message) int {
if !errors.As(resp.Error, &jerr) {
return 0
}
// A legacy request gets this code only from a server that supports no
// version before 2026-07-28, which rejects it in ServerSession.handle
// before any handler runs, so nothing has been written to the response
// yet.
if jerr.Code == CodeUnsupportedProtocolVersion {
return http.StatusBadRequest
}
if protocolVersionFromContext(ctx) < protocolVersion20260728 {
return 0
}
switch jerr.Code {
case jsonrpc.CodeMethodNotFound:
return http.StatusNotFound
case jsonrpc.CodeInvalidParams, CodeUnsupportedProtocolVersion, CodeMissingRequiredClientCapabilities:
case jsonrpc.CodeInvalidParams, CodeMissingRequiredClientCapabilities:
return http.StatusBadRequest
}
return 0
Expand Down
47 changes: 47 additions & 0 deletions mcp/streamable_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1757,6 +1757,53 @@ func TestStreamableStateless(t *testing.T) {
testClientCompatibility(t, statelessHandler)
}

// TestStreamableStateless_RejectsLegacyOn2026OnlyServer verifies that a
// stateless server configured with [ServerOptions.SupportedProtocolVersions]
// set to only ["2026-07-28"] rejects legacy-format requests (no _meta, no
// Mcp-Protocol-Version header) with [CodeUnsupportedProtocolVersion].
func TestStreamableStateless_RejectsLegacyOn2026OnlyServer(t *testing.T) {
server := NewServer(testImpl, &ServerOptions{
SupportedProtocolVersions: []string{protocolVersion20260728},
})
handler := NewStreamableHTTPHandler(func(*http.Request) *Server { return server }, &StreamableHTTPOptions{
Stateless: true,
JSONResponse: true,
})
defer handler.closeAll()

ts := httptest.NewServer(handler)
defer ts.Close()

body, _ := jsonrpc2.EncodeMessage(req(1, methodListTools, &ListToolsParams{}))
httpReq, err := http.NewRequest(http.MethodPost, ts.URL, bytes.NewReader(body))
if err != nil {
t.Fatalf("NewRequest: %v", err)
}
httpReq.Header.Set("Content-Type", "application/json")
httpReq.Header.Set("Accept", "application/json, text/event-stream")
resp, err := http.DefaultClient.Do(httpReq)
if err != nil {
t.Fatalf("POST failed: %v", err)
}
defer resp.Body.Close()

if resp.StatusCode != http.StatusBadRequest {
t.Errorf("status = %d, want %d", resp.StatusCode, http.StatusBadRequest)
}
got, _ := io.ReadAll(resp.Body)
var errResp struct {
Error struct {
Code int64 `json:"code"`
} `json:"error"`
}
if err := json.Unmarshal(got, &errResp); err != nil {
t.Fatalf("unmarshal response: %v\nbody: %s", err, got)
}
if errResp.Error.Code != CodeUnsupportedProtocolVersion {
t.Errorf("error code = %d, want %d", errResp.Error.Code, CodeUnsupportedProtocolVersion)
}
}

func textContent(t *testing.T, res *CallToolResult) string {
t.Helper()
if len(res.Content) != 1 {
Expand Down
Loading