mcp: Implement server-side support for discover method (SEP-2575) (#987)

## Summary
Part 3 of the SEP-2575 ("Make MCP Stateless") implementation. Builds on
PR #975 which landed the client-side discover/fallback flow and the
per-request `_meta` plumbing. This PR adds the **server-side**
counterparts:

- `server/discover` handler (replaces the
stub),ProtocolVersionSupporter`
- transport hook for per-transport version filtering,
- transport-agnostic version validation and method-removal enforcement
in `ServerSession.handle`,
- HTTP-level wire compliance for SEP-2575 error codes/status codes,
- bug fix in `internal/jsonrpc2`,`processResult` with an `MCPGODEBUG`
opt-out for backward compatibility.
This commit is contained in:
Guglielmo Colombo 2026-06-08 17:52:10 +02:00 • committed by GitHub
commit dd978160ab
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 702 additions and 187 deletions

View file

@ -6,9 +6,9 @@ package mcp
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"slices"
"sync/atomic"
"testing"
@ -678,6 +678,17 @@ func TestClientConnectDiscover(t *testing.T) {
wantInitialize: true,
wantVersion: latestProtocolVersion,
},
{
name: "unsupported protocol version falls back to initialize",
discoverHandler: func() (Result, error) {
return nil, &jsonrpc.Error{
Code: CodeUnsupportedProtocolVersion,
Message: "unsupported protocol version",
}
},
wantInitialize: true,
wantVersion: latestProtocolVersion,
},
{
name: "no overlapping supported version falls back to initialize",
discoverHandler: func() (Result, error) {
@ -773,6 +784,10 @@ func TestClientConnectDiscover(t *testing.T) {
// request sent by Client.Connect carries the SEP-2575 per-request _meta triple:
// protocolVersion, clientInfo, and clientCapabilities.
func TestClientConnectDiscover_RequestContents(t *testing.T) {
orig := supportedProtocolVersions
supportedProtocolVersions = append([]string{protocolVersion20260630}, slices.Clone(orig)...)
t.Cleanup(func() { supportedProtocolVersions = orig })
ctx := context.Background()
type captured struct {
@ -843,31 +858,263 @@ func TestClientConnectDiscover_RequestContents(t *testing.T) {
}
}
// 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
// TestInMemory_E2E_DiscoverSuccess is a full end-to-end smoke test for
// SEP-2575 over a non-HTTP transport.
func TestInMemory_E2E_DiscoverSuccess(t *testing.T) {
ctx := context.Background()
orig := supportedProtocolVersions
supportedProtocolVersions = append([]string{protocolVersion20260630}, slices.Clone(orig)...)
t.Cleanup(func() { supportedProtocolVersions = orig })
server := NewServer(&Implementation{Name: "stdio-like-server", Version: "v1"}, nil)
ct, st := NewInMemoryTransports()
ss, err := server.Connect(ctx, st, nil)
if err != nil {
t.Fatalf("server.Connect: %v", err)
}
defer ss.Close()
client := NewClient(&Implementation{Name: "stdio-like-client", Version: "v1"}, nil)
cs, err := client.Connect(ctx, ct, &ClientSessionOptions{protocolVersion: protocolVersion20260630})
if err != nil {
t.Fatalf("client.Connect: %v", err)
}
defer cs.Close()
ir := cs.InitializeResult()
if ir == nil {
t.Fatal("InitializeResult is nil; discover should have populated it")
}
if ir.ProtocolVersion != protocolVersion20260630 {
t.Errorf("InitializeResult.ProtocolVersion = %q, want %q (negotiated via discover, no initialize)",
ir.ProtocolVersion, protocolVersion20260630)
}
if ir.ServerInfo == nil || ir.ServerInfo.Name != "stdio-like-server" {
t.Errorf("InitializeResult.ServerInfo = %+v, want name=stdio-like-server", ir.ServerInfo)
}
// Prove the session is usable.
if _, err := cs.ListTools(ctx, nil); err != nil {
t.Errorf("ListTools after discover: %v", err)
}
}
// TestInMemory_E2E_DiscoverFallback_NoOverlap verifies the fallback path
// over an InMemory (STDIO-equivalent) transport: the client probes with
// _meta.protocolVersion = 2026-06-30, but the server's supported list does
// NOT include that version (the default for an SDK server that hasn't
// shimmed supportedProtocolVersions).
func TestInMemory_E2E_DiscoverFallback_NoOverlap(t *testing.T) {
ctx := context.Background()
orig := supportedProtocolVersions
supportedProtocolVersions = append([]string{protocolVersion20260630}, slices.Clone(orig)...)
t.Cleanup(func() { supportedProtocolVersions = orig })
server := NewServer(&Implementation{Name: "vpre-like-server", Version: "v1"}, nil)
// Intercept discover and reply as if we were a server that only
// supports legacy versions.
server.AddReceivingMiddleware(func(next MethodHandler) MethodHandler {
return func(ctx context.Context, method string, req Request) (Result, error) {
if method == methodDiscover {
return &DiscoverResult{
SupportedVersions: []string{protocolVersion20251125},
Capabilities: &ServerCapabilities{},
ServerInfo: &Implementation{Name: "vpre-like-server", Version: "v1"},
}, nil
}
return next(ctx, method, req)
}
})
ct, st := NewInMemoryTransports()
ss, err := server.Connect(ctx, st, nil)
if err != nil {
t.Fatalf("server.Connect: %v", err)
}
defer ss.Close()
client := NewClient(&Implementation{Name: "new-client", Version: "v1"}, nil)
cs, err := client.Connect(ctx, ct, &ClientSessionOptions{protocolVersion: protocolVersion20260630})
if err != nil {
t.Fatalf("client.Connect: %v", err)
}
defer cs.Close()
ir := cs.InitializeResult()
if ir == nil {
t.Fatal("InitializeResult is nil after fallback initialize")
}
if ir.ProtocolVersion != latestProtocolVersion {
t.Errorf("InitializeResult.ProtocolVersion = %q, want %q (legacy fallback after no-overlap discover)",
ir.ProtocolVersion, latestProtocolVersion)
}
// Prove the session is usable after fallback.
if _, err := cs.ListTools(ctx, nil); err != nil {
t.Errorf("ListTools after fallback initialize: %v", err)
}
}
// TestInMemory_E2E_DiscoverFallback_MethodNotFound verifies the fallback
// path over InMemory when the server doesn't know about server/discover at
// all (simulating a true pre-SEP-2575 server).
func TestInMemory_E2E_DiscoverFallback_MethodNotFound(t *testing.T) {
ctx := context.Background()
orig := supportedProtocolVersions
supportedProtocolVersions = append([]string{protocolVersion20260630}, slices.Clone(orig)...)
t.Cleanup(func() { supportedProtocolVersions = orig })
server := NewServer(&Implementation{Name: "vpre-server", Version: "v1"}, nil)
server.AddReceivingMiddleware(func(next MethodHandler) MethodHandler {
return func(ctx context.Context, method string, req Request) (Result, error) {
if method == methodDiscover {
return nil, jsonrpc2.ErrMethodNotFound
}
return next(ctx, method, req)
}
})
ct, st := NewInMemoryTransports()
ss, err := server.Connect(ctx, st, nil)
if err != nil {
t.Fatalf("server.Connect: %v", err)
}
defer ss.Close()
client := NewClient(&Implementation{Name: "new-client", Version: "v1"}, nil)
cs, err := client.Connect(ctx, ct, &ClientSessionOptions{protocolVersion: protocolVersion20260630})
if err != nil {
t.Fatalf("client.Connect: %v", err)
}
defer cs.Close()
ir := cs.InitializeResult()
if ir == nil {
t.Fatal("InitializeResult is nil after fallback initialize")
}
if ir.ProtocolVersion != latestProtocolVersion {
t.Errorf("InitializeResult.ProtocolVersion = %q, want %q (legacy fallback after MethodNotFound)",
ir.ProtocolVersion, latestProtocolVersion)
}
if _, err := cs.ListTools(ctx, nil); err != nil {
t.Errorf("ListTools after fallback initialize: %v", err)
}
}
// TestInMemory_E2E_DiscoverFallback_UnsupportedProtocolVersion verifies the
// fallback path when the server explicitly rejects the discover probe with
// CodeUnsupportedProtocolVersion (the structured SEP-2575 signal). This
// exercises Path A of the fallback logic in client.go.
func TestInMemory_E2E_DiscoverFallback_UnsupportedProtocolVersion(t *testing.T) {
ctx := context.Background()
orig := supportedProtocolVersions
supportedProtocolVersions = append([]string{protocolVersion20260630}, slices.Clone(orig)...)
t.Cleanup(func() { supportedProtocolVersions = orig })
server := NewServer(&Implementation{Name: "strict-server", Version: "v1"}, nil)
server.AddReceivingMiddleware(func(next MethodHandler) MethodHandler {
return func(ctx context.Context, method string, req Request) (Result, error) {
if method == methodDiscover {
return nil, &jsonrpc.Error{
Code: CodeUnsupportedProtocolVersion,
Message: "unsupported protocol version",
}
}
return next(ctx, method, req)
}
})
ct, st := NewInMemoryTransports()
ss, err := server.Connect(ctx, st, nil)
if err != nil {
t.Fatalf("server.Connect: %v", err)
}
defer ss.Close()
client := NewClient(&Implementation{Name: "new-client", Version: "v1"}, nil)
cs, err := client.Connect(ctx, ct, &ClientSessionOptions{protocolVersion: protocolVersion20260630})
if err != nil {
t.Fatalf("client.Connect: %v", err)
}
defer cs.Close()
ir := cs.InitializeResult()
if ir == nil {
t.Fatal("InitializeResult is nil after fallback initialize")
}
if ir.ProtocolVersion != latestProtocolVersion {
t.Errorf("InitializeResult.ProtocolVersion = %q, want %q (legacy fallback after UnsupportedProtocolVersion)",
ir.ProtocolVersion, latestProtocolVersion)
}
}
// TestInMemory_E2E_DiscoverPropagatesOtherErrors verifies that an unrelated
// error from the discover handler aborts Connect and does NOT silently
// fall back.
func TestInMemory_E2E_DiscoverPropagatesOtherErrors(t *testing.T) {
ctx := context.Background()
orig := supportedProtocolVersions
supportedProtocolVersions = append([]string{protocolVersion20260630}, slices.Clone(orig)...)
t.Cleanup(func() { supportedProtocolVersions = orig })
var sawInitialize atomic.Bool
server := NewServer(&Implementation{Name: "broken-server", Version: "v1"}, nil)
server.AddReceivingMiddleware(func(next MethodHandler) MethodHandler {
return func(ctx context.Context, method string, req Request) (Result, error) {
switch method {
case methodDiscover:
return nil, &jsonrpc.Error{
Code: jsonrpc.CodeInternalError,
Message: "boom",
}
case methodInitialize:
sawInitialize.Store(true)
}
return next(ctx, method, req)
}
})
ct, st := NewInMemoryTransports()
ss, err := server.Connect(ctx, st, nil)
if err != nil {
t.Fatalf("server.Connect: %v", err)
}
defer ss.Close()
client := NewClient(&Implementation{Name: "new-client", Version: "v1"}, nil)
cs, err := client.Connect(ctx, ct, &ClientSessionOptions{protocolVersion: protocolVersion20260630})
if err == nil {
_ = cs.Close()
t.Fatal("Connect succeeded; want propagated discover error")
}
if sawInitialize.Load() {
t.Error("server received initialize; Connect should have aborted on the discover error")
}
}
// TestClientConnectDiscover_UnsupportedVersionNegotiation verifies the
// per SEP-2575 Version Negotiation Flow: when the client probes server/discover
// with a protocol version the server doesn't implement, the server's
// UnsupportedProtocolVersionError carries Data.Supported, and the client
// selects a mutually supported version from that list and retries.
func TestClientConnectDiscover_UnsupportedVersionNegotiation(t *testing.T) {
oldSupported := supportedProtocolVersions
supportedProtocolVersions = append([]string{protocolVersion20260630}, slices.Clone(oldSupported)...)
t.Cleanup(func() { supportedProtocolVersions = oldSupported })
ctx := context.Background()
const (
unsupportedClientVersion = "2099-12-31"
serverNegotiatedVersion = protocolVersion20260630
)
const unsupportedClientVersion = "2099-12-31"
var (
discoverCalls atomic.Int32
gotInitialize atomic.Bool
firstRequestedVersion atomic.Value // string
secondRequestedVersion atomic.Value // string
discoverCalls atomic.Int32
gotInitialize atomic.Bool
observedDiscoverVersion atomic.Value // string
)
s := NewServer(testImpl, nil)
@ -880,37 +1127,10 @@ func TestClientConnectDiscover_UnsupportedVersionNegotiation(t *testing.T) {
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
if v, _ := sr.Params.GetMeta()[MetaKeyProtocolVersion].(string); v != "" {
observedDiscoverVersion.Store(v)
}
discoverCalls.Add(1)
case methodInitialize:
gotInitialize.Store(true)
}
@ -932,14 +1152,11 @@ func TestClientConnectDiscover_UnsupportedVersionNegotiation(t *testing.T) {
}
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, want := discoverCalls.Load(), int32(1); got != want {
t.Errorf("server/discover handler call count = %d, want %d (first probe is rejected by the dispatcher; only the retry reaches the handler)", 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 got, _ := observedDiscoverVersion.Load().(string); got != protocolVersion20260630 {
t.Errorf("retry discover requested version = %q, want %q (highest mutually supported version)", got, protocolVersion20260630)
}
if gotInitialize.Load() {
t.Error("legacy initialize handshake ran, but negotiated discover should have succeeded")
@ -949,7 +1166,7 @@ func TestClientConnectDiscover_UnsupportedVersionNegotiation(t *testing.T) {
if ir == nil {
t.Fatal("InitializeResult is nil after Connect")
}
if got, want := ir.ProtocolVersion, serverNegotiatedVersion; got != want {
if got, want := ir.ProtocolVersion, protocolVersion20260630; got != want {
t.Errorf("InitializeResult.ProtocolVersion = %q, want %q", got, want)
}
}

View file

@ -526,27 +526,6 @@ func TestMultiRoundTrip_ReadResource_ManualRetry(t *testing.T) {
func mustConnect(t *testing.T, s *Server, clientOpts *ClientOptions) *ClientSession {
t.Helper()
// The mrtr tests require negotiating the 2026-06-30 protocol version.
// Server.discover is currently a stub that returns ErrMethodNotFound, which
// would cause Client.Connect to fall back to the legacy initialize handshake
// and downgrade the negotiated version. Install a receiving middleware that
// answers server/discover with a DiscoverResult advertising 2026-06-30 so
// the client can negotiate the new protocol via the discover path.
//
// TODO: Remove this once the server has a proper discover implementation.
s.AddReceivingMiddleware(func(next MethodHandler) MethodHandler {
return func(ctx context.Context, method string, req Request) (Result, error) {
if method == methodDiscover {
return &DiscoverResult{
SupportedVersions: []string{protocolVersion20260630},
Capabilities: &ServerCapabilities{},
ServerInfo: testImpl,
}, nil
}
return next(ctx, method, req)
}
})
st, ct := NewInMemoryTransports()
ss, err := s.Connect(t.Context(), st, nil)
if err != nil {

View file

@ -766,9 +766,45 @@ func (s *Server) getPrompt(ctx context.Context, req *GetPromptRequest) (*GetProm
// discover is the server-side handler for the SEP-2575 "server/discover" RPC.
//
// TODO: Complete implementation.
func (s *Server) discover(context.Context, *ServerRequest[*DiscoverParams]) (*DiscoverResult, error) {
return nil, jsonrpc2.ErrMethodNotFound
// It returns the protocol versions supported by the underlying transport,
// the server's capabilities, the server's identity, and the server's
// instructions, allowing clients to negotiate without performing the legacy
// initialize handshake.
func (s *Server) discover(_ context.Context, req *ServerRequest[*DiscoverParams]) (*DiscoverResult, error) {
versions := req.Session.supportedVersions
if versions == nil {
versions = slices.Clone(supportedProtocolVersions)
}
req.Session.updateState(func(state *ServerSessionState) {
state.InitializeParams = &InitializeParams{
ProtocolVersion: req.ProtocolVersion(),
Capabilities: req.ClientCapabilities(),
ClientInfo: req.ClientInfo(),
}
})
return &DiscoverResult{
SupportedVersions: versions,
Capabilities: s.capabilities(),
ServerInfo: s.impl,
Instructions: s.opts.Instructions,
}, 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 {
pvs, ok := t.(ProtocolVersionSupporter)
if !ok {
return slices.Clone(supportedProtocolVersions)
}
out := make([]string, 0, len(supportedProtocolVersions))
for _, v := range supportedProtocolVersions {
if pvs.SupportsProtocolVersion(v) {
out = append(out, v)
}
}
return out
}
func (s *Server) listTools(_ context.Context, req *ListToolsRequest) (*ListToolsResult, error) {
@ -1093,6 +1129,11 @@ func (s *Server) Connect(ctx context.Context, t Transport, opts *ServerSessionOp
return nil, err
}
// Compute the protocol versions this session can serve, filtered by the
// transport's capabilities (if it implements [ProtocolVersionSupporter]).
// The list is consumed by the SEP-2575 server/discover handler.
ss.supportedVersions = filterSupportedVersions(t)
// Start keepalive before returning the session to avoid race conditions with Close.
// This is safe because the spec allows sending pings before initialization (see ServerSession.handle for details).
if s.opts.KeepAlive > 0 {
@ -1177,6 +1218,12 @@ type ServerSession struct {
mcpConn Connection
keepaliveCancel context.CancelFunc
// supportedVersions is the subset of [supportedProtocolVersions] 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.
supportedVersions []string
mu sync.Mutex
state ServerSessionState
}
@ -1511,6 +1558,23 @@ func (ss *ServerSession) handle(ctx context.Context, req *jsonrpc.Request) (any,
if perRequestErr != nil {
return nil, perRequestErr
}
// server/discover is exempt from the version-validation gate so that a
// client probing with a version the server doesn't implement can still
// learn the server's supported versions from the response (whether the
// handler returns a DiscoverResult or an UnsupportedProtocolVersionError
// with a Data.Supported payload).
if validatedMeta.usesNewProtocol &&
!slices.Contains(supportedProtocolVersions, validatedMeta.initializeParams.ProtocolVersion) {
data, _ := json.Marshal(UnsupportedProtocolVersionData{
Supported: supportedProtocolVersions,
Requested: validatedMeta.initializeParams.ProtocolVersion,
})
return nil, &jsonrpc.Error{
Code: CodeUnsupportedProtocolVersion,
Message: "unsupported protocol version",
Data: data,
}
}
switch req.Method {
case methodInitialize, methodPing, notificationInitialized, methodSetLevel:

View file

@ -1100,6 +1100,10 @@ func TestServerCapabilitiesOverWire(t *testing.T) {
// that opts into the new protocol via `_meta.protocolVersion` must be
// rejected with `Method not found` (-32601).
func TestServerSessionHandle_RejectsInitializeOnNewProtocol(t *testing.T) {
orig := supportedProtocolVersions
supportedProtocolVersions = append([]string{protocolVersion20260630}, slices.Clone(orig)...)
t.Cleanup(func() { supportedProtocolVersions = orig })
tests := []struct {
name string
params any
@ -1213,6 +1217,10 @@ func TestServerSessionHandle_RejectsInitializeOnNewProtocol(t *testing.T) {
// `ping`) all return Method not found when the request opts into the new
// protocol via `_meta.protocolVersion`.
func TestServerSessionHandle_RejectsRemovedMethodsOnNewProtocol(t *testing.T) {
orig := supportedProtocolVersions
supportedProtocolVersions = append([]string{protocolVersion20260630}, slices.Clone(orig)...)
t.Cleanup(func() { supportedProtocolVersions = orig })
newProtoMeta := map[string]any{
"_meta": map[string]any{
MetaKeyProtocolVersion: protocolVersion20260630,

View file

@ -116,7 +116,6 @@ func defaultSendingMethodHandler(ctx context.Context, method string, req Request
// 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)
@ -356,6 +355,9 @@ func clientSessionMethod[P Params, R Result](f func(*ClientSession, context.Cont
// MCP-specific error codes.
const (
// CodeMissingRequiredClientCapabilities is the JSON-RPC error code defined by
// SEP-2575 for MissingRequiredClientCapabilitiesError.
CodeMissingRequiredClientCapabilities = -32003
// CodeUnsupportedProtocolVersion is the JSON-RPC error code defined by
// SEP-2575 for UnsupportedProtocolVersionError.
CodeUnsupportedProtocolVersion = -32004
@ -516,7 +518,7 @@ func validateRequestMeta(req *jsonrpc.Request) (*validatedMeta, error) {
return &validatedMeta{usesNewProtocol: false, initializeParams: nil}, nil
}
protocolVersion, ok := meta[MetaKeyProtocolVersion].(string)
if !ok {
if !ok || protocolVersion < protocolVersion20260630 {
return &validatedMeta{usesNewProtocol: false, initializeParams: nil}, nil
}
// Notifications do not carry full client identity.

View file

@ -266,6 +266,23 @@ var enableoriginverification = mcpgodebug.Value("enableoriginverification")
// The option will be removed in the 1.9.0 version of the SDK.
var allowsessionsinstateless = mcpgodebug.Value("allowsessionsinstateless")
// noprotocolerrorbody is a compatibility parameter that restores the previous
// behavior of [streamableClientConn.checkResponse]. When unset (the default),
// the client always attempts to surface the underlying JSON-RPC error.
var noprotocolerrorbody = mcpgodebug.Value("noprotocolerrorbody")
// writeJSONRPCError writes a JSON-RPC error response with the given HTTP
// status code, request ID (may be a zero ID for errors that occur before the
// request body has been parsed), and JSON-RPC error.
func writeJSONRPCError(w http.ResponseWriter, status int, id jsonrpc.ID, jerr *jsonrpc.Error) {
resp := &jsonrpc.Response{ID: id, Error: jerr}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
if data, err := jsonrpc2.EncodeMessage(resp); err == nil {
w.Write(data)
}
}
func (h *StreamableHTTPHandler) ServeHTTP(w http.ResponseWriter, req *http.Request) {
// DNS rebinding protection: auto-enabled for localhost servers.
// See: https://modelcontextprotocol.io/specification/2025-11-25/basic/security_best_practices#local-mcp-server-compromise
@ -291,7 +308,7 @@ func (h *StreamableHTTPHandler) ServeHTTP(w http.ResponseWriter, req *http.Reque
//
// [§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) {
if protocolVersion != "" && !slices.Contains(supportedProtocolVersions, protocolVersion) && protocolVersion < protocolVersion20260630 {
http.Error(w, fmt.Sprintf("Bad Request: Unsupported protocol version (supported versions: %s)", strings.Join(supportedProtocolVersions, ",")), http.StatusBadRequest)
return
}
@ -784,6 +801,16 @@ func (t *StreamableServerTransport) Connect(ctx context.Context) (Connection, er
return t.connection, nil
}
// The streamable HTTP transport supports every legacy SDK protocol version,
// but the SEP-2575 >= 2026-06-30 protocol is only supported when the
// transport is configured as stateless.
func (t *StreamableServerTransport) SupportsProtocolVersion(version string) bool {
if version >= protocolVersion20260630 {
return t.Stateless && slices.Contains(supportedProtocolVersions, version)
}
return slices.Contains(supportedProtocolVersions, version)
}
type streamableServerConn struct {
sessionID string
stateless bool
@ -916,6 +943,35 @@ func (s *stream) release() {
s.done = nil // may already be nil, if the stream is done or closed
}
// extractErrorStatus reports the HTTP status to send when the given
// outgoing message is a JSON-RPC error response under the SEP-2575 protocol
// (>= 2026-06-30).
//
// Per SEP-2575:
// - MethodNotFound (-32601) MUST return HTTP 404.
// - InvalidParams (-32602) and UnsupportedProtocolVersion (-32004) MUST
// return HTTP 400.
func extractErrorStatus(ctx context.Context, msg jsonrpc.Message) int {
if protocolVersionFromContext(ctx) < protocolVersion20260630 {
return 0
}
resp, ok := msg.(*jsonrpc.Response)
if !ok || resp.Error == nil {
return 0
}
var jerr *jsonrpc.Error
if !errors.As(resp.Error, &jerr) {
return 0
}
switch jerr.Code {
case jsonrpc.CodeMethodNotFound:
return http.StatusNotFound
case jsonrpc.CodeInvalidParams, CodeUnsupportedProtocolVersion, CodeMissingRequiredClientCapabilities:
return http.StatusBadRequest
}
return 0
}
// deliverLocked writes data to the stream (for SSE) or stores it in
// pendingJSONMessages (for JSON mode). The eventID is used for SSE event ID;
// pass "" to omit.
@ -923,11 +979,16 @@ func (s *stream) release() {
// If responseTo is valid, it is removed from the requests map. When all
// requests have been responded to, the done channel is closed and set to nil.
//
// If overrideStatus is non-zero, data is treated as a SEP-2575 protocol-level
// error response (>= 2026-06-30): it is written as a single raw JSON-RPC
// response body with Content-Type: application/json and HTTP status
// overrideStatus.
//
// Returns true if the stream is now done (all requests have been responded to).
// The done value is always accurate, even if an error is returned.
//
// s.mu must be held when calling this method.
func (s *stream) deliverLocked(data []byte, eventID string, responseTo jsonrpc.ID) (done bool, err error) {
func (s *stream) deliverLocked(data []byte, eventID string, responseTo jsonrpc.ID, overrideStatus int) (done bool, err error) {
// First, record the response. We must do this *before* returning an error
// below, as even if the stream is disconnected we want to update our
// accounting.
@ -942,6 +1003,17 @@ func (s *stream) deliverLocked(data []byte, eventID string, responseTo jsonrpc.I
if done {
defer func() { close(s.done); s.done = nil }()
}
// SEP-2575 protocol-level error override: write the error as a raw
// JSON-RPC response with the spec-mandated HTTP status, bypassing any
// SSE framing.
if overrideStatus != 0 {
s.w.Header().Set("Content-Type", "application/json")
s.w.WriteHeader(overrideStatus)
if _, err := s.w.Write(data); err != nil {
return done, err
}
return done, nil
}
// Try to write to the response.
//
// If we get here, the request is still hanging (because s.done != nil
@ -1301,6 +1373,13 @@ func (c *streamableServerConn) servePOST(w http.ResponseWriter, req *http.Reques
// the HTTP request. If we didn't do this, a request with a bad method or
// missing ID could be silently swallowed.
if _, err := checkRequest(jreq, serverMethodInfos); err != nil {
if headerVersion >= protocolVersion20260630 && errors.Is(err, jsonrpc2.ErrNotHandled) && jreq.IsCall() {
writeJSONRPCError(w, http.StatusNotFound, jreq.ID, &jsonrpc.Error{
Code: jsonrpc.CodeMethodNotFound,
Message: err.Error(),
})
return
}
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
@ -1324,7 +1403,10 @@ func (c *streamableServerConn) servePOST(w http.ResponseWriter, req *http.Reques
metaVersion, _ = meta[MetaKeyProtocolVersion].(string)
}
if protocolVersion >= protocolVersion20260630 || metaVersion != "" {
if !c.stateless {
// server/discover is exempt from the stateful
// rejection as it should learn about the supported protocols from the
// DiscoverResult response.
if !c.stateless && jreq.Method != methodDiscover {
http.Error(w, fmt.Sprintf(
"Bad Request: protocol version %q is only supported on stateless HTTP servers (set StreamableHTTPOptions.Stateless = true)",
protocolVersion),
@ -1332,18 +1414,31 @@ func (c *streamableServerConn) servePOST(w http.ResponseWriter, req *http.Reques
return
}
if headerVersion == "" {
http.Error(w, fmt.Sprintf(
"Bad Request: %s header is required for requests carrying %q",
protocolVersionHeader, MetaKeyProtocolVersion),
http.StatusBadRequest)
writeJSONRPCError(w, http.StatusBadRequest, jreq.ID, &jsonrpc.Error{
Code: CodeHeaderMismatch,
Message: fmt.Sprintf(
"%s header is required for requests carrying %q",
protocolVersionHeader, MetaKeyProtocolVersion),
})
return
}
if metaVersion == "" {
writeJSONRPCError(w, http.StatusBadRequest, jreq.ID, &jsonrpc.Error{
Code: jsonrpc.CodeInvalidParams,
Message: fmt.Sprintf(
"missing or invalid _meta field %q",
MetaKeyProtocolVersion),
})
return
}
if headerVersion != metaVersion {
http.Error(w, fmt.Sprintf(
"Bad Request: %s header %q does not match request %s %q",
protocolVersionHeader, headerVersion,
MetaKeyProtocolVersion, metaVersion),
http.StatusBadRequest)
writeJSONRPCError(w, http.StatusBadRequest, jreq.ID, &jsonrpc.Error{
Code: CodeHeaderMismatch,
Message: fmt.Sprintf(
"%s header %q does not match request %s %q",
protocolVersionHeader, headerVersion,
MetaKeyProtocolVersion, metaVersion),
})
return
}
}
@ -1457,7 +1552,10 @@ func (c *streamableServerConn) servePOST(w http.ResponseWriter, req *http.Reques
stream.pendingJSONMessages = []json.RawMessage{}
} else {
// SSE mode: write a priming event if supported.
if c.eventStore != nil && effectiveVersion >= protocolVersion20251125 {
//
// SEP-2575 removes Last-Event-ID-based resumable streams for protocol
// version >= 2026-06-30.
if c.eventStore != nil && effectiveVersion >= protocolVersion20251125 && effectiveVersion < protocolVersion20260630 {
// Write a priming event, as defined by [§2.1.6] of the spec.
//
// [§2.1.6]: https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#sending-messages-to-the-server
@ -1646,7 +1744,12 @@ func (c *streamableServerConn) Write(ctx context.Context, msg jsonrpc.Message) e
eventID = formatEventID(s.id, s.lastIdx+1)
}
done, err := s.deliverLocked(data, eventID, responseTo)
// SEP-2575: map protocol-level JSON-RPC error codes to HTTP status codes
// on the new protocol (>= 2026-06-30). When non-zero, deliverLocked will
// write the body as raw application/json with the override status.
overrideStatus := extractErrorStatus(ctx, msg)
done, err := s.deliverLocked(data, eventID, responseTo, overrideStatus)
if err != nil {
errs = append(errs, err)
} else {
@ -1846,7 +1949,7 @@ func (c *streamableClientConn) sessionUpdated(state clientSessionState) {
c.initializedResult = state.InitializeResult
c.mu.Unlock()
// When the protocol version is >= 2026-06-30, the standalone HTTP GET
// Under SEP-2575 (protocol version >= 2026-06-30) the standalone HTTP GET
// SSE stream is removed.
if state.InitializeResult == nil ||
state.InitializeResult.ProtocolVersion >= protocolVersion20260630 {
@ -2271,18 +2374,24 @@ func (c *streamableClientConn) checkResponse(ctx context.Context, requestSummary
return fmt.Errorf("%w: %s: %v", jsonrpc2.ErrRejected, requestSummary, http.StatusText(resp.StatusCode))
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
// Read the body and if we can detect vPre servers that
// reject "server/discover" as unsupported method with a plain HTTP 400,
// then return jsonrpc2.ErrMethodNotFound or jsonrpc2.ErrUnsupportedProtocolVersion to trigger the fallback.
protocolVersion := protocolVersionFromContext(ctx)
if protocolVersion != "" && protocolVersion >= protocolVersion20260630 {
body, _ := io.ReadAll(resp.Body)
if strings.Contains(string(body), fmt.Sprintf("%s: %q unsupported", jsonrpc2.ErrNotHandled, methodDiscover)) {
return fmt.Errorf("%s: %w: %v", requestSummary, jsonrpc2.ErrMethodNotFound, http.StatusText(resp.StatusCode))
}
if strings.Contains(string(body), "Unsupported protocol version") {
return fmt.Errorf("%s: %w: %v", requestSummary, jsonrpc2.ErrUnsupportedProtocolVersion, http.StatusText(resp.StatusCode))
}
// By default, always try to decode the body and surface the underlying
// JSON-RPC error (or detect vPre servers that reject "server/discover"
// as an unsupported method with a plain HTTP 400) regardless of the
// negotiated protocol version. Setting MCPGODEBUG=noprotocolerrorbody=1
// restores the previous behavior.
if noprotocolerrorbody == "1" {
return fmt.Errorf("%s: %v", requestSummary, http.StatusText(resp.StatusCode))
}
body, _ := io.ReadAll(resp.Body)
msg, _ := jsonrpc.DecodeMessage(body)
if response, ok := msg.(*jsonrpc.Response); ok && response.Error != nil {
return fmt.Errorf("%s: %w: %v", requestSummary, response.Error, http.StatusText(resp.StatusCode))
}
if strings.Contains(string(body), fmt.Sprintf("%s: %q unsupported", jsonrpc2.ErrNotHandled, methodDiscover)) {
return fmt.Errorf("%s: %w: %v", requestSummary, jsonrpc2.ErrMethodNotFound, http.StatusText(resp.StatusCode))
}
if strings.Contains(string(body), "Unsupported protocol version") {
return fmt.Errorf("%s: %w: %v", requestSummary, jsonrpc2.ErrUnsupportedProtocolVersion, http.StatusText(resp.StatusCode))
}
return fmt.Errorf("%s: %v", requestSummary, http.StatusText(resp.StatusCode))
}

View file

@ -2069,15 +2069,7 @@ func TestStreamableMcpHeaderValidationErrorFormat(t *testing.T) {
})
defer handler.closeAll()
// TODO(SEP-2575): drop discoverInterceptor and hit `handler` directly
// once Server.discover returns a real DiscoverResult instead of
// MethodNotFound. See comment on discoverInterceptor for details.
wrapped := discoverInterceptor(t, handler,
[]string{minVersionForStandardHeaders},
&ServerCapabilities{Tools: &ToolCapabilities{}},
&Implementation{Name: "testServer", Version: "v1.0.0"},
)
httpServer := httptest.NewServer(mustNotPanic(t, wrapped))
httpServer := httptest.NewServer(mustNotPanic(t, handler))
defer httpServer.Close()
// Use the MCP client with a custom RoundTripper to inject a bad header.
@ -2239,15 +2231,7 @@ func TestStreamableParamHeadersClientSetsHeaders(t *testing.T) {
Stateless: true,
})
defer handler.closeAll()
// TODO(SEP-2575): drop discoverInterceptor and hit `handler` directly
// once Server.discover returns a real DiscoverResult instead of
// MethodNotFound. See comment on discoverInterceptor for details.
wrapped := discoverInterceptor(t, handler,
[]string{minVersionForStandardHeaders},
&ServerCapabilities{Tools: &ToolCapabilities{ListChanged: true}},
&Implementation{Name: "testServer", Version: "v1.0.0"},
)
httpServer := httptest.NewServer(mustNotPanic(t, wrapped))
httpServer := httptest.NewServer(mustNotPanic(t, handler))
defer httpServer.Close()
var capturedHeaders http.Header
@ -2361,15 +2345,7 @@ func TestStreamableFilterValidToolsIntegration(t *testing.T) {
Stateless: true,
})
defer handler.closeAll()
// TODO(SEP-2575): drop discoverInterceptor and hit `handler` directly
// once Server.discover returns a real DiscoverResult instead of
// MethodNotFound. See comment on discoverInterceptor for details.
wrapped := discoverInterceptor(t, handler,
[]string{minVersionForStandardHeaders},
&ServerCapabilities{Tools: &ToolCapabilities{ListChanged: true}},
&Implementation{Name: "testServer", Version: "v1.0.0"},
)
httpServer := httptest.NewServer(mustNotPanic(t, wrapped))
httpServer := httptest.NewServer(mustNotPanic(t, handler))
defer httpServer.Close()
client := NewClient(&Implementation{Name: "testClient", Version: "v1.0.0"}, nil)
@ -2382,7 +2358,9 @@ func TestStreamableFilterValidToolsIntegration(t *testing.T) {
}
defer session.Close()
result, err := session.ListTools(ctx, nil)
// Pass non-nil params so the SEP-2575 per-request _meta triple is
// injected; injectMeta is a no-op when params is nil.
result, err := session.ListTools(ctx, &ListToolsParams{})
if err != nil {
t.Fatal(err)
}
@ -2690,51 +2668,6 @@ func TestStreamableSessionTimeout(t *testing.T) {
handler.mu.Unlock()
}
// discoverInterceptor wraps an HTTP handler so that POST requests carrying a
// server/discover JSON-RPC request are answered with a canned DiscoverResult
// advertising the given supportedVersions. All other requests are forwarded
// to next unchanged.
//
// TODO(SEP-2575): this is a workaround for tests that need an end-to-end
// SEP-2575 session (e.g. to exercise the Mcp-Method / Mcp-Param-* request
// headers gated on protocol >= 2026-06-30) while the server-side
// Server.discover implementation still returns MethodNotFound. Once
// server-side discover is implemented, this helper can be removed and the
// tests can hit the real handler directly.
func discoverInterceptor(t *testing.T, next http.Handler, supportedVersions []string, capabilities *ServerCapabilities, serverInfo *Implementation) http.Handler {
t.Helper()
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodPost {
next.ServeHTTP(w, req)
return
}
body, err := io.ReadAll(req.Body)
req.Body.Close()
if err != nil {
http.Error(w, "failed to read body", http.StatusBadRequest)
return
}
req.Body = io.NopCloser(bytes.NewReader(body))
msg, err := jsonrpc.DecodeMessage(body)
if err != nil {
next.ServeHTTP(w, req)
return
}
r, ok := msg.(*jsonrpc.Request)
if !ok || r.Method != methodDiscover {
next.ServeHTTP(w, req)
return
}
result := &DiscoverResult{
SupportedVersions: supportedVersions,
Capabilities: capabilities,
ServerInfo: serverInfo,
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(jsonBody(t, &jsonrpc.Response{ID: r.ID, Result: mustMarshal(result)})))
})
}
// mustNotPanic is a helper to enforce that test handlers do not panic (see
// issue #556).
func mustNotPanic(t *testing.T, h http.Handler) http.Handler {
@ -3630,3 +3563,156 @@ func TestStreamableClientUnsupportedVersionFallback(t *testing.T) {
t.Errorf("Ping after fallback initialize: %v", err)
}
}
// TestStreamableStateful_AcceptsDiscover verifies that a stateful HTTP server
// accepts a server/discover probe carrying MCP-Protocol-Version: 2026-06-30
// (and the matching _meta.protocolVersion), instead of rejecting it with the
// "stateless required" 400. The SEP-2575 client flow has the client probing
// the server with the new protocol version to learn which versions are
// supported.
func TestStreamableStateful_AcceptsDiscover(t *testing.T) {
orig := supportedProtocolVersions
supportedProtocolVersions = append(slices.Clone(orig), protocolVersion20260630)
t.Cleanup(func() { supportedProtocolVersions = orig })
server := NewServer(testImpl, nil)
handler := NewStreamableHTTPHandler(func(*http.Request) *Server { return server }, nil)
httpServer := httptest.NewServer(mustNotPanic(t, handler))
defer httpServer.Close()
body, err := json.Marshal(map[string]any{
"jsonrpc": "2.0",
"id": 1,
"method": methodDiscover,
"params": map[string]any{
"_meta": map[string]any{
MetaKeyProtocolVersion: protocolVersion20260630,
MetaKeyClientInfo: map[string]any{"name": "new-proto-client", "version": "9.9"},
MetaKeyClientCapabilities: map[string]any{"sampling": map[string]any{}},
},
},
})
if err != nil {
t.Fatal(err)
}
req, err := http.NewRequest(http.MethodPost, httpServer.URL, bytes.NewReader(body))
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, protocolVersion20260630)
req.Header.Set(methodHeader, methodDiscover)
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
respBody, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want 200; body = %s", resp.StatusCode, respBody)
}
// Parse the JSON-RPC response. The body may arrive as a bare JSON object
// or as a single SSE event depending on the Accept negotiation; both
// shapes are valid here.
jsonPayload := respBody
if i := bytes.Index(respBody, []byte("data: ")); i >= 0 {
jsonPayload = respBody[i+len("data: "):]
if j := bytes.IndexByte(jsonPayload, '\n'); j >= 0 {
jsonPayload = jsonPayload[:j]
}
}
var rpcResp struct {
Result *DiscoverResult `json:"result"`
Error *jsonrpc.Error `json:"error"`
}
if err := json.Unmarshal(jsonPayload, &rpcResp); err != nil {
t.Fatalf("unmarshal response %q: %v", respBody, err)
}
if rpcResp.Error != nil {
t.Fatalf("discover returned error: %+v (body = %s)", rpcResp.Error, respBody)
}
if rpcResp.Result == nil {
t.Fatalf("discover returned no result; body = %s", respBody)
}
if slices.Contains(rpcResp.Result.SupportedVersions, protocolVersion20260630) {
t.Errorf("DiscoverResult.SupportedVersions = %v, must not include %q on a stateful transport",
rpcResp.Result.SupportedVersions, protocolVersion20260630)
}
if len(rpcResp.Result.SupportedVersions) == 0 {
t.Errorf("DiscoverResult.SupportedVersions is empty; want at least one legacy version")
}
}
// TestStreamableHTTP_E2E_DiscoverSuccess is a full end-to-end smoke test for
// SEP-2575 over the streamable HTTP transport.
func TestStreamableHTTP_E2E_DiscoverSuccess(t *testing.T) {
ctx := context.Background()
orig := supportedProtocolVersions
supportedProtocolVersions = append([]string{protocolVersion20260630}, slices.Clone(orig)...)
t.Cleanup(func() { supportedProtocolVersions = orig })
server := NewServer(&Implementation{Name: "e2e-server", Version: "v1"}, nil)
// Register a simple tool so we can prove the session is usable end-to-end.
AddTool(server, &Tool{Name: "echo", Description: "echoes its input"},
func(_ context.Context, _ *CallToolRequest, args struct {
Msg string `json:"msg"`
}) (*CallToolResult, struct{}, error) {
return &CallToolResult{
Content: []Content{&TextContent{Text: args.Msg}},
}, struct{}{}, nil
},
)
handler := NewStreamableHTTPHandler(
func(*http.Request) *Server { return server },
&StreamableHTTPOptions{Stateless: true},
)
httpServer := httptest.NewServer(handler)
defer httpServer.Close()
client := NewClient(&Implementation{Name: "e2e-client", Version: "v1"}, nil)
transport := &StreamableClientTransport{Endpoint: httpServer.URL}
cs, err := client.Connect(ctx, transport, &ClientSessionOptions{protocolVersion: protocolVersion20260630})
if err != nil {
t.Fatalf("Connect: %v", err)
}
defer cs.Close()
ir := cs.InitializeResult()
if ir == nil {
t.Fatal("InitializeResult is nil after Connect; discover should have populated it")
}
if ir.ProtocolVersion != protocolVersion20260630 {
t.Errorf("InitializeResult.ProtocolVersion = %q, want %q (negotiated via discover)",
ir.ProtocolVersion, protocolVersion20260630)
}
if ir.ServerInfo == nil || ir.ServerInfo.Name != "e2e-server" {
t.Errorf("InitializeResult.ServerInfo = %+v, want name=e2e-server", ir.ServerInfo)
}
// Prove the session is fully usable: list tools and call one.
tools, err := cs.ListTools(ctx, nil)
if err != nil {
t.Fatalf("ListTools: %v", err)
}
if len(tools.Tools) != 1 || tools.Tools[0].Name != "echo" {
t.Errorf("ListTools = %+v, want one tool named 'echo'", tools.Tools)
}
res, err := cs.CallTool(ctx, &CallToolParams{
Name: "echo",
Arguments: map[string]any{"msg": "hello"},
})
if err != nil {
t.Fatalf("CallTool: %v", err)
}
if len(res.Content) != 1 {
t.Fatalf("CallTool result content = %+v, want 1 entry", res.Content)
}
tc, ok := res.Content[0].(*TextContent)
if !ok || tc.Text != "hello" {
t.Errorf("CallTool result[0] = %+v, want TextContent{Text:\"hello\"}", res.Content[0])
}
}

View file

@ -48,6 +48,19 @@ type Transport interface {
Connect(ctx context.Context) (Connection, error)
}
// ProtocolVersionSupporter is an optional capability that a [Transport] may
// implement to declare which MCP protocol versions it can serve.
//
// [Server.Connect] consults this interface to filter the
// list of versions advertised in server/discover responses. Transports that
// do not implement this interface are assumed to support every protocol
// version known to the SDK.
type ProtocolVersionSupporter interface {
// SupportsProtocolVersion reports whether the transport can serve
// requests using the given protocol version.
SupportsProtocolVersion(version string) bool
}
// A Connection is a logical bidirectional JSON-RPC connection.
type Connection interface {
// Read reads the next message to process off the connection.