mcp: allow restricting the protocol versions a server supports (#1199)
Fixes #945
This commit is contained in:
parent
5a89b63764
commit
21c18c6229
9 changed files with 397 additions and 35 deletions
|
|
@ -44,6 +44,10 @@ type Server struct {
|
|||
// fixed at creation
|
||||
impl *Implementation
|
||||
opts ServerOptions
|
||||
// protocolVersions is the descending-ordered list of protocol versions
|
||||
// this server advertises and negotiates: [supportedProtocolVersions],
|
||||
// narrowed by [ServerOptions.SupportedProtocolVersions].
|
||||
protocolVersions []string
|
||||
|
||||
mu sync.Mutex
|
||||
prompts *featureSet[*serverPrompt]
|
||||
|
|
@ -169,6 +173,23 @@ type ServerOptions struct {
|
|||
// GetSessionID is not consulted when [StreamableHTTPOptions.Stateless] is
|
||||
// true, since stateless servers do not maintain sessions.
|
||||
GetSessionID func() string
|
||||
|
||||
// SupportedProtocolVersions, if non-empty, restricts the protocol versions
|
||||
// this server advertises. If empty, every version returned by
|
||||
// [SupportedProtocolVersions] is used.
|
||||
// The list can only narrow support, never widen it.
|
||||
// A request using the >= 2026-07-28 protocol at an excluded version is
|
||||
// rejected with error code [CodeUnsupportedProtocolVersion].
|
||||
//
|
||||
// The legacy initialize handshake never rejects. The lifecycle spec
|
||||
// requires the server to answer a request it does not support with a
|
||||
// version it does, so an excluded version is answered with the newest
|
||||
// listed version that handshake can negotiate, and the client is expected
|
||||
// to disconnect when it cannot speak it. A list holding no such version
|
||||
// (one restricted to 2026-07-28 and later) is answered with 2025-11-25,
|
||||
// the newest handshake-era version, so that the client disconnects rather
|
||||
// than reading the answer as a negotiation into the new protocol.
|
||||
SupportedProtocolVersions []string
|
||||
}
|
||||
|
||||
// NewServer creates a new MCP server. The resulting server has no features:
|
||||
|
|
@ -205,6 +226,18 @@ func NewServer(impl *Implementation, options *ServerOptions) *Server {
|
|||
opts.GetSessionID = rand.Text
|
||||
}
|
||||
|
||||
protocolVersions := SupportedProtocolVersions()
|
||||
if len(opts.SupportedProtocolVersions) > 0 {
|
||||
for _, v := range opts.SupportedProtocolVersions {
|
||||
if !slices.Contains(supportedProtocolVersions, v) {
|
||||
panic(fmt.Errorf("unsupported protocol version %q", v))
|
||||
}
|
||||
}
|
||||
protocolVersions = slices.DeleteFunc(protocolVersions, func(v string) bool {
|
||||
return !slices.Contains(opts.SupportedProtocolVersions, v)
|
||||
})
|
||||
}
|
||||
|
||||
if opts.Logger == nil { // ensure we have a logger
|
||||
opts.Logger = ensureLogger(nil)
|
||||
}
|
||||
|
|
@ -215,6 +248,7 @@ func NewServer(impl *Implementation, options *ServerOptions) *Server {
|
|||
s := &Server{
|
||||
impl: impl,
|
||||
opts: opts,
|
||||
protocolVersions: protocolVersions,
|
||||
prompts: newFeatureSet(func(p *serverPrompt) string { return p.prompt.Name }),
|
||||
tools: newFeatureSet(func(t *serverTool) string { return t.tool.Name }),
|
||||
resources: newFeatureSet(func(r *serverResource) string { return r.resource.URI }),
|
||||
|
|
@ -886,7 +920,7 @@ func (s *Server) discover(_ context.Context, req *ServerRequest[*DiscoverParams]
|
|||
versions := req.Session.supportedVersions
|
||||
req.Session.mu.Unlock()
|
||||
if versions == nil {
|
||||
versions = slices.Clone(supportedProtocolVersions)
|
||||
versions = slices.Clone(s.protocolVersions)
|
||||
}
|
||||
// Read the request-scoped identity/capabilities before acquiring the
|
||||
// session lock: these accessors may fall back to Session.InitializeParams
|
||||
|
|
@ -903,7 +937,7 @@ func (s *Server) discover(_ context.Context, req *ServerRequest[*DiscoverParams]
|
|||
// is never surfaced to the client via Mcp-Session-Id; leaving
|
||||
// InitializeParams nil lets serveStatefulPOST's safety-net cleanup
|
||||
// close it instead of leaking.
|
||||
if supportedVersion := negotiateMutuallySupportedVersion(versions); supportedVersion >= protocolVersion20260728 {
|
||||
if slices.ContainsFunc(versions, func(v string) bool { return v >= protocolVersion20260728 }) {
|
||||
req.Session.updateState(func(state *ServerSessionState) {
|
||||
state.InitializeParams = init
|
||||
})
|
||||
|
|
@ -917,16 +951,16 @@ func (s *Server) discover(_ context.Context, req *ServerRequest[*DiscoverParams]
|
|||
return res, nil
|
||||
}
|
||||
|
||||
// filterSupportedVersions returns the subset of [supportedProtocolVersions]
|
||||
// that the Transport can serve. If t does not implement [ProtocolVersionSupporter], every
|
||||
// SDK-supported version is included.
|
||||
func filterSupportedVersions(t Transport) []string {
|
||||
// filterSupportedVersions returns the subset of versions that the Transport
|
||||
// can serve. If t does not implement [ProtocolVersionSupporter], every version
|
||||
// is included.
|
||||
func filterSupportedVersions(t Transport, versions []string) []string {
|
||||
pvs, ok := t.(ProtocolVersionSupporter)
|
||||
if !ok {
|
||||
return slices.Clone(supportedProtocolVersions)
|
||||
return slices.Clone(versions)
|
||||
}
|
||||
out := make([]string, 0, len(supportedProtocolVersions))
|
||||
for _, v := range supportedProtocolVersions {
|
||||
out := make([]string, 0, len(versions))
|
||||
for _, v := range versions {
|
||||
if pvs.SupportsProtocolVersion(v) {
|
||||
out = append(out, v)
|
||||
}
|
||||
|
|
@ -1398,7 +1432,7 @@ func (s *Server) Connect(ctx context.Context, t Transport, opts *ServerSessionOp
|
|||
// but without the lock the Go memory model gives the read goroutine no
|
||||
// guarantee of seeing this write, and -race flags it.
|
||||
ss.mu.Lock()
|
||||
ss.supportedVersions = filterSupportedVersions(t)
|
||||
ss.supportedVersions = filterSupportedVersions(t, s.protocolVersions)
|
||||
ss.mu.Unlock()
|
||||
|
||||
// Start keepalive before returning the session to avoid race conditions with Close.
|
||||
|
|
@ -1492,7 +1526,7 @@ type ServerSession struct {
|
|||
mcpConn Connection
|
||||
keepaliveCancel context.CancelFunc
|
||||
|
||||
// supportedVersions is the subset of [supportedProtocolVersions] that the
|
||||
// supportedVersions is the subset of [Server.protocolVersions] that the
|
||||
// transport can actually serve, computed once at connection time from
|
||||
// [ProtocolVersionSupporter] (if implemented by the transport) and used by
|
||||
// the SEP-2575 server/discover handler.
|
||||
|
|
@ -1896,9 +1930,9 @@ func (ss *ServerSession) handle(ctx context.Context, req *jsonrpc.Request) (any,
|
|||
}
|
||||
|
||||
if validatedMeta.usesNewProtocol &&
|
||||
!slices.Contains(supportedProtocolVersions, validatedMeta.initializeParams.ProtocolVersion) {
|
||||
!slices.Contains(ss.server.protocolVersions, validatedMeta.initializeParams.ProtocolVersion) {
|
||||
data, _ := json.Marshal(UnsupportedProtocolVersionData{
|
||||
Supported: supportedProtocolVersions,
|
||||
Supported: ss.server.protocolVersions,
|
||||
Requested: validatedMeta.initializeParams.ProtocolVersion,
|
||||
})
|
||||
return nil, &jsonrpc.Error{
|
||||
|
|
@ -2021,12 +2055,17 @@ func (ss *ServerSession) initialize(ctx context.Context, params *InitializeParam
|
|||
if params == nil {
|
||||
return nil, fmt.Errorf("%w: \"params\" must be be provided", jsonrpc2.ErrInvalidParams)
|
||||
}
|
||||
var wasInit bool
|
||||
var (
|
||||
wasInit bool
|
||||
negotiated string
|
||||
)
|
||||
ss.updateState(func(state *ServerSessionState) {
|
||||
wasInit = state.InitializeParams != nil
|
||||
if !wasInit {
|
||||
state.InitializeParams = params
|
||||
state.NegotiatedProtocolVersion = negotiatedVersion(params.ProtocolVersion, ss.server.protocolVersions)
|
||||
}
|
||||
negotiated = state.NegotiatedProtocolVersion
|
||||
})
|
||||
if wasInit {
|
||||
ss.server.opts.Logger.Error("duplicate initialize request")
|
||||
|
|
@ -2037,7 +2076,7 @@ func (ss *ServerSession) initialize(ctx context.Context, params *InitializeParam
|
|||
return &InitializeResult{
|
||||
// TODO(rfindley): alter behavior when falling back to an older version:
|
||||
// reject unsupported features.
|
||||
ProtocolVersion: negotiatedVersion(params.ProtocolVersion),
|
||||
ProtocolVersion: negotiated,
|
||||
Capabilities: s.capabilities(),
|
||||
Instructions: s.opts.Instructions,
|
||||
ServerInfo: s.impl,
|
||||
|
|
|
|||
|
|
@ -1691,3 +1691,164 @@ func TestServerSession_RejectsServerInitiated(t *testing.T) {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestServerSupportedProtocolVersions verifies that
|
||||
// [ServerOptions.SupportedProtocolVersions] narrows both the versions the
|
||||
// server advertises in server/discover and the versions it negotiates,
|
||||
// and that it cannot widen SDK support.
|
||||
func TestServerSupportedProtocolVersions(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
t.Run("discover advertises the configured subset", func(t *testing.T) {
|
||||
server := NewServer(testImpl, &ServerOptions{
|
||||
// Deliberately unordered: the server must advertise newest first.
|
||||
SupportedProtocolVersions: []string{protocolVersion20251125, protocolVersion20260728},
|
||||
})
|
||||
var advertised []string
|
||||
server.AddReceivingMiddleware(func(next MethodHandler) MethodHandler {
|
||||
return func(ctx context.Context, method string, req Request) (Result, error) {
|
||||
res, err := next(ctx, method, req)
|
||||
if method == methodDiscover && err == nil {
|
||||
advertised = res.(*DiscoverResult).SupportedVersions
|
||||
}
|
||||
return res, err
|
||||
}
|
||||
})
|
||||
|
||||
ct, st := NewInMemoryTransports()
|
||||
ss, err := server.Connect(ctx, st, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("server.Connect: %v", err)
|
||||
}
|
||||
defer ss.Close()
|
||||
|
||||
cs, err := NewClient(testImpl, nil).Connect(ctx, ct, &ClientSessionOptions{
|
||||
ProtocolVersion: protocolVersion20260728,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("client.Connect: %v", err)
|
||||
}
|
||||
defer cs.Close()
|
||||
|
||||
want := []string{protocolVersion20260728, protocolVersion20251125}
|
||||
if diff := cmp.Diff(want, advertised); diff != "" {
|
||||
t.Errorf("DiscoverResult.SupportedVersions mismatch (-want +got):\n%s", diff)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("initialize negotiates within the configured subset", func(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
supported []string
|
||||
client string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "requested version supported",
|
||||
supported: []string{protocolVersion20251125, protocolVersion20250618},
|
||||
client: protocolVersion20250618,
|
||||
want: protocolVersion20250618,
|
||||
},
|
||||
{
|
||||
name: "requested version excluded",
|
||||
supported: []string{protocolVersion20251125, protocolVersion20250618},
|
||||
client: protocolVersion20241105,
|
||||
want: protocolVersion20251125,
|
||||
},
|
||||
{
|
||||
name: "fallback is the newest configured version",
|
||||
supported: []string{protocolVersion20250326},
|
||||
client: protocolVersion20241105,
|
||||
want: protocolVersion20250326,
|
||||
},
|
||||
{
|
||||
// Nothing the server supports can carry an initialize
|
||||
// handshake, so the answer names a handshake-era version the
|
||||
// client can act on rather than one it could mistake for the
|
||||
// new protocol.
|
||||
name: "server with no handshake version",
|
||||
supported: []string{protocolVersion20260728},
|
||||
client: protocolVersion20250618,
|
||||
want: protocolVersion20251125,
|
||||
},
|
||||
{
|
||||
name: "unrestricted server keeps the default behavior",
|
||||
supported: nil,
|
||||
client: protocolVersion20241105,
|
||||
want: protocolVersion20241105,
|
||||
},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
server := NewServer(testImpl, &ServerOptions{SupportedProtocolVersions: test.supported})
|
||||
ct, st := NewInMemoryTransports()
|
||||
ss, err := server.Connect(ctx, st, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("server.Connect: %v", err)
|
||||
}
|
||||
defer ss.Close()
|
||||
|
||||
cs, err := NewClient(testImpl, nil).Connect(ctx, ct, &ClientSessionOptions{
|
||||
ProtocolVersion: test.client,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("client.Connect: %v", err)
|
||||
}
|
||||
defer cs.Close()
|
||||
|
||||
if got := cs.InitializeResult().ProtocolVersion; got != test.want {
|
||||
t.Errorf("negotiated protocol version = %q, want %q", got, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("unknown version panics", func(t *testing.T) {
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Error("NewServer did not panic on an unsupported protocol version")
|
||||
}
|
||||
}()
|
||||
NewServer(testImpl, &ServerOptions{SupportedProtocolVersions: []string{"1999-01-01"}})
|
||||
})
|
||||
}
|
||||
|
||||
// TestServerSupportedProtocolVersions_NewProtocol verifies that a request
|
||||
// using the SEP-2575 per-request protocol is rejected with
|
||||
// [CodeUnsupportedProtocolVersion] when its version is excluded by
|
||||
// [ServerOptions.SupportedProtocolVersions].
|
||||
func TestServerSupportedProtocolVersions_NewProtocol(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
server := NewServer(testImpl, &ServerOptions{
|
||||
SupportedProtocolVersions: []string{protocolVersion20251125},
|
||||
})
|
||||
ct, st := NewInMemoryTransports()
|
||||
ss, err := server.Connect(ctx, st, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("server.Connect: %v", err)
|
||||
}
|
||||
defer ss.Close()
|
||||
_ = ct
|
||||
|
||||
params := fmt.Sprintf(`{"_meta":{%q:%q,%q:{}}}`,
|
||||
MetaKeyProtocolVersion, protocolVersion20260728, MetaKeyClientCapabilities)
|
||||
_, err = ss.handle(ctx, &jsonrpc.Request{
|
||||
ID: jsonrpc2.Int64ID(1),
|
||||
Method: methodListTools,
|
||||
Params: json.RawMessage(params),
|
||||
})
|
||||
var jerr *jsonrpc.Error
|
||||
if !errors.As(err, &jerr) {
|
||||
t.Fatalf("handle returned %v, want a *jsonrpc.Error", err)
|
||||
}
|
||||
if jerr.Code != CodeUnsupportedProtocolVersion {
|
||||
t.Fatalf("error code = %d, want %d", jerr.Code, CodeUnsupportedProtocolVersion)
|
||||
}
|
||||
var data UnsupportedProtocolVersionData
|
||||
if err := json.Unmarshal(jerr.Data, &data); err != nil {
|
||||
t.Fatalf("unmarshal error data: %v", err)
|
||||
}
|
||||
if diff := cmp.Diff([]string{protocolVersion20251125}, data.Supported); diff != "" {
|
||||
t.Errorf("UnsupportedProtocolVersionData.Supported mismatch (-want +got):\n%s", diff)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,6 +22,13 @@ type ServerSessionState struct {
|
|||
// InitializedParams are the parameters from 'notifications/initialized'.
|
||||
InitializedParams *InitializedParams `json:"initializedParams"`
|
||||
|
||||
// NegotiatedProtocolVersion is the protocol version agreed during
|
||||
// 'initialize', which may differ from the version the client requested if
|
||||
// the server does not support it.
|
||||
//
|
||||
// It is empty for sessions that never ran the initialize handshake.
|
||||
NegotiatedProtocolVersion string `json:"negotiatedProtocolVersion,omitempty"`
|
||||
|
||||
// LogLevel is the logging level for the session.
|
||||
LogLevel LoggingLevel `json:"logLevel"`
|
||||
|
||||
|
|
|
|||
|
|
@ -63,18 +63,38 @@ var supportedProtocolVersions = []string{
|
|||
protocolVersion20241105,
|
||||
}
|
||||
|
||||
// SupportedProtocolVersions returns the protocol versions supported by this
|
||||
// version of the SDK, newest first.
|
||||
//
|
||||
// Use it to discover the values accepted by
|
||||
// [ServerOptions.SupportedProtocolVersions]. The returned slice is a copy:
|
||||
// modifying it does not change what the SDK supports.
|
||||
func SupportedProtocolVersions() []string { return slices.Clone(supportedProtocolVersions) }
|
||||
|
||||
// negotiatedVersion returns the effective protocol version to use, given a
|
||||
// client version.
|
||||
func negotiatedVersion(clientVersion string) string {
|
||||
// In general, prefer to use the clientVersion, but if we don't support the
|
||||
// client's version, use the latest version.
|
||||
// client version and the versions the server supports, newest first.
|
||||
func negotiatedVersion(clientVersion string, supported []string) string {
|
||||
// In general, prefer to use the clientVersion, but if not supported
|
||||
// by the server, use the latest version the server supports.
|
||||
//
|
||||
// Cap the supported versions at the legacy protocolVersion20251125, as this
|
||||
// method is used by the initialize method which is deprecated in
|
||||
// version protocolVersion20260728.
|
||||
if slices.Contains(supportedProtocolVersions, clientVersion) && clientVersion < protocolVersion20260728 {
|
||||
if slices.Contains(supported, clientVersion) && clientVersion < protocolVersion20260728 {
|
||||
return clientVersion
|
||||
}
|
||||
for _, v := range supported {
|
||||
if v < protocolVersion20260728 {
|
||||
return v
|
||||
}
|
||||
}
|
||||
// If the server was enforced to support only protocolVersion20260728 and
|
||||
// later, the initialize function should not be called.
|
||||
// If a client calls the initialize function, return the protocolVersion20251125.
|
||||
// As according to spec (https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle#version-negotiation):
|
||||
// "If the server supports the requested protocol version, it MUST respond with the same version."
|
||||
// "Otherwise, the server MUST respond with another protocol version it supports."
|
||||
// "This SHOULD be the latest version supported by the server."
|
||||
return protocolVersion20251125
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ import (
|
|||
// stdio and Streamable HTTP (see #1112).
|
||||
func TestSSEServerTransport_SupportedVersions(t *testing.T) {
|
||||
var tr SSEServerTransport
|
||||
versions := filterSupportedVersions(&tr)
|
||||
versions := filterSupportedVersions(&tr, supportedProtocolVersions)
|
||||
if slices.Contains(versions, protocolVersion20260728) {
|
||||
t.Errorf("filterSupportedVersions(SSEServerTransport) = %v, must not include %q",
|
||||
versions, protocolVersion20260728)
|
||||
|
|
|
|||
|
|
@ -363,13 +363,21 @@ func (h *StreamableHTTPHandler) ServeHTTP(w http.ResponseWriter, req *http.Reque
|
|||
}
|
||||
|
||||
// [§2.7] of the spec (2025-06-18): validate the MCP-Protocol-Version
|
||||
// header. If provided, it must be a supported version. If absent, the
|
||||
// version is unknown (the request may be an initialize for any version).
|
||||
// header. If provided, it must be a version the server negotiates, which
|
||||
// [ServerOptions.SupportedProtocolVersions] may have narrowed. If absent,
|
||||
// the version is unknown (the request may be an initialize for any
|
||||
// version).
|
||||
//
|
||||
// [§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) && protocolVersion < protocolVersion20260728 {
|
||||
http.Error(w, fmt.Sprintf("Bad Request: Unsupported protocol version (supported versions: %s)", strings.Join(supportedProtocolVersions, ",")), http.StatusBadRequest)
|
||||
var supportedVersions []string
|
||||
if server := h.getServer(req); server != nil {
|
||||
supportedVersions = server.protocolVersions
|
||||
} else {
|
||||
supportedVersions = supportedProtocolVersions
|
||||
}
|
||||
if protocolVersion != "" && !slices.Contains(supportedVersions, protocolVersion) && protocolVersion < protocolVersion20260728 {
|
||||
http.Error(w, fmt.Sprintf("Bad Request: Unsupported protocol version (supported versions: %s)", strings.Join(supportedVersions, ",")), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
req = req.WithContext(context.WithValue(req.Context(), protocolVersionContextKey{}, protocolVersion))
|
||||
|
|
@ -1541,7 +1549,11 @@ func (c *streamableServerConn) servePOST(w http.ResponseWriter, req *http.Reques
|
|||
// Advertise only the legacy versions this stateful server accepts
|
||||
// (i.e. exclude 2026-07-28, since that's what the request asked for
|
||||
// and what we're rejecting).
|
||||
legacyVersions := slices.DeleteFunc(slices.Clone(supportedProtocolVersions), func(v string) bool {
|
||||
advertised := supportedProtocolVersions
|
||||
if c.server != nil {
|
||||
advertised = c.server.protocolVersions
|
||||
}
|
||||
legacyVersions := slices.DeleteFunc(slices.Clone(advertised), func(v string) bool {
|
||||
return v >= protocolVersion20260728
|
||||
})
|
||||
data, _ := json.Marshal(UnsupportedProtocolVersionData{
|
||||
|
|
|
|||
|
|
@ -4141,3 +4141,80 @@ func TestStreamableMaxRequestBodyBytes(t *testing.T) {
|
|||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestStreamableSupportedProtocolVersions_Header verifies that the
|
||||
// MCP-Protocol-Version header is validated against
|
||||
// [ServerOptions.SupportedProtocolVersions], and not merely against the
|
||||
// versions the SDK knows about.
|
||||
func TestStreamableSupportedProtocolVersions_Header(t *testing.T) {
|
||||
const initBody = `{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}`
|
||||
|
||||
newServer := func(t *testing.T, stateless bool) *httptest.Server {
|
||||
server := NewServer(testImpl, &ServerOptions{
|
||||
SupportedProtocolVersions: []string{protocolVersion20251125},
|
||||
})
|
||||
handler := NewStreamableHTTPHandler(func(*http.Request) *Server { return server },
|
||||
&StreamableHTTPOptions{Stateless: stateless})
|
||||
httpServer := httptest.NewServer(handler)
|
||||
t.Cleanup(httpServer.Close)
|
||||
return httpServer
|
||||
}
|
||||
post := func(t *testing.T, url, version, sessionID string) *http.Response {
|
||||
t.Helper()
|
||||
req, err := http.NewRequest(http.MethodPost, url, strings.NewReader(initBody))
|
||||
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, version)
|
||||
if sessionID != "" {
|
||||
req.Header.Set(sessionIDHeader, sessionID)
|
||||
}
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
for _, stateless := range []bool{false, true} {
|
||||
for _, test := range []struct {
|
||||
version string
|
||||
wantStatus int
|
||||
}{
|
||||
{protocolVersion20251125, http.StatusOK},
|
||||
// Supported by the SDK, but excluded by the server.
|
||||
{protocolVersion20250618, http.StatusBadRequest},
|
||||
// Unknown to the SDK: rejected before any server is consulted.
|
||||
{"1999-01-01", http.StatusBadRequest},
|
||||
} {
|
||||
t.Run(fmt.Sprintf("stateless=%v/%s", stateless, test.version), func(t *testing.T) {
|
||||
resp := post(t, newServer(t, stateless).URL, test.version, "")
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode != test.wantStatus {
|
||||
t.Errorf("status = %d, want %d; body = %s", resp.StatusCode, test.wantStatus, body)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
t.Run("existing session", func(t *testing.T) {
|
||||
httpServer := newServer(t, false)
|
||||
resp := post(t, httpServer.URL, protocolVersion20251125, "")
|
||||
io.Copy(io.Discard, resp.Body)
|
||||
resp.Body.Close()
|
||||
sessionID := resp.Header.Get(sessionIDHeader)
|
||||
if sessionID == "" {
|
||||
t.Fatalf("initialize response missing %s header", sessionIDHeader)
|
||||
}
|
||||
|
||||
resp = post(t, httpServer.URL, protocolVersion20250618, sessionID)
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Errorf("status = %d, want %d; body = %s", resp.StatusCode, http.StatusBadRequest, body)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -114,6 +114,8 @@ type clientConnection interface {
|
|||
// TODO: should this interface be exported?
|
||||
type serverConnection interface {
|
||||
Connection
|
||||
|
||||
// sessionUpdated is called whenever the server session state changes.
|
||||
sessionUpdated(ServerSessionState)
|
||||
}
|
||||
|
||||
|
|
@ -525,17 +527,13 @@ func newIOConn(rwc io.ReadWriteCloser) *ioConn {
|
|||
func (c *ioConn) SessionID() string { return "" }
|
||||
|
||||
func (c *ioConn) sessionUpdated(state ServerSessionState) {
|
||||
protocolVersion := ""
|
||||
if state.InitializeParams != nil {
|
||||
protocolVersion = state.InitializeParams.ProtocolVersion
|
||||
}
|
||||
protocolVersion := state.NegotiatedProtocolVersion
|
||||
if protocolVersion == "" {
|
||||
// 2025-03-26 is used, because it's the last spec version
|
||||
// where specifying the protocol version in the HTTP header
|
||||
// was not required.
|
||||
protocolVersion = protocolVersion20250326
|
||||
}
|
||||
protocolVersion = negotiatedVersion(protocolVersion)
|
||||
c.sessionMu.Lock()
|
||||
c.protocolVersion = protocolVersion
|
||||
c.sessionMu.Unlock()
|
||||
|
|
|
|||
|
|
@ -56,10 +56,15 @@ func TestBatchFraming(t *testing.T) {
|
|||
|
||||
func TestIOConnRead(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
want string
|
||||
name string
|
||||
input string
|
||||
want string
|
||||
// protocolVersion is the version negotiated by 'initialize'; empty
|
||||
// means no session state was pushed to the connection.
|
||||
protocolVersion string
|
||||
// requested is the version the client asked for, which differs from
|
||||
// protocolVersion when the server counter-offered.
|
||||
requested string
|
||||
}{
|
||||
{
|
||||
name: "valid json input",
|
||||
|
|
@ -100,6 +105,16 @@ func TestIOConnRead(t *testing.T) {
|
|||
want: "JSON-RPC batching is not supported in 2025-06-18 and later (request version: 2025-06-18)",
|
||||
protocolVersion: protocolVersion20250618,
|
||||
},
|
||||
{
|
||||
// The client asked for a version the server does not negotiate, so
|
||||
// the connection must follow the server's counter-offer (which
|
||||
// forbids batching) rather than the client's request.
|
||||
name: "batching at a counter-offered version",
|
||||
input: `[{"jsonrpc":"2.0","id":1,"method":"test1"},{"jsonrpc":"2.0","id":2,"method":"test2"}]`,
|
||||
want: "JSON-RPC batching is not supported in 2025-06-18 and later (request version: 2025-11-25)",
|
||||
requested: protocolVersion20241105,
|
||||
protocolVersion: protocolVersion20251125,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
|
|
@ -108,10 +123,15 @@ func TestIOConnRead(t *testing.T) {
|
|||
})
|
||||
t.Cleanup(func() { tr.Close() })
|
||||
if tt.protocolVersion != "" {
|
||||
requested := tt.requested
|
||||
if requested == "" {
|
||||
requested = tt.protocolVersion
|
||||
}
|
||||
tr.sessionUpdated(ServerSessionState{
|
||||
InitializeParams: &InitializeParams{
|
||||
ProtocolVersion: tt.protocolVersion,
|
||||
ProtocolVersion: requested,
|
||||
},
|
||||
NegotiatedProtocolVersion: tt.protocolVersion,
|
||||
})
|
||||
}
|
||||
_, err := tr.Read(context.Background())
|
||||
|
|
|
|||
Loading…
Reference in a new issue