go-sdk/mcp/client_test.go
dev_Hakaze 1acd66e9d7
mcp: export ClientSessionOptions.ProtocolVersion (#1127)
## Summary

`ClientSessionOptions.protocolVersion` was unexported and documented as
test-only, so external consumers of `Client.Connect` could not request a
specific protocol revision.

This exports the field as `ProtocolVersion` with the same semantics:

- empty → request the latest supported version (unchanged default)
- non-empty → send that version in initialize/discover
- existing negotiate-down path is unchanged

## Usage

```go
cs, err := client.Connect(ctx, transport, &mcp.ClientSessionOptions{
    ProtocolVersion: "2025-11-25",
})
```

## Testing

- `go test ./mcp/ -count=1` (pass)

Fixes #1113

Co-authored-by: arimu1 <19286898+arimu1@users.noreply.github.com>
Co-authored-by: Guglielmo Colombo <guglielmoc@google.com>
2026-08-03 15:29:52 +02:00

1094 lines
33 KiB
Go

// Copyright 2025 The Go MCP SDK Authors. All rights reserved.
// Use of this source code is governed by an MIT-style
// license that can be found in the LICENSE file.
//lint:file-ignore SA1019 tests exercise deprecated SEP-2577 APIs (roots, sampling, logging).
package mcp
import (
"context"
"fmt"
"log/slog"
"sync/atomic"
"testing"
"github.com/google/go-cmp/cmp"
"github.com/google/go-cmp/cmp/cmpopts"
"github.com/google/jsonschema-go/jsonschema"
"github.com/modelcontextprotocol/go-sdk/internal/jsonrpc2"
"github.com/modelcontextprotocol/go-sdk/jsonrpc"
)
type Item struct {
Name string
Value string
}
type ListTestParams struct {
Cursor string
}
func (p *ListTestParams) cursorPtr() *string {
return &p.Cursor
}
type ListTestResult struct {
Items []*Item
NextCursor string
}
func (r *ListTestResult) nextCursorPtr() *string {
return &r.NextCursor
}
var allItems = []*Item{
{"alpha", "val-A"},
{"bravo", "val-B"},
{"charlie", "val-C"},
{"delta", "val-D"},
{"echo", "val-E"},
{"foxtrot", "val-F"},
{"golf", "val-G"},
{"hotel", "val-H"},
{"india", "val-I"},
{"juliet", "val-J"},
{"kilo", "val-K"},
}
// generatePaginatedResults is a helper to create a sequence of mock responses for pagination.
// It simulates a server returning items in pages based on a given page size.
func generatePaginatedResults(all []*Item, pageSize int) []*ListTestResult {
if len(all) == 0 {
return []*ListTestResult{{Items: []*Item{}, NextCursor: ""}}
}
if pageSize <= 0 {
panic("pageSize must be greater than 0")
}
numPages := (len(all) + pageSize - 1) / pageSize // Ceiling division
var results []*ListTestResult
for i := range numPages {
startIndex := i * pageSize
endIndex := min(startIndex+pageSize, len(all)) // Use min to prevent out of bounds
nextCursor := ""
if endIndex < len(all) { // If there are more items after this page
nextCursor = fmt.Sprintf("cursor_%d", endIndex)
}
results = append(results, &ListTestResult{Items: all[startIndex:endIndex], NextCursor: nextCursor})
}
return results
}
func TestClientPaginateBasic(t *testing.T) {
ctx := context.Background()
testCases := []struct {
name string
results []*ListTestResult
mockError error
initialParams *ListTestParams
expected []*Item
expectError bool
}{
{
name: "SinglePageAllItems",
results: generatePaginatedResults(allItems, len(allItems)),
expected: allItems,
},
{
name: "MultiplePages",
results: generatePaginatedResults(allItems, 3),
expected: allItems,
},
{
name: "EmptyResults",
results: generatePaginatedResults([]*Item{}, 10),
expected: nil,
},
{
name: "ListFuncReturnsErrorImmediately",
results: []*ListTestResult{{}},
mockError: fmt.Errorf("API error on first call"),
expected: nil,
expectError: true,
},
{
name: "InitialCursorProvided",
initialParams: &ListTestParams{Cursor: "cursor_2"},
results: generatePaginatedResults(allItems[2:], 3),
expected: allItems[2:],
},
{
name: "CursorBeyondAllItems",
initialParams: &ListTestParams{Cursor: "cursor_999"},
results: []*ListTestResult{{Items: []*Item{}, NextCursor: ""}},
expected: nil,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
listFunc := func(ctx context.Context, params *ListTestParams) (*ListTestResult, error) {
if len(tc.results) == 0 {
t.Fatalf("listFunc called but no more results defined for test case %q", tc.name)
}
res := tc.results[0]
tc.results = tc.results[1:]
var err error
if tc.mockError != nil {
err = tc.mockError
}
return res, err
}
params := tc.initialParams
if tc.initialParams == nil {
params = &ListTestParams{}
}
var gotItems []*Item
var iterationErr error
seq := paginate(ctx, params, listFunc, func(r *ListTestResult) []*Item { return r.Items })
for item, err := range seq {
if err != nil {
iterationErr = err
break
}
gotItems = append(gotItems, item)
}
if tc.expectError {
if iterationErr == nil {
t.Errorf("paginate() expected an error during iteration, but got none")
}
} else {
if iterationErr != nil {
t.Errorf("paginate() got: %v, want: nil", iterationErr)
}
}
if diff := cmp.Diff(tc.expected, gotItems, cmpopts.IgnoreUnexported(jsonschema.Schema{})); diff != "" {
t.Fatalf("paginate() mismatch (-want +got):\n%s", diff)
}
})
}
}
func TestClientLogger(t *testing.T) {
// Case 1: No logger provided
c1 := NewClient(&Implementation{Name: "test", Version: "1.0"}, nil)
if c1.opts.Logger == nil {
t.Error("expected default logger, got nil")
}
// Case 2: Logger provided
logger := slog.Default()
c2 := NewClient(&Implementation{Name: "test", Version: "1.0"}, &ClientOptions{
Logger: logger,
})
if c2.opts.Logger != logger {
t.Error("expected provided logger, got different one")
}
}
func TestClientPaginateVariousPageSizes(t *testing.T) {
ctx := context.Background()
for i := 1; i < len(allItems)+1; i++ {
testname := fmt.Sprintf("PageSize=%d", i)
t.Run(testname, func(t *testing.T) {
results := generatePaginatedResults(allItems, i)
listFunc := func(ctx context.Context, params *ListTestParams) (*ListTestResult, error) {
res := results[0]
results = results[1:]
return res, nil
}
var gotItems []*Item
seq := paginate(ctx, &ListTestParams{}, listFunc, func(r *ListTestResult) []*Item { return r.Items })
for item, err := range seq {
if err != nil {
t.Fatalf("paginate() unexpected error during iteration: %v", err)
}
gotItems = append(gotItems, item)
}
if diff := cmp.Diff(allItems, gotItems, cmpopts.IgnoreUnexported(jsonschema.Schema{})); diff != "" {
t.Fatalf("paginate() mismatch (-want +got):\n%s", diff)
}
})
}
}
func TestClientCapabilities(t *testing.T) {
testCases := []struct {
name string
configureClient func(s *Client)
clientOpts ClientOptions
protocolVersion string // defaults to latestProtocolVersion if empty
wantCapabilities *ClientCapabilities
}{
{
name: "default",
configureClient: func(s *Client) {},
wantCapabilities: &ClientCapabilities{
Roots: RootCapabilities{ListChanged: true},
RootsV2: &RootCapabilities{ListChanged: true},
},
},
{
name: "with sampling",
configureClient: func(s *Client) {},
clientOpts: ClientOptions{
CreateMessageHandler: func(context.Context, *CreateMessageRequest) (*CreateMessageResult, error) {
return nil, nil
},
},
wantCapabilities: &ClientCapabilities{
Roots: RootCapabilities{ListChanged: true},
RootsV2: &RootCapabilities{ListChanged: true},
Sampling: &SamplingCapabilities{},
},
},
{
name: "with elicitation",
configureClient: func(s *Client) {},
clientOpts: ClientOptions{
ElicitationHandler: func(context.Context, *ElicitRequest) (*ElicitResult, error) {
return nil, nil
},
},
protocolVersion: protocolVersion20251125,
wantCapabilities: &ClientCapabilities{
Roots: RootCapabilities{ListChanged: true},
RootsV2: &RootCapabilities{ListChanged: true},
Elicitation: &ElicitationCapabilities{
Form: &FormElicitationCapabilities{},
},
},
},
{
name: "with elicitation (old protocol)",
configureClient: func(s *Client) {},
clientOpts: ClientOptions{
ElicitationHandler: func(context.Context, *ElicitRequest) (*ElicitResult, error) {
return nil, nil
},
},
protocolVersion: protocolVersion20250618,
wantCapabilities: &ClientCapabilities{
Roots: RootCapabilities{ListChanged: true},
RootsV2: &RootCapabilities{ListChanged: true},
Elicitation: &ElicitationCapabilities{},
},
},
{
name: "with URL elicitation",
configureClient: func(s *Client) {},
clientOpts: ClientOptions{
Capabilities: &ClientCapabilities{
Roots: RootCapabilities{ListChanged: true},
RootsV2: &RootCapabilities{ListChanged: true},
Elicitation: &ElicitationCapabilities{
URL: &URLElicitationCapabilities{},
},
},
ElicitationHandler: func(context.Context, *ElicitRequest) (*ElicitResult, error) {
return nil, nil
},
},
wantCapabilities: &ClientCapabilities{
Roots: RootCapabilities{ListChanged: true},
RootsV2: &RootCapabilities{ListChanged: true},
Elicitation: &ElicitationCapabilities{
URL: &URLElicitationCapabilities{},
},
},
},
{
name: "with form and URL elicitation",
configureClient: func(s *Client) {},
clientOpts: ClientOptions{
Capabilities: &ClientCapabilities{
Roots: RootCapabilities{ListChanged: true},
RootsV2: &RootCapabilities{ListChanged: true},
Elicitation: &ElicitationCapabilities{
Form: &FormElicitationCapabilities{},
URL: &URLElicitationCapabilities{},
},
},
ElicitationHandler: func(context.Context, *ElicitRequest) (*ElicitResult, error) {
return nil, nil
},
},
wantCapabilities: &ClientCapabilities{
Roots: RootCapabilities{ListChanged: true},
RootsV2: &RootCapabilities{ListChanged: true},
Elicitation: &ElicitationCapabilities{
Form: &FormElicitationCapabilities{},
URL: &URLElicitationCapabilities{},
},
},
},
{
name: "no capabilities",
configureClient: func(s *Client) {},
clientOpts: ClientOptions{
Capabilities: &ClientCapabilities{},
},
wantCapabilities: &ClientCapabilities{},
},
{
name: "no roots",
configureClient: func(s *Client) {},
clientOpts: ClientOptions{
Capabilities: &ClientCapabilities{
Sampling: &SamplingCapabilities{},
},
},
wantCapabilities: &ClientCapabilities{
Sampling: &SamplingCapabilities{},
},
},
{
name: "roots-no list",
configureClient: func(s *Client) {},
clientOpts: ClientOptions{
Capabilities: &ClientCapabilities{
RootsV2: &RootCapabilities{ListChanged: false},
},
},
wantCapabilities: &ClientCapabilities{
RootsV2: &RootCapabilities{ListChanged: false},
},
},
{
name: "custom capabilities with sampling",
configureClient: func(s *Client) {},
clientOpts: ClientOptions{
Capabilities: &ClientCapabilities{
RootsV2: &RootCapabilities{ListChanged: true},
},
CreateMessageHandler: func(context.Context, *CreateMessageRequest) (*CreateMessageResult, error) {
return nil, nil
},
},
wantCapabilities: &ClientCapabilities{
Roots: RootCapabilities{ListChanged: true},
RootsV2: &RootCapabilities{ListChanged: true},
Sampling: &SamplingCapabilities{},
},
},
{
name: "elicitation override",
configureClient: func(s *Client) {},
clientOpts: ClientOptions{
Capabilities: &ClientCapabilities{
Elicitation: &ElicitationCapabilities{
URL: &URLElicitationCapabilities{},
},
},
ElicitationHandler: func(context.Context, *ElicitRequest) (*ElicitResult, error) {
return nil, nil
},
},
wantCapabilities: &ClientCapabilities{
Elicitation: &ElicitationCapabilities{
URL: &URLElicitationCapabilities{},
},
},
},
{
name: "custom capabilities with experimental",
configureClient: func(s *Client) {},
clientOpts: ClientOptions{
Capabilities: &ClientCapabilities{
Experimental: map[string]any{"custom": "value"},
RootsV2: &RootCapabilities{ListChanged: true},
},
},
wantCapabilities: &ClientCapabilities{
Experimental: map[string]any{"custom": "value"},
Roots: RootCapabilities{ListChanged: true},
RootsV2: &RootCapabilities{ListChanged: true},
},
},
{
name: "extensions preserved",
configureClient: func(s *Client) {},
clientOpts: func() ClientOptions {
caps := &ClientCapabilities{
RootsV2: &RootCapabilities{ListChanged: true},
}
caps.AddExtension("io.example/ext1", map[string]any{"key": "value"})
caps.AddExtension("io.example/ext2", nil)
return ClientOptions{Capabilities: caps}
}(),
wantCapabilities: &ClientCapabilities{
Extensions: map[string]any{
"io.example/ext1": map[string]any{"key": "value"},
"io.example/ext2": map[string]any{},
},
Roots: RootCapabilities{ListChanged: true},
RootsV2: &RootCapabilities{ListChanged: true},
},
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
client := NewClient(testImpl, &tc.clientOpts)
tc.configureClient(client)
protocolVersion := tc.protocolVersion
if protocolVersion == "" {
protocolVersion = latestProtocolVersion
}
gotCapabilities := client.capabilities(protocolVersion)
if diff := cmp.Diff(tc.wantCapabilities, gotCapabilities); diff != "" {
t.Errorf("capabilities() mismatch (-want +got):\n%s", diff)
}
})
}
}
func TestLookupTool(t *testing.T) {
tool1 := &Tool{Name: "tool1", Description: "first"}
tool2 := &Tool{Name: "tool2", Description: "second"}
tool1Updated := &Tool{Name: "tool1", Description: "updated"}
// page represents a single cached ListToolsResult, keyed by cursor.
type page struct {
cursor string
tools []*Tool
}
testCases := []struct {
name string
pages []page
lookup string
want *Tool
}{
{
name: "empty cache",
lookup: "tool1",
want: nil,
},
{
name: "single tool found",
pages: []page{{cursor: "", tools: []*Tool{tool1}}},
lookup: "tool1",
want: tool1,
},
{
name: "unknown tool",
pages: []page{{cursor: "", tools: []*Tool{tool1}}},
lookup: "nonexistent",
want: nil,
},
{
name: "multiple tools single page",
pages: []page{{cursor: "", tools: []*Tool{tool1, tool2}}},
lookup: "tool2",
want: tool2,
},
{
name: "tool found across paginated pages",
pages: []page{
{cursor: "", tools: []*Tool{tool1}},
{cursor: "page2", tools: []*Tool{tool2}},
},
lookup: "tool2",
want: tool2,
},
{
name: "re-list same cursor overwrites entry",
pages: []page{
{cursor: "", tools: []*Tool{tool1}},
{cursor: "", tools: []*Tool{tool1Updated}},
},
lookup: "tool1",
want: tool1Updated,
},
{
name: "empty page no-op",
pages: []page{{cursor: "", tools: []*Tool{}}},
lookup: "tool1",
want: nil,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
cs := &ClientSession{}
for _, p := range tc.pages {
cs.toolsCache.put(p.cursor, &ListToolsResult{
Cacheable: Cacheable{TTLMs: 60_000},
Tools: p.tools,
})
}
got := cs.lookupTool(tc.lookup)
if diff := cmp.Diff(tc.want, got); diff != "" {
t.Errorf("lookupTool(%q) mismatch (-want +got):\n%s", tc.lookup, diff)
}
})
}
}
func TestClientCapabilitiesOverWire(t *testing.T) {
testCases := []struct {
name string
clientOpts *ClientOptions
wantCapabilities *ClientCapabilities
}{
{
name: "Default capabilities",
clientOpts: nil,
wantCapabilities: &ClientCapabilities{
Roots: RootCapabilities{ListChanged: true},
RootsV2: &RootCapabilities{ListChanged: true},
},
},
{
name: "Custom Capabilities with roots listChanged false",
clientOpts: &ClientOptions{
Capabilities: &ClientCapabilities{
RootsV2: &RootCapabilities{ListChanged: false},
},
},
wantCapabilities: &ClientCapabilities{
Roots: RootCapabilities{ListChanged: false},
RootsV2: &RootCapabilities{ListChanged: false},
},
},
{
name: "Dynamic sampling capability",
clientOpts: &ClientOptions{
Capabilities: &ClientCapabilities{
RootsV2: &RootCapabilities{ListChanged: true},
},
CreateMessageHandler: func(context.Context, *CreateMessageRequest) (*CreateMessageResult, error) {
return nil, nil
},
},
wantCapabilities: &ClientCapabilities{
Roots: RootCapabilities{ListChanged: true},
RootsV2: &RootCapabilities{ListChanged: true},
Sampling: &SamplingCapabilities{},
},
},
{
name: "Empty capabilities disables defaults",
clientOpts: &ClientOptions{
Capabilities: &ClientCapabilities{},
},
wantCapabilities: &ClientCapabilities{},
},
{
name: "Extensions over wire",
clientOpts: func() *ClientOptions {
caps := &ClientCapabilities{
RootsV2: &RootCapabilities{ListChanged: true},
}
caps.AddExtension("io.example/ext", map[string]any{"key": "value"})
return &ClientOptions{Capabilities: caps}
}(),
wantCapabilities: &ClientCapabilities{
Extensions: map[string]any{
"io.example/ext": map[string]any{"key": "value"},
},
Roots: RootCapabilities{ListChanged: true},
RootsV2: &RootCapabilities{ListChanged: true},
},
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
ctx := context.Background()
// Create client.
impl := &Implementation{Name: "testClient", Version: "v1.0.0"}
client := NewClient(impl, tc.clientOpts)
// Connect client and server.
cTransport, sTransport := NewInMemoryTransports()
server := NewServer(&Implementation{Name: "testServer", Version: "v1.0.0"}, nil)
ss, err := server.Connect(ctx, sTransport, nil)
if err != nil {
t.Fatal(err)
}
defer ss.Close()
cs, err := client.Connect(ctx, cTransport, nil)
if err != nil {
t.Fatal(err)
}
defer cs.Close()
// Check that the server received the expected capabilities.
initParams := ss.InitializeParams()
if initParams == nil {
t.Fatal("InitializeParams is nil")
}
if diff := cmp.Diff(tc.wantCapabilities, initParams.Capabilities); diff != "" {
t.Errorf("Capabilities mismatch (-want +got):\n%s", diff)
}
})
}
}
// TestClientConnectDiscover exercises the SEP-2575 server/discover probe that
// Client.Connect now sends before falling back to the legacy initialize
// handshake.
//
// Each subtest installs a server-side receiving middleware that intercepts the
// "server/discover" method and returns a canned response: a successful
// DiscoverResult, a "Method not found" error, an UnsupportedProtocolVersion
// error, an unrelated failure, or a DiscoverResult whose supportedVersions
// don't overlap with the SDK. The test then asserts the resulting session
// state and whether the legacy initialize handshake ran.
func TestClientConnectDiscover(t *testing.T) {
const otherVersionsOnly = "1999-01-01"
tests := []struct {
name string
// discoverHandler decides how the server replies to server/discover.
// Returning (nil, nil) means "let the default stub handle it" (which
// returns ErrMethodNotFound).
discoverHandler func() (Result, error)
// wantInitialize is true if the legacy initialize handshake should
// have run (i.e. discover signaled "not supported").
wantInitialize bool
// wantVersion is the protocol version expected to end up on
// ClientSession.state.InitializeResult after Connect returns.
wantVersion string
}{
{
name: "discover success skips initialize",
discoverHandler: func() (Result, error) {
return &DiscoverResult{
Meta: Meta{
MetaKeyServerInfo: &Implementation{Name: "discoverServer", Version: "v1.0.0"},
},
SupportedVersions: []string{protocolVersion20260728},
Capabilities: &ServerCapabilities{
Tools: &ToolCapabilities{ListChanged: true},
},
}, nil
},
wantInitialize: false,
wantVersion: protocolVersion20260728,
},
{
name: "method not found falls back to initialize",
discoverHandler: func() (Result, error) {
return nil, jsonrpc2.ErrMethodNotFound
},
wantInitialize: true,
wantVersion: protocolVersion20251125,
},
{
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: protocolVersion20251125,
},
{
name: "no overlapping supported version falls back to initialize",
discoverHandler: func() (Result, error) {
return &DiscoverResult{
Meta: Meta{
MetaKeyServerInfo: &Implementation{Name: "discoverServer", Version: "v1.0.0"},
},
SupportedVersions: []string{otherVersionsOnly},
Capabilities: &ServerCapabilities{},
}, nil
},
wantInitialize: true,
wantVersion: protocolVersion20251125,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
ctx := context.Background()
var (
gotDiscover atomic.Bool
gotInitialize atomic.Bool
)
s := NewServer(testImpl, nil)
s.AddReceivingMiddleware(func(next MethodHandler) MethodHandler {
return func(ctx context.Context, method string, req Request) (Result, error) {
switch method {
case methodDiscover:
gotDiscover.Store(true)
return tc.discoverHandler()
case methodInitialize:
gotInitialize.Store(true)
}
return next(ctx, method, req)
}
})
ct, st := NewInMemoryTransports()
ss, err := s.Connect(ctx, st, nil)
if err != nil {
t.Fatalf("server Connect: %v", err)
}
defer ss.Close()
c := NewClient(testImpl, nil)
cs, err := c.Connect(ctx, ct, &ClientSessionOptions{ProtocolVersion: protocolVersion20260728})
if err != nil {
t.Fatalf("Connect: %v", err)
}
defer cs.Close()
if !gotDiscover.Load() {
t.Error("server did not receive server/discover")
}
if got, want := gotInitialize.Load(), tc.wantInitialize; got != want {
t.Errorf("initialize invoked = %v, want %v", got, want)
}
ir := cs.InitializeResult()
if ir == nil {
t.Fatal("InitializeResult is nil after Connect")
}
if got, want := ir.ProtocolVersion, tc.wantVersion; got != want {
t.Errorf("InitializeResult.ProtocolVersion = %q, want %q", got, want)
}
})
}
}
// TestClientConnectDiscover_RequestContents verifies that the server/discover
// request sent by Client.Connect carries the SEP-2575 per-request _meta triple:
// protocolVersion, clientInfo, and clientCapabilities.
func TestClientConnectDiscover_RequestContents(t *testing.T) {
ctx := context.Background()
type captured struct {
params *DiscoverParams
}
var got captured
s := NewServer(testImpl, nil)
s.AddReceivingMiddleware(func(next MethodHandler) MethodHandler {
return func(ctx context.Context, method string, req Request) (Result, error) {
if method == methodDiscover {
sr, ok := req.(*ServerRequest[*DiscoverParams])
if !ok {
t.Errorf("discover req has unexpected type %T", req)
return nil, jsonrpc2.ErrMethodNotFound
}
got.params = sr.Params
// Make discover "not supported" so Connect proceeds (we only
// care about the discover request payload here).
return nil, jsonrpc2.ErrMethodNotFound
}
return next(ctx, method, req)
}
})
ct, st := NewInMemoryTransports()
ss, err := s.Connect(ctx, st, nil)
if err != nil {
t.Fatalf("server Connect: %v", err)
}
defer ss.Close()
clientImpl := &Implementation{Name: "discover-probe-client", Version: "v9.9.9"}
c := NewClient(clientImpl, &ClientOptions{
CreateMessageHandler: func(context.Context, *CreateMessageRequest) (*CreateMessageResult, error) {
return nil, nil
},
})
cs, err := c.Connect(ctx, ct, &ClientSessionOptions{ProtocolVersion: protocolVersion20260728})
if err != nil {
t.Fatalf("Connect: %v", err)
}
defer cs.Close()
if got.params == nil {
t.Fatal("server did not receive server/discover")
}
meta := got.params.GetMeta()
if v, _ := meta[MetaKeyProtocolVersion].(string); v != protocolVersion20260728 {
t.Errorf("_meta[%s] = %q, want %q", MetaKeyProtocolVersion, v, protocolVersion20260728)
}
// _meta values round-trip through JSON, so on the server side they
// arrive as map[string]any rather than the typed Go pointers we sent.
info, ok := meta[MetaKeyClientInfo].(map[string]any)
if !ok {
t.Fatalf("_meta[%s] = %T, want map[string]any", MetaKeyClientInfo, meta[MetaKeyClientInfo])
}
if got, want := info["name"], any(clientImpl.Name); got != want {
t.Errorf("clientInfo.name = %v, want %v", got, want)
}
caps, ok := meta[MetaKeyClientCapabilities].(map[string]any)
if !ok {
t.Fatalf("_meta[%s] = %T, want map[string]any", MetaKeyClientCapabilities, meta[MetaKeyClientCapabilities])
}
if _, ok := caps["sampling"]; !ok {
t.Errorf("clientCapabilities.sampling missing (CreateMessageHandler was set); got %v", caps)
}
}
// 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()
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: protocolVersion20260728})
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 != protocolVersion20260728 {
t.Errorf("InitializeResult.ProtocolVersion = %q, want %q (negotiated via discover, no initialize)",
ir.ProtocolVersion, protocolVersion20260728)
}
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-07-28, but the server overrides discover via
// middleware to advertise only legacy versions, so the client must fall
// back to the legacy initialize handshake.
func TestInMemory_E2E_DiscoverFallback_NoOverlap(t *testing.T) {
ctx := context.Background()
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{
Meta: Meta{
MetaKeyServerInfo: &Implementation{Name: "vpre-like-server", Version: "v1"},
},
SupportedVersions: []string{protocolVersion20251125},
Capabilities: &ServerCapabilities{},
}, 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: protocolVersion20260728})
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")
}
// The fallback initialize explicitly requests protocolVersion20251125
// (see client.go), so the server negotiates that version.
if ir.ProtocolVersion != protocolVersion20251125 {
t.Errorf("InitializeResult.ProtocolVersion = %q, want %q (legacy fallback after no-overlap discover)",
ir.ProtocolVersion, protocolVersion20251125)
}
// 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()
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: protocolVersion20260728})
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")
}
// The fallback initialize explicitly requests protocolVersion20251125
// (see client.go), so the server negotiates that version.
if ir.ProtocolVersion != protocolVersion20251125 {
t.Errorf("InitializeResult.ProtocolVersion = %q, want %q (legacy fallback after MethodNotFound)",
ir.ProtocolVersion, protocolVersion20251125)
}
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()
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: protocolVersion20260728})
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")
}
// The fallback initialize explicitly requests protocolVersion20251125
// (see client.go), so the server negotiates that version.
if ir.ProtocolVersion != protocolVersion20251125 {
t.Errorf("InitializeResult.ProtocolVersion = %q, want %q (legacy fallback after UnsupportedProtocolVersion)",
ir.ProtocolVersion, protocolVersion20251125)
}
}
// 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) {
ctx := context.Background()
const unsupportedClientVersion = "2099-12-31"
var (
discoverCalls atomic.Int32
gotInitialize atomic.Bool
observedDiscoverVersion atomic.Value // string
)
s := NewServer(testImpl, nil)
s.AddReceivingMiddleware(func(next MethodHandler) MethodHandler {
return func(ctx context.Context, method string, req Request) (Result, error) {
switch method {
case methodDiscover:
sr, ok := req.(*ServerRequest[*DiscoverParams])
if !ok {
t.Errorf("discover req has unexpected type %T", req)
return nil, jsonrpc2.ErrMethodNotFound
}
if v, _ := sr.Params.GetMeta()[MetaKeyProtocolVersion].(string); v != "" {
observedDiscoverVersion.Store(v)
}
discoverCalls.Add(1)
case methodInitialize:
gotInitialize.Store(true)
}
return next(ctx, method, req)
}
})
ct, st := NewInMemoryTransports()
ss, err := s.Connect(ctx, st, nil)
if err != nil {
t.Fatalf("server Connect: %v", err)
}
defer ss.Close()
c := NewClient(testImpl, nil)
cs, err := c.Connect(ctx, ct, &ClientSessionOptions{ProtocolVersion: unsupportedClientVersion})
if err != nil {
t.Fatalf("Connect: %v", err)
}
defer cs.Close()
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, _ := observedDiscoverVersion.Load().(string); got != protocolVersion20260728 {
t.Errorf("retry discover requested version = %q, want %q (highest mutually supported version)", got, protocolVersion20260728)
}
if gotInitialize.Load() {
t.Error("legacy initialize handshake ran, but negotiated discover should have succeeded")
}
ir := cs.InitializeResult()
if ir == nil {
t.Fatal("InitializeResult is nil after Connect")
}
if got, want := ir.ProtocolVersion, protocolVersion20260728; got != want {
t.Errorf("InitializeResult.ProtocolVersion = %q, want %q", got, want)
}
}