## 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>
1094 lines
33 KiB
Go
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)
|
|
}
|
|
}
|