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
12 changed files with 702 additions and 187 deletions

View file

@ -39,6 +39,17 @@ Options listed below were added and will be removed in the 1.9.0 version of the
restoring the previous behavior. The default behavior was changed so that
stateless servers ignore session IDs entirely and reject `DELETE` with 405.
- `nomethodnotfoundcodeinerror` added. If set to `1`, the jsonrpc2 layer will not
include the MethodNotFound Error (`-32601`) in the error response when the
requested method in STDIO transport is not found. The default behavior was
changed to include the MethodNotFound Error in the error response when the
requested method in STDIO transport is not found.
- `noprotocolerrorbody` added. If set to `1`, the streamable HTTP client will
not attempt to decode the JSON-RPC error body of a non-2xx HTTP response,
restoring the previous behavior. The default behavior was changed so that
the client always attempts to surface the underlying JSON-RPC error.
### 1.6.0
Options listed below were added and will be removed in the 1.8.0 version of the SDK.

View file

@ -38,6 +38,17 @@ Options listed below were added and will be removed in the 1.9.0 version of the
restoring the previous behavior. The default behavior was changed so that
stateless servers ignore session IDs entirely and reject `DELETE` with 405.
- `nomethodnotfoundcodeinerror` added. If set to `1`, the jsonrpc2 layer will not
include the MethodNotFound Error (`-32601`) in the error response when the
requested method in STDIO transport is not found. The default behavior was
changed to include the MethodNotFound Error in the error response when the
requested method in STDIO transport is not found.
- `noprotocolerrorbody` added. If set to `1`, the streamable HTTP client will
not attempt to decode the JSON-RPC error body of a non-2xx HTTP response,
restoring the previous behavior. The default behavior was changed so that
the client always attempts to surface the underlying JSON-RPC error.
### 1.6.0
Options listed below were added and will be removed in the 1.8.0 version of the SDK.

View file

@ -14,8 +14,25 @@ import (
"time"
"github.com/modelcontextprotocol/go-sdk/internal/json"
"github.com/modelcontextprotocol/go-sdk/internal/mcpgodebug"
)
// nomethodnotfoundcodeinerror is a compatibility parameter that restores the
// pre-fix behavior of [processResult], where wrapped [ErrNotHandled] or
// [ErrMethodNotFound] errors returned by request handlers were not
// recognized as "method not found" signals. The original switch statement
// compared sentinel errors with ==, which never matched errors returned via
// fmt.Errorf("%w: ...", ErrNotHandled, ...) — including the ones produced
// by checkRequest. As a result the wire error response carried code 0
// instead of code -32601. The fix uses errors.Is to recognize wrapped
// sentinels and append the method name to the message.
//
// To restore the previous behavior, set MCPGODEBUG=nomethodnotfoundcodeinerror=1.
// This option will be removed in a future SDK version.
// See the documentation for the mcpgodebug package for instructions on how
// to use it.
var nomethodnotfoundcodeinerror = mcpgodebug.Value("nomethodnotfoundcodeinerror")
// Connection manages the jsonrpc2 protocol, connecting responses back to their
// calls. Connection is bidirectional; it does not have a designated server or
// client end.
@ -648,9 +665,7 @@ func (c *Connection) handleAsync() {
// processResult processes the result of a request and, if appropriate, sends a response.
func (c *Connection) processResult(from any, req *incomingRequest, result any, err error) error {
switch err {
case ErrNotHandled, ErrMethodNotFound:
// Add detail describing the unhandled method.
if nomethodnotfoundcodeinerror != "1" && (errors.Is(err, ErrNotHandled) || errors.Is(err, ErrMethodNotFound)) {
err = fmt.Errorf("%w: %q", ErrMethodNotFound, req.Method)
}

View file

@ -33,7 +33,7 @@ var (
// ErrServerClosing is returned for calls that arrive while the server is closing.
ErrServerClosing = NewError(-32006, "server is closing")
// ErrClientClosing is a dummy error returned for calls initiated while the client is closing.
ErrClientClosing = NewError(-32003, "client is closing")
ErrClientClosing = NewError(-32007, "client is closing")
// The following errors have special semantics for MCP transports

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.