Expose the jsonrpc.Error type to allow access to underlying JSON-RPC error codes. Also, expose common JSON-RPC error codes in the jsonrpc package, and MCP error codes in the mcp package. Also, implement support for the MCP URL elicitation required error (-32042), enabling servers to request out-of-band user authorization and clients to automatically handle these requests with retry logic. Server-side: - Add CodeURLElicitationRequired constant - Add URLElicitationRequiredError() constructor. Client-side: - Add urlElicitationMiddleware to intercept URLElicitationRequired errors and retry calls. - Update callElicitationCompleteHandler to signal waiting operations. - Fix capability advertisement to support both form and URL modes. Fixes #452 Fixes #623
292 lines
7.9 KiB
Go
292 lines
7.9 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.
|
|
|
|
package mcp
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"testing"
|
|
|
|
"github.com/google/go-cmp/cmp"
|
|
"github.com/google/go-cmp/cmp/cmpopts"
|
|
"github.com/google/jsonschema-go/jsonschema"
|
|
)
|
|
|
|
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 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
|
|
wantCapabilities *ClientCapabilities
|
|
}{
|
|
{
|
|
name: "With initial capabilities",
|
|
configureClient: func(s *Client) {},
|
|
wantCapabilities: &ClientCapabilities{
|
|
Roots: struct {
|
|
ListChanged bool "json:\"listChanged,omitempty\""
|
|
}{ListChanged: true},
|
|
},
|
|
},
|
|
{
|
|
name: "With sampling",
|
|
configureClient: func(s *Client) {},
|
|
clientOpts: ClientOptions{
|
|
CreateMessageHandler: func(context.Context, *CreateMessageRequest) (*CreateMessageResult, error) {
|
|
return nil, nil
|
|
},
|
|
},
|
|
wantCapabilities: &ClientCapabilities{
|
|
Roots: struct {
|
|
ListChanged bool "json:\"listChanged,omitempty\""
|
|
}{ListChanged: true},
|
|
Sampling: &SamplingCapabilities{},
|
|
},
|
|
},
|
|
{
|
|
name: "With form elicitation",
|
|
configureClient: func(s *Client) {},
|
|
clientOpts: ClientOptions{
|
|
ElicitationModes: []string{"form"},
|
|
ElicitationHandler: func(context.Context, *ElicitRequest) (*ElicitResult, error) {
|
|
return nil, nil
|
|
},
|
|
},
|
|
wantCapabilities: &ClientCapabilities{
|
|
Roots: struct {
|
|
ListChanged bool "json:\"listChanged,omitempty\""
|
|
}{ListChanged: true},
|
|
Elicitation: &ElicitationCapabilities{
|
|
Form: &FormElicitationCapabilities{},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "With URL elicitation",
|
|
configureClient: func(s *Client) {},
|
|
clientOpts: ClientOptions{
|
|
ElicitationModes: []string{"url"},
|
|
ElicitationHandler: func(context.Context, *ElicitRequest) (*ElicitResult, error) {
|
|
return nil, nil
|
|
},
|
|
},
|
|
wantCapabilities: &ClientCapabilities{
|
|
Roots: struct {
|
|
ListChanged bool "json:\"listChanged,omitempty\""
|
|
}{ListChanged: true},
|
|
Elicitation: &ElicitationCapabilities{
|
|
URL: &URLElicitationCapabilities{},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "With both form and URL elicitation",
|
|
configureClient: func(s *Client) {},
|
|
clientOpts: ClientOptions{
|
|
ElicitationModes: []string{"form", "url"},
|
|
ElicitationHandler: func(context.Context, *ElicitRequest) (*ElicitResult, error) {
|
|
return nil, nil
|
|
},
|
|
},
|
|
wantCapabilities: &ClientCapabilities{
|
|
Roots: struct {
|
|
ListChanged bool "json:\"listChanged,omitempty\""
|
|
}{ListChanged: true},
|
|
Elicitation: &ElicitationCapabilities{
|
|
Form: &FormElicitationCapabilities{},
|
|
URL: &URLElicitationCapabilities{},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, tc := range testCases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
client := NewClient(testImpl, &tc.clientOpts)
|
|
tc.configureClient(client)
|
|
gotCapabilities := client.capabilities()
|
|
if diff := cmp.Diff(tc.wantCapabilities, gotCapabilities); diff != "" {
|
|
t.Errorf("capabilities() mismatch (-want +got):\n%s", diff)
|
|
}
|
|
})
|
|
}
|
|
}
|