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:
parent
00d3642840
commit
dd978160ab
12 changed files with 702 additions and 187 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Reference in a new issue