CallToolResult currently decodes structuredContent through an any field, which converts JSON numbers to float64 and silently rounds integers outside the IEEE-754 safe range. This change decodes structuredContent separately with UseNumber, preserving wire numbers as json.Number while retaining the existing decoded object, array, and primitive shapes. The field documentation now describes that representation. Users who depend on the previous float64 behavior can temporarily restore it with MCPGODEBUG=structuredcontentfloat64=1. An end-to-end in-memory client/server regression covers 9007199254740993 from a low-level Server.AddTool result. Additional coverage verifies the compatibility option restores the legacy rounded float64 value. Verification: - gofmt -l . - go test ./... - go vet ./... Fixes #1236 --------- Co-authored-by: Guglielmo Colombo <guglielmoc@google.com>
2257 lines
68 KiB
Go
2257 lines
68 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 (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"log"
|
|
"log/slog"
|
|
"slices"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/google/go-cmp/cmp"
|
|
"github.com/google/jsonschema-go/jsonschema"
|
|
"github.com/modelcontextprotocol/go-sdk/internal/jsonrpc2"
|
|
"github.com/modelcontextprotocol/go-sdk/jsonrpc"
|
|
)
|
|
|
|
type testItem struct {
|
|
Name string
|
|
Value string
|
|
}
|
|
|
|
type testListParams struct {
|
|
Cursor string
|
|
}
|
|
|
|
func (p *testListParams) cursorPtr() *string {
|
|
return &p.Cursor
|
|
}
|
|
|
|
type testListResult struct {
|
|
Items []*testItem
|
|
NextCursor string
|
|
}
|
|
|
|
func (r *testListResult) nextCursorPtr() *string {
|
|
return &r.NextCursor
|
|
}
|
|
|
|
var allTestItems = []*testItem{
|
|
{"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"},
|
|
}
|
|
|
|
// getCursor encodes a string input into a URL-safe base64 cursor,
|
|
// fatally logging any encoding errors.
|
|
func getCursor(input string) string {
|
|
cursor, err := encodeCursor(input)
|
|
if err != nil {
|
|
log.Fatalf("encodeCursor(%s) error = %v", input, err)
|
|
}
|
|
return cursor
|
|
}
|
|
|
|
func TestServerPaginateBasic(t *testing.T) {
|
|
testCases := []struct {
|
|
name string
|
|
initialItems []*testItem
|
|
inputCursor string
|
|
inputPageSize int
|
|
wantFeatures []*testItem
|
|
wantNextCursor string
|
|
wantErr bool
|
|
}{
|
|
{
|
|
name: "FirstPage_DefaultSize_Full",
|
|
initialItems: allTestItems,
|
|
inputCursor: "",
|
|
inputPageSize: 5,
|
|
wantFeatures: allTestItems[0:5],
|
|
wantNextCursor: getCursor("echo"), // Based on last item of first page
|
|
wantErr: false,
|
|
},
|
|
{
|
|
name: "SecondPage_DefaultSize_Full",
|
|
initialItems: allTestItems,
|
|
inputCursor: getCursor("echo"),
|
|
inputPageSize: 5,
|
|
wantFeatures: allTestItems[5:10],
|
|
wantNextCursor: getCursor("juliet"), // Based on last item of second page
|
|
wantErr: false,
|
|
},
|
|
{
|
|
name: "SecondPage_DefaultSize_Full_OutOfOrder",
|
|
initialItems: append(allTestItems[5:], allTestItems[0:5]...),
|
|
inputCursor: getCursor("echo"),
|
|
inputPageSize: 5,
|
|
wantFeatures: allTestItems[5:10],
|
|
wantNextCursor: getCursor("juliet"), // Based on last item of second page
|
|
wantErr: false,
|
|
},
|
|
{
|
|
name: "SecondPage_DefaultSize_Full_Duplicates",
|
|
initialItems: append(allTestItems, allTestItems[0:5]...),
|
|
inputCursor: getCursor("echo"),
|
|
inputPageSize: 5,
|
|
wantFeatures: allTestItems[5:10],
|
|
wantNextCursor: getCursor("juliet"), // Based on last item of second page
|
|
wantErr: false,
|
|
},
|
|
{
|
|
name: "LastPage_Remaining",
|
|
initialItems: allTestItems,
|
|
inputCursor: getCursor("juliet"),
|
|
inputPageSize: 5,
|
|
wantFeatures: allTestItems[10:11], // Only 1 item left
|
|
wantNextCursor: "", // No more pages
|
|
wantErr: false,
|
|
},
|
|
{
|
|
name: "PageSize_1",
|
|
initialItems: allTestItems,
|
|
inputCursor: "",
|
|
inputPageSize: 1,
|
|
wantFeatures: allTestItems[0:1],
|
|
wantNextCursor: getCursor("alpha"),
|
|
wantErr: false,
|
|
},
|
|
{
|
|
name: "PageSize_All",
|
|
initialItems: allTestItems,
|
|
inputCursor: "",
|
|
inputPageSize: len(allTestItems), // Page size equals total
|
|
wantFeatures: allTestItems,
|
|
wantNextCursor: "", // No more pages
|
|
wantErr: false,
|
|
},
|
|
{
|
|
name: "PageSize_LargerThanAll",
|
|
initialItems: allTestItems,
|
|
inputCursor: "",
|
|
inputPageSize: len(allTestItems) + 5, // Page size larger than total
|
|
wantFeatures: allTestItems,
|
|
wantNextCursor: "",
|
|
wantErr: false,
|
|
},
|
|
{
|
|
name: "EmptySet",
|
|
initialItems: nil,
|
|
inputCursor: "",
|
|
inputPageSize: 5,
|
|
wantFeatures: nil,
|
|
wantNextCursor: "",
|
|
wantErr: false,
|
|
},
|
|
{
|
|
name: "InvalidCursor",
|
|
initialItems: allTestItems,
|
|
inputCursor: "not-a-valid-gob-base64-cursor",
|
|
inputPageSize: 5,
|
|
wantFeatures: nil, // Should be nil for error cases
|
|
wantNextCursor: "",
|
|
wantErr: true,
|
|
},
|
|
{
|
|
name: "AboveNonExistentID",
|
|
initialItems: allTestItems,
|
|
inputCursor: getCursor("dne"), // A UID that doesn't exist
|
|
inputPageSize: 5,
|
|
wantFeatures: allTestItems[4:9], // Should return elements above UID.
|
|
wantNextCursor: getCursor("india"),
|
|
wantErr: false,
|
|
},
|
|
}
|
|
|
|
for _, tc := range testCases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
fs := newFeatureSet(func(t *testItem) string { return t.Name })
|
|
fs.add(tc.initialItems...)
|
|
params := &testListParams{Cursor: tc.inputCursor}
|
|
gotResult, err := paginateList(fs, tc.inputPageSize, params, &testListResult{}, func(res *testListResult, items []*testItem) {
|
|
res.Items = items
|
|
})
|
|
if (err != nil) != tc.wantErr {
|
|
t.Errorf("paginateList(%s) error, got %v, wantErr %v", tc.name, err, tc.wantErr)
|
|
}
|
|
if tc.wantErr {
|
|
return
|
|
}
|
|
if diff := cmp.Diff(tc.wantFeatures, gotResult.Items); diff != "" {
|
|
t.Errorf("paginateList(%s) mismatch (-want +got):\n%s", tc.name, diff)
|
|
}
|
|
if tc.wantNextCursor != gotResult.NextCursor {
|
|
t.Errorf("paginateList(%s) nextCursor, got %v, want %v", tc.name, gotResult.NextCursor, tc.wantNextCursor)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestServerPaginateVariousPageSizes(t *testing.T) {
|
|
fs := newFeatureSet(func(t *testItem) string { return t.Name })
|
|
fs.add(allTestItems...)
|
|
// Try all possible page sizes, ensuring we get the correct list of items.
|
|
for pageSize := 1; pageSize < len(allTestItems)+1; pageSize++ {
|
|
var gotItems []*testItem
|
|
var nextCursor string
|
|
wantChunks := slices.Collect(slices.Chunk(allTestItems, pageSize))
|
|
index := 0
|
|
// Iterate through all pages, comparing sub-slices to the paginated list.
|
|
for {
|
|
params := &testListParams{Cursor: nextCursor}
|
|
gotResult, err := paginateList(fs, pageSize, params, &testListResult{}, func(res *testListResult, items []*testItem) {
|
|
res.Items = items
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("paginateList() unexpected error for pageSize %d, cursor %q: %v", pageSize, nextCursor, err)
|
|
}
|
|
if diff := cmp.Diff(wantChunks[index], gotResult.Items); diff != "" {
|
|
t.Errorf("paginateList mismatch (-want +got):\n%s", diff)
|
|
}
|
|
gotItems = append(gotItems, gotResult.Items...)
|
|
nextCursor = gotResult.NextCursor
|
|
if nextCursor == "" {
|
|
break
|
|
}
|
|
index++
|
|
}
|
|
|
|
if len(gotItems) != len(allTestItems) {
|
|
t.Fatalf("paginateList() returned %d items, want %d", len(allTestItems), len(gotItems))
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestServerCapabilities(t *testing.T) {
|
|
tool := &Tool{Name: "t", InputSchema: &jsonschema.Schema{Type: "object"}}
|
|
testCases := []struct {
|
|
name string
|
|
configureServer func(s *Server)
|
|
serverOpts ServerOptions
|
|
wantCapabilities *ServerCapabilities
|
|
}{
|
|
{
|
|
name: "no capabilities",
|
|
configureServer: func(s *Server) {},
|
|
wantCapabilities: &ServerCapabilities{
|
|
Logging: &LoggingCapabilities{},
|
|
},
|
|
},
|
|
{
|
|
name: "with prompts",
|
|
configureServer: func(s *Server) {
|
|
s.AddPrompt(&Prompt{Name: "p"}, nil)
|
|
},
|
|
wantCapabilities: &ServerCapabilities{
|
|
Logging: &LoggingCapabilities{},
|
|
Prompts: &PromptCapabilities{ListChanged: true},
|
|
},
|
|
},
|
|
{
|
|
name: "with resources",
|
|
configureServer: func(s *Server) {
|
|
s.AddResource(&Resource{URI: "file:///r"}, nil)
|
|
},
|
|
wantCapabilities: &ServerCapabilities{
|
|
Logging: &LoggingCapabilities{},
|
|
Resources: &ResourceCapabilities{ListChanged: true},
|
|
},
|
|
},
|
|
{
|
|
name: "with resource templates",
|
|
configureServer: func(s *Server) {
|
|
s.AddResourceTemplate(&ResourceTemplate{URITemplate: "file:///rt"}, nil)
|
|
},
|
|
wantCapabilities: &ServerCapabilities{
|
|
Logging: &LoggingCapabilities{},
|
|
Resources: &ResourceCapabilities{ListChanged: true},
|
|
},
|
|
},
|
|
{
|
|
name: "with resource subscriptions",
|
|
configureServer: func(s *Server) {
|
|
s.AddResourceTemplate(&ResourceTemplate{URITemplate: "file:///rt"}, nil)
|
|
},
|
|
serverOpts: ServerOptions{
|
|
SubscribeHandler: func(context.Context, *SubscribeRequest) error {
|
|
return nil
|
|
},
|
|
UnsubscribeHandler: func(context.Context, *UnsubscribeRequest) error {
|
|
return nil
|
|
},
|
|
},
|
|
wantCapabilities: &ServerCapabilities{
|
|
Logging: &LoggingCapabilities{},
|
|
Resources: &ResourceCapabilities{ListChanged: true, Subscribe: true},
|
|
},
|
|
},
|
|
{
|
|
name: "with tools",
|
|
configureServer: func(s *Server) {
|
|
s.AddTool(tool, nil)
|
|
},
|
|
wantCapabilities: &ServerCapabilities{
|
|
Logging: &LoggingCapabilities{},
|
|
Tools: &ToolCapabilities{ListChanged: true},
|
|
},
|
|
},
|
|
{
|
|
name: "with completions",
|
|
configureServer: func(s *Server) {},
|
|
serverOpts: ServerOptions{
|
|
CompletionHandler: func(context.Context, *CompleteRequest) (*CompleteResult, error) {
|
|
return nil, nil
|
|
},
|
|
},
|
|
wantCapabilities: &ServerCapabilities{
|
|
Logging: &LoggingCapabilities{},
|
|
Completions: &CompletionCapabilities{},
|
|
},
|
|
},
|
|
{
|
|
name: "all capabilities",
|
|
configureServer: func(s *Server) {
|
|
s.AddPrompt(&Prompt{Name: "p"}, nil)
|
|
s.AddResource(&Resource{URI: "file:///r"}, nil)
|
|
s.AddResourceTemplate(&ResourceTemplate{URITemplate: "file:///rt"}, nil)
|
|
s.AddTool(tool, nil)
|
|
},
|
|
serverOpts: ServerOptions{
|
|
SubscribeHandler: func(context.Context, *SubscribeRequest) error {
|
|
return nil
|
|
},
|
|
UnsubscribeHandler: func(context.Context, *UnsubscribeRequest) error {
|
|
return nil
|
|
},
|
|
CompletionHandler: func(context.Context, *CompleteRequest) (*CompleteResult, error) {
|
|
return nil, nil
|
|
},
|
|
},
|
|
wantCapabilities: &ServerCapabilities{
|
|
Completions: &CompletionCapabilities{},
|
|
Logging: &LoggingCapabilities{},
|
|
Prompts: &PromptCapabilities{ListChanged: true},
|
|
Resources: &ResourceCapabilities{ListChanged: true, Subscribe: true},
|
|
Tools: &ToolCapabilities{ListChanged: true},
|
|
},
|
|
},
|
|
{
|
|
name: "has features",
|
|
configureServer: func(s *Server) {},
|
|
serverOpts: ServerOptions{
|
|
HasPrompts: true,
|
|
HasResources: true,
|
|
HasTools: true,
|
|
},
|
|
wantCapabilities: &ServerCapabilities{
|
|
Logging: &LoggingCapabilities{},
|
|
Prompts: &PromptCapabilities{ListChanged: true},
|
|
Resources: &ResourceCapabilities{ListChanged: true},
|
|
Tools: &ToolCapabilities{ListChanged: true},
|
|
},
|
|
},
|
|
{
|
|
name: "empty capabilities",
|
|
configureServer: func(s *Server) {},
|
|
serverOpts: ServerOptions{
|
|
Capabilities: &ServerCapabilities{},
|
|
},
|
|
wantCapabilities: &ServerCapabilities{},
|
|
},
|
|
{
|
|
name: "no logging",
|
|
configureServer: func(s *Server) {},
|
|
serverOpts: ServerOptions{
|
|
Capabilities: &ServerCapabilities{
|
|
Tools: &ToolCapabilities{ListChanged: true},
|
|
},
|
|
},
|
|
wantCapabilities: &ServerCapabilities{
|
|
Tools: &ToolCapabilities{ListChanged: true},
|
|
},
|
|
},
|
|
{
|
|
name: "no list",
|
|
configureServer: func(s *Server) {},
|
|
serverOpts: ServerOptions{
|
|
Capabilities: &ServerCapabilities{
|
|
Tools: &ToolCapabilities{ListChanged: false},
|
|
Prompts: &PromptCapabilities{ListChanged: false},
|
|
},
|
|
},
|
|
wantCapabilities: &ServerCapabilities{
|
|
Tools: &ToolCapabilities{ListChanged: false},
|
|
Prompts: &PromptCapabilities{ListChanged: false},
|
|
},
|
|
},
|
|
{
|
|
name: "adding tools-list",
|
|
configureServer: func(s *Server) {
|
|
s.AddTool(tool, nil)
|
|
},
|
|
serverOpts: ServerOptions{
|
|
Capabilities: &ServerCapabilities{
|
|
Logging: &LoggingCapabilities{},
|
|
},
|
|
},
|
|
wantCapabilities: &ServerCapabilities{
|
|
Logging: &LoggingCapabilities{},
|
|
Tools: &ToolCapabilities{ListChanged: true},
|
|
},
|
|
},
|
|
{
|
|
name: "adding tools-no list",
|
|
configureServer: func(s *Server) {
|
|
s.AddTool(tool, nil)
|
|
},
|
|
serverOpts: ServerOptions{
|
|
Capabilities: &ServerCapabilities{
|
|
Tools: &ToolCapabilities{ListChanged: false},
|
|
},
|
|
},
|
|
wantCapabilities: &ServerCapabilities{
|
|
Tools: &ToolCapabilities{ListChanged: false},
|
|
},
|
|
},
|
|
{
|
|
name: "experimental preserved",
|
|
configureServer: func(s *Server) {},
|
|
serverOpts: ServerOptions{
|
|
Capabilities: &ServerCapabilities{
|
|
Experimental: map[string]any{"custom": "value"},
|
|
Logging: &LoggingCapabilities{},
|
|
},
|
|
},
|
|
wantCapabilities: &ServerCapabilities{
|
|
Experimental: map[string]any{"custom": "value"},
|
|
Logging: &LoggingCapabilities{},
|
|
},
|
|
},
|
|
{
|
|
name: "extensions preserved",
|
|
configureServer: func(s *Server) {},
|
|
serverOpts: func() ServerOptions {
|
|
caps := &ServerCapabilities{
|
|
Logging: &LoggingCapabilities{},
|
|
}
|
|
caps.AddExtension("io.example/ext1", map[string]any{"key": "value"})
|
|
caps.AddExtension("io.example/ext2", nil)
|
|
return ServerOptions{Capabilities: caps}
|
|
}(),
|
|
wantCapabilities: &ServerCapabilities{
|
|
Extensions: map[string]any{
|
|
"io.example/ext1": map[string]any{"key": "value"},
|
|
"io.example/ext2": map[string]any{},
|
|
},
|
|
Logging: &LoggingCapabilities{},
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, tc := range testCases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
server := NewServer(testImpl, &tc.serverOpts)
|
|
tc.configureServer(server)
|
|
gotCapabilities := server.capabilities()
|
|
if diff := cmp.Diff(tc.wantCapabilities, gotCapabilities); diff != "" {
|
|
t.Errorf("capabilities() mismatch (-want +got):\n%s", diff)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestServerAddResourceTemplate(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
template string
|
|
expectPanic bool
|
|
}{
|
|
{"ValidFileTemplate", "file:///{a}/{b}", false},
|
|
{"ValidCustomScheme", "myproto:///{a}", false},
|
|
{"EmptyVariable", "file:///{}/{b}", true},
|
|
{"UnclosedVariable", "file:///{a", true},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
rt := ResourceTemplate{URITemplate: tt.template}
|
|
|
|
defer func() {
|
|
if r := recover(); r != nil {
|
|
if !tt.expectPanic {
|
|
t.Errorf("%s: unexpected panic: %v", tt.name, r)
|
|
}
|
|
} else {
|
|
if tt.expectPanic {
|
|
t.Errorf("%s: expected panic but did not panic", tt.name)
|
|
}
|
|
}
|
|
}()
|
|
|
|
s := NewServer(testImpl, nil)
|
|
s.AddResourceTemplate(&rt, nil)
|
|
})
|
|
}
|
|
}
|
|
|
|
// panicks reports whether f() panics.
|
|
func panics(f func()) (b bool) {
|
|
defer func() {
|
|
b = recover() != nil
|
|
}()
|
|
f()
|
|
return false
|
|
}
|
|
|
|
func TestAddTool(t *testing.T) {
|
|
// AddTool should panic if In is not a JSON object.
|
|
// Out may be of any type whose inferred JSON Schema is valid.
|
|
s := NewServer(testImpl, nil)
|
|
if !panics(func() {
|
|
AddTool(s, &Tool{Name: "T1"}, func(context.Context, *CallToolRequest, string) (*CallToolResult, any, error) { return nil, nil, nil })
|
|
}) {
|
|
t.Error("bad In: expected panic")
|
|
}
|
|
if panics(func() {
|
|
AddTool(s, &Tool{Name: "T2"}, func(context.Context, *CallToolRequest, map[string]any) (*CallToolResult, any, error) {
|
|
return nil, nil, nil
|
|
})
|
|
}) {
|
|
t.Error("good In: expected no panic")
|
|
}
|
|
if panics(func() {
|
|
AddTool(s, &Tool{Name: "T3"}, func(context.Context, *CallToolRequest, map[string]any) (*CallToolResult, int, error) {
|
|
return nil, 0, nil
|
|
})
|
|
}) {
|
|
t.Error("primitive Out: expected no panic")
|
|
}
|
|
}
|
|
|
|
func TestAddToolNameValidation(t *testing.T) {
|
|
tests := []struct {
|
|
label string
|
|
name string
|
|
wantLogContaining string
|
|
}{
|
|
{
|
|
label: "empty name",
|
|
name: "",
|
|
wantLogContaining: `tool name cannot be empty`,
|
|
},
|
|
{
|
|
label: "long name",
|
|
name: strings.Repeat("a", 129),
|
|
wantLogContaining: "exceeds maximum length of 128 characters",
|
|
},
|
|
{
|
|
label: "name with spaces",
|
|
name: "get user profile",
|
|
wantLogContaining: `tool name contains invalid characters: \" \"`,
|
|
},
|
|
{
|
|
label: "name with multiple invalid chars",
|
|
name: "user name@domain,com",
|
|
wantLogContaining: `tool name contains invalid characters: \" \", \"@\", \",\"`,
|
|
},
|
|
{
|
|
label: "name with unicode",
|
|
name: "tool-ñame",
|
|
wantLogContaining: `tool name contains invalid characters: \"ñ\"`,
|
|
},
|
|
{
|
|
label: "valid name",
|
|
name: "valid-tool_name.123",
|
|
wantLogContaining: "", // No log expected
|
|
},
|
|
}
|
|
for _, test := range tests {
|
|
t.Run(test.label, func(t *testing.T) {
|
|
var buf bytes.Buffer
|
|
s := NewServer(testImpl, &ServerOptions{
|
|
Logger: slog.New(slog.NewTextHandler(&buf, nil)),
|
|
})
|
|
|
|
// Use the generic AddTool as it also calls validateToolName.
|
|
AddTool(s, &Tool{Name: test.name}, func(context.Context, *CallToolRequest, any) (*CallToolResult, any, error) {
|
|
return nil, nil, nil
|
|
})
|
|
|
|
logOutput := buf.String()
|
|
if test.wantLogContaining != "" {
|
|
if !strings.Contains(logOutput, test.wantLogContaining) {
|
|
t.Errorf("log output =\n%s\nwant containing %q", logOutput, test.wantLogContaining)
|
|
}
|
|
} else {
|
|
if logOutput != "" {
|
|
t.Errorf("expected empty log output, got %q", logOutput)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestAddToolNilSchema(t *testing.T) {
|
|
var nilSchema *jsonschema.Schema
|
|
|
|
panicMsg := func(f func()) (msg string) {
|
|
defer func() {
|
|
if r := recover(); r != nil {
|
|
msg = fmt.Sprintf("%v", r)
|
|
}
|
|
}()
|
|
f()
|
|
return msg
|
|
}
|
|
|
|
// Call s.AddTool directly to exercise the typed-nil checks added to that method.
|
|
tests := []struct {
|
|
name string
|
|
tool *Tool
|
|
wantContain string
|
|
}{
|
|
{
|
|
name: "typed nil InputSchema",
|
|
tool: &Tool{Name: "T", InputSchema: nilSchema},
|
|
wantContain: "input schema is nil",
|
|
},
|
|
{
|
|
name: "typed nil OutputSchema",
|
|
tool: &Tool{Name: "T", InputSchema: &jsonschema.Schema{Type: "object"}, OutputSchema: nilSchema},
|
|
wantContain: "output schema is nil",
|
|
},
|
|
}
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
s := NewServer(testImpl, nil)
|
|
|
|
msg := panicMsg(func() {
|
|
s.AddTool(tc.tool, nil)
|
|
})
|
|
if msg == "" {
|
|
t.Error("expected panic")
|
|
} else if !strings.Contains(msg, tc.wantContain) {
|
|
t.Errorf("panic message %q does not contain %q", msg, tc.wantContain)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestAddToolNonObjectOutputSchema verifies the low-level
|
|
// Server.AddTool accepts an output schema whose root type is not "object"
|
|
// (array or primitive) and structured content of the matching shape
|
|
// round-trips end-to-end.
|
|
func TestAddToolNonObjectOutputSchema(t *testing.T) {
|
|
ctx := context.Background()
|
|
|
|
for _, tc := range []struct {
|
|
name string
|
|
outputSchema any
|
|
content any
|
|
want any
|
|
}{
|
|
{
|
|
name: "array of objects",
|
|
outputSchema: &jsonschema.Schema{Type: "array", Items: &jsonschema.Schema{Type: "object"}},
|
|
content: []any{map[string]any{"id": "u1"}, map[string]any{"id": "u2"}},
|
|
want: []any{map[string]any{"id": "u1"}, map[string]any{"id": "u2"}},
|
|
},
|
|
{
|
|
name: "primitive number (map-based schema)",
|
|
outputSchema: map[string]any{"type": "number"},
|
|
content: 42.0,
|
|
want: json.Number("42"),
|
|
},
|
|
{
|
|
name: "primitive string (RawMessage schema)",
|
|
outputSchema: json.RawMessage(`{"type":"string"}`),
|
|
content: "hello",
|
|
want: "hello",
|
|
},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
server := NewServer(testImpl, nil)
|
|
handler := func(ctx context.Context, req *CallToolRequest) (*CallToolResult, error) {
|
|
return &CallToolResult{StructuredContent: tc.content}, nil
|
|
}
|
|
tool := &Tool{
|
|
Name: "t",
|
|
InputSchema: &jsonschema.Schema{Type: "object"},
|
|
OutputSchema: tc.outputSchema,
|
|
}
|
|
// Must not panic.
|
|
server.AddTool(tool, handler)
|
|
|
|
ct, st := NewInMemoryTransports()
|
|
if _, err := server.Connect(ctx, st, nil); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
client := NewClient(testImpl, nil)
|
|
cs, err := client.Connect(ctx, ct, nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer cs.Close()
|
|
|
|
res, err := cs.CallTool(ctx, &CallToolParams{Name: "t"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if res.IsError {
|
|
t.Fatalf("unexpected tool error: %v", res.Content)
|
|
}
|
|
if diff := cmp.Diff(tc.want, res.StructuredContent); diff != "" {
|
|
t.Errorf("structured content mismatch (-want +got):\n%s", diff)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestCallToolStructuredContentPreservesLargeInteger(t *testing.T) {
|
|
const want int64 = 9007199254740993
|
|
|
|
server := NewServer(testImpl, nil)
|
|
server.AddTool(&Tool{
|
|
Name: "large-integer",
|
|
InputSchema: &jsonschema.Schema{Type: "object"},
|
|
}, func(context.Context, *CallToolRequest) (*CallToolResult, error) {
|
|
return &CallToolResult{StructuredContent: map[string]any{"id": want}}, nil
|
|
})
|
|
|
|
clientTransport, serverTransport := NewInMemoryTransports()
|
|
serverSession, err := server.Connect(context.Background(), serverTransport, nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer serverSession.Close()
|
|
|
|
client := NewClient(testImpl, nil)
|
|
clientSession, err := client.Connect(context.Background(), clientTransport, nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer clientSession.Close()
|
|
|
|
result, err := clientSession.CallTool(context.Background(), &CallToolParams{Name: "large-integer"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
structured, ok := result.StructuredContent.(map[string]any)
|
|
if !ok {
|
|
t.Fatalf("StructuredContent type = %T, want map[string]any", result.StructuredContent)
|
|
}
|
|
number, ok := structured["id"].(json.Number)
|
|
if !ok {
|
|
t.Fatalf("id type = %T, want json.Number", structured["id"])
|
|
}
|
|
got, err := number.Int64()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got != want {
|
|
t.Errorf("id = %d, want %d", got, want)
|
|
}
|
|
}
|
|
|
|
// TestAddToolGenericNonObjectOutput verifies SEP-2106 for the generic
|
|
// AddTool[In, Out] helper: Out may be a slice or primitive whose inferred
|
|
// JSON Schema has a non-object root.
|
|
func TestAddToolGenericNonObjectOutput(t *testing.T) {
|
|
ctx := context.Background()
|
|
|
|
type item struct {
|
|
ID string `json:"id"`
|
|
}
|
|
|
|
t.Run("slice output", func(t *testing.T) {
|
|
server := NewServer(testImpl, nil)
|
|
AddTool(server, &Tool{Name: "list"},
|
|
func(ctx context.Context, req *CallToolRequest, _ struct{}) (*CallToolResult, []item, error) {
|
|
return nil, []item{{ID: "u1"}, {ID: "u2"}}, nil
|
|
})
|
|
|
|
ct, st := NewInMemoryTransports()
|
|
if _, err := server.Connect(ctx, st, nil); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
client := NewClient(testImpl, nil)
|
|
cs, err := client.Connect(ctx, ct, nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer cs.Close()
|
|
|
|
// Verify tools/list reports an array-rooted output schema.
|
|
// jsonschema-go infers ["null","array"] for slices since nil slices
|
|
// are valid; accept either form.
|
|
lt, err := cs.ListTools(ctx, nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(lt.Tools) != 1 {
|
|
t.Fatalf("got %d tools, want 1", len(lt.Tools))
|
|
}
|
|
gotSchema, _ := lt.Tools[0].OutputSchema.(map[string]any)
|
|
typeOK := false
|
|
switch typ := gotSchema["type"].(type) {
|
|
case string:
|
|
typeOK = typ == "array"
|
|
case []any:
|
|
for _, e := range typ {
|
|
if e == "array" {
|
|
typeOK = true
|
|
}
|
|
}
|
|
}
|
|
if !typeOK {
|
|
t.Errorf("output schema type = %v, want to include %q", gotSchema["type"], "array")
|
|
}
|
|
|
|
res, err := cs.CallTool(ctx, &CallToolParams{Name: "list"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if res.IsError {
|
|
t.Fatalf("unexpected tool error: %v", res.Content)
|
|
}
|
|
want := []any{map[string]any{"id": "u1"}, map[string]any{"id": "u2"}}
|
|
if diff := cmp.Diff(want, res.StructuredContent); diff != "" {
|
|
t.Errorf("structured content mismatch (-want +got):\n%s", diff)
|
|
}
|
|
})
|
|
|
|
t.Run("primitive output", func(t *testing.T) {
|
|
server := NewServer(testImpl, nil)
|
|
AddTool(server, &Tool{Name: "count"},
|
|
func(ctx context.Context, req *CallToolRequest, _ struct{}) (*CallToolResult, int, error) {
|
|
return nil, 42, nil
|
|
})
|
|
|
|
ct, st := NewInMemoryTransports()
|
|
if _, err := server.Connect(ctx, st, nil); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
client := NewClient(testImpl, nil)
|
|
cs, err := client.Connect(ctx, ct, nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer cs.Close()
|
|
|
|
res, err := cs.CallTool(ctx, &CallToolParams{Name: "count"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if res.IsError {
|
|
t.Fatalf("unexpected tool error: %v", res.Content)
|
|
}
|
|
if diff := cmp.Diff(json.Number("42"), res.StructuredContent); diff != "" {
|
|
t.Errorf("structured content mismatch (-want +got):\n%s", diff)
|
|
}
|
|
})
|
|
}
|
|
|
|
// TestAddToolInputSchemaComposition verifies SEP-2106 (input side): composition
|
|
// keywords such as oneOf are allowed on the input schema alongside
|
|
// type:"object".
|
|
func TestAddToolInputSchemaComposition(t *testing.T) {
|
|
server := NewServer(testImpl, nil)
|
|
tool := &Tool{
|
|
Name: "lookup",
|
|
InputSchema: &jsonschema.Schema{
|
|
Type: "object",
|
|
OneOf: []*jsonschema.Schema{
|
|
{Required: []string{"id"}, Properties: map[string]*jsonschema.Schema{"id": {Type: "string"}}},
|
|
{Required: []string{"name"}, Properties: map[string]*jsonschema.Schema{"name": {Type: "string"}}},
|
|
},
|
|
},
|
|
}
|
|
// Must not panic.
|
|
server.AddTool(tool, func(ctx context.Context, req *CallToolRequest) (*CallToolResult, error) {
|
|
return &CallToolResult{}, nil
|
|
})
|
|
}
|
|
|
|
type schema = jsonschema.Schema
|
|
|
|
func testToolForSchema[In, Out any](t *testing.T, tool *Tool, in string, out Out, wantIn, wantOut any, wantErrContaining string) {
|
|
t.Helper()
|
|
th := func(context.Context, *CallToolRequest, In) (*CallToolResult, Out, error) {
|
|
return nil, out, nil
|
|
}
|
|
gott, goth, err := toolForErr(tool, th, nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if diff := cmp.Diff(wantIn, gott.InputSchema); diff != "" {
|
|
t.Errorf("input: mismatch (-want, +got):\n%s", diff)
|
|
}
|
|
if diff := cmp.Diff(wantOut, gott.OutputSchema); diff != "" {
|
|
t.Errorf("output: mismatch (-want, +got):\n%s", diff)
|
|
}
|
|
ctr := &CallToolRequest{
|
|
Params: &CallToolParamsRaw{
|
|
Arguments: json.RawMessage(in),
|
|
},
|
|
}
|
|
result, err := goth(context.Background(), ctr)
|
|
if wantErrContaining != "" {
|
|
// Input validation errors are returned as tool results with IsError=true,
|
|
// not as Go errors. Check both possibilities.
|
|
if err != nil {
|
|
if !strings.Contains(err.Error(), wantErrContaining) {
|
|
t.Errorf("got error %q, want containing %q", err, wantErrContaining)
|
|
}
|
|
} else if result != nil && result.IsError {
|
|
text := result.Content[0].(*TextContent).Text
|
|
if !strings.Contains(text, wantErrContaining) {
|
|
t.Errorf("got tool error %q, want containing %q", text, wantErrContaining)
|
|
}
|
|
} else {
|
|
t.Errorf("got no error, want error containing %q", wantErrContaining)
|
|
}
|
|
} else if err != nil {
|
|
t.Errorf("got error %v, want no error", err)
|
|
}
|
|
|
|
if gott.OutputSchema != nil && err == nil && !result.IsError {
|
|
// Check that structured content matches exactly.
|
|
unstructured := result.Content[0].(*TextContent).Text
|
|
structured := string(result.StructuredContent.(json.RawMessage))
|
|
if diff := cmp.Diff(unstructured, structured); diff != "" {
|
|
t.Errorf("Unstructured content does not match structured content exactly (-unstructured +structured):\n%s", diff)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestClientRootCapabilities verifies that the server correctly observes
|
|
// RootsV2 for various client capability configurations. This tests the fix
|
|
// for #607.
|
|
func TestClientRootCapabilities(t *testing.T) {
|
|
testCases := []struct {
|
|
name string
|
|
capabilities *string // JSON for the capabilities field; nil means omit the field
|
|
wantRootsV2 *RootCapabilities
|
|
}{
|
|
{
|
|
name: "capabilities field omitted",
|
|
capabilities: nil,
|
|
wantRootsV2: nil,
|
|
},
|
|
{
|
|
name: "empty capabilities",
|
|
capabilities: ptr(`{}`),
|
|
wantRootsV2: nil,
|
|
},
|
|
{
|
|
name: "capabilities with no roots",
|
|
capabilities: ptr(`{"sampling": {}}`),
|
|
wantRootsV2: nil,
|
|
},
|
|
{
|
|
name: "capabilities with empty roots",
|
|
capabilities: ptr(`{"roots": {}}`),
|
|
wantRootsV2: &RootCapabilities{ListChanged: false},
|
|
},
|
|
{
|
|
name: "capabilities with roots without listChanged",
|
|
capabilities: ptr(`{"roots": {"listChanged": false}}`),
|
|
wantRootsV2: &RootCapabilities{ListChanged: false},
|
|
},
|
|
{
|
|
name: "capabilities with roots with listChanged",
|
|
capabilities: ptr(`{"roots": {"listChanged": true}}`),
|
|
wantRootsV2: &RootCapabilities{ListChanged: true},
|
|
},
|
|
}
|
|
|
|
for _, tc := range testCases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
ctx := context.Background()
|
|
|
|
// Create a minimal server.
|
|
impl := &Implementation{Name: "testServer", Version: "v1.0.0"}
|
|
s := NewServer(impl, nil)
|
|
|
|
// Connect the server.
|
|
cTransport, sTransport := NewInMemoryTransports()
|
|
ss, err := s.Connect(ctx, sTransport, nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Connect the client JSON-RPC connection (raw, no client).
|
|
cConn, err := cTransport.Connect(ctx)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Build initialize params, optionally including capabilities.
|
|
var initParams json.RawMessage
|
|
if tc.capabilities != nil {
|
|
initParams = json.RawMessage(`{
|
|
"protocolVersion": "2025-11-25",
|
|
"capabilities": ` + *tc.capabilities + `,
|
|
"clientInfo": {"name": "TestClient", "version": "1.0.0"}
|
|
}`)
|
|
} else {
|
|
initParams = json.RawMessage(`{
|
|
"protocolVersion": "2025-11-25",
|
|
"clientInfo": {"name": "TestClient", "version": "1.0.0"}
|
|
}`)
|
|
}
|
|
|
|
initReq, err := jsonrpc2.NewCall(jsonrpc2.Int64ID(1), "initialize", initParams)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
if err := cConn.Write(ctx, initReq); err != nil {
|
|
t.Fatalf("Write failed: %v", err)
|
|
}
|
|
|
|
// Read the initialize response.
|
|
msg, err := cConn.Read(ctx)
|
|
if err != nil {
|
|
t.Fatalf("Read failed: %v", err)
|
|
}
|
|
resp, ok := msg.(*jsonrpc2.Response)
|
|
if !ok {
|
|
t.Fatalf("expected Response, got %T", msg)
|
|
}
|
|
if resp.Error != nil {
|
|
t.Fatalf("initialize failed: %v", resp.Error)
|
|
}
|
|
|
|
// Verify that the server session has the correct RootsV2 value.
|
|
params := ss.InitializeParams()
|
|
if params == nil {
|
|
t.Fatal("InitializeParams is nil")
|
|
}
|
|
|
|
var gotRootsV2 *RootCapabilities
|
|
if params.Capabilities != nil {
|
|
gotRootsV2 = params.Capabilities.RootsV2
|
|
}
|
|
if diff := cmp.Diff(tc.wantRootsV2, gotRootsV2); diff != "" {
|
|
t.Errorf("RootsV2 mismatch (-want +got):\n%s", diff)
|
|
}
|
|
|
|
// Close the client connection.
|
|
if err := cConn.Close(); err != nil {
|
|
t.Fatalf("Stream.Close failed: %v", err)
|
|
}
|
|
ss.Wait()
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestServerRejectsDuplicateInitialize(t *testing.T) {
|
|
ctx := context.Background()
|
|
|
|
server := NewServer(&Implementation{Name: "testServer", Version: "v1.0.0"}, nil)
|
|
cTransport, sTransport := NewInMemoryTransports()
|
|
ss, err := server.Connect(ctx, sTransport, nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer ss.Close()
|
|
|
|
cConn, err := cTransport.Connect(ctx)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer cConn.Close()
|
|
|
|
firstParams := json.RawMessage(`{
|
|
"protocolVersion": "2025-11-25",
|
|
"clientInfo": {"name": "first-client", "version": "1.0.0"}
|
|
}`)
|
|
firstReq, err := jsonrpc2.NewCall(jsonrpc2.Int64ID(1), methodInitialize, firstParams)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := cConn.Write(ctx, firstReq); err != nil {
|
|
t.Fatalf("first initialize write failed: %v", err)
|
|
}
|
|
msg, err := cConn.Read(ctx)
|
|
if err != nil {
|
|
t.Fatalf("first initialize read failed: %v", err)
|
|
}
|
|
resp, ok := msg.(*jsonrpc2.Response)
|
|
if !ok {
|
|
t.Fatalf("expected Response, got %T", msg)
|
|
}
|
|
if resp.Error != nil {
|
|
t.Fatalf("first initialize failed: %v", resp.Error)
|
|
}
|
|
|
|
initializedReq, err := jsonrpc2.NewNotification(notificationInitialized, &InitializedParams{})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := cConn.Write(ctx, initializedReq); err != nil {
|
|
t.Fatalf("initialized notification write failed: %v", err)
|
|
}
|
|
|
|
secondParams := json.RawMessage(`{
|
|
"protocolVersion": "2024-11-05",
|
|
"clientInfo": {"name": "second-client", "version": "2.0.0"}
|
|
}`)
|
|
secondReq, err := jsonrpc2.NewCall(jsonrpc2.Int64ID(2), methodInitialize, secondParams)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := cConn.Write(ctx, secondReq); err != nil {
|
|
t.Fatalf("second initialize write failed: %v", err)
|
|
}
|
|
msg, err = cConn.Read(ctx)
|
|
if err != nil {
|
|
t.Fatalf("second initialize read failed: %v", err)
|
|
}
|
|
resp, ok = msg.(*jsonrpc2.Response)
|
|
if !ok {
|
|
t.Fatalf("expected Response, got %T", msg)
|
|
}
|
|
if resp.Error == nil {
|
|
t.Fatal("second initialize unexpectedly succeeded")
|
|
}
|
|
if !strings.Contains(resp.Error.Error(), `duplicate "initialize" received`) {
|
|
t.Fatalf("second initialize error = %v, want duplicate initialize", resp.Error)
|
|
}
|
|
|
|
got := ss.InitializeParams()
|
|
if got == nil {
|
|
t.Fatal("InitializeParams is nil")
|
|
}
|
|
if got.ProtocolVersion != "2025-11-25" {
|
|
t.Fatalf("ProtocolVersion = %q, want first initialize value", got.ProtocolVersion)
|
|
}
|
|
if got.ClientInfo == nil || got.ClientInfo.Name != "first-client" {
|
|
t.Fatalf("ClientInfo = %#v, want first initialize value", got.ClientInfo)
|
|
}
|
|
}
|
|
|
|
// TODO: move this to tool_test.go
|
|
func TestToolForSchemas(t *testing.T) {
|
|
// Validate that toolForErr handles schemas properly.
|
|
type in struct {
|
|
P int `json:"p,omitempty"`
|
|
}
|
|
type out struct {
|
|
B bool `json:"b,omitempty"`
|
|
}
|
|
|
|
var (
|
|
falseSchema = &schema{Not: &schema{}}
|
|
inSchema = &schema{
|
|
Type: "object",
|
|
AdditionalProperties: falseSchema,
|
|
Properties: map[string]*schema{"p": {Type: "integer"}},
|
|
PropertyOrder: []string{"p"},
|
|
}
|
|
inSchema2 = &schema{
|
|
Type: "object",
|
|
AdditionalProperties: falseSchema,
|
|
Properties: map[string]*schema{"p": {Type: "string"}},
|
|
}
|
|
inSchema3 = &schema{
|
|
Type: "object",
|
|
AdditionalProperties: falseSchema,
|
|
Properties: map[string]*schema{}, // empty map is preserved
|
|
}
|
|
outSchema = &schema{
|
|
Type: "object",
|
|
AdditionalProperties: falseSchema,
|
|
Properties: map[string]*schema{"b": {Type: "boolean"}},
|
|
PropertyOrder: []string{"b"},
|
|
}
|
|
outSchema2 = &schema{
|
|
Type: "object",
|
|
AdditionalProperties: falseSchema,
|
|
Properties: map[string]*schema{"b": {Type: "integer"}},
|
|
PropertyOrder: []string{"b"},
|
|
}
|
|
)
|
|
|
|
// Infer both schemas.
|
|
testToolForSchema[in](t, &Tool{}, `{"p":3}`, out{true}, inSchema, outSchema, "")
|
|
// Validate the input schema: expect an error if it's wrong.
|
|
// We can't test that the output schema is validated, because it's typed.
|
|
testToolForSchema[in](t, &Tool{}, `{"p":"x"}`, out{true}, inSchema, outSchema, `want "integer"`)
|
|
// Ignore type any for output.
|
|
testToolForSchema[in, any](t, &Tool{}, `{"p":3}`, 0, inSchema, nil, "")
|
|
// Input is still validated.
|
|
testToolForSchema[in, any](t, &Tool{}, `{"p":"x"}`, 0, inSchema, nil, `want "integer"`)
|
|
// Tool sets input schema: that is what's used.
|
|
testToolForSchema[in, any](t, &Tool{InputSchema: inSchema2}, `{"p":3}`, 0, inSchema2, nil, `want "string"`)
|
|
// Tool sets input schema, empty properties map.
|
|
testToolForSchema[in, any](t, &Tool{InputSchema: inSchema3}, `{}`, 0, inSchema3, nil, "")
|
|
// Tool sets output schema: that is what's used, and validation happens.
|
|
testToolForSchema[in, any](t, &Tool{OutputSchema: outSchema2}, `{"p":3}`, out{true},
|
|
inSchema, outSchema2, `want "integer"`)
|
|
|
|
// Check a slightly more complicated case.
|
|
type weatherOutput struct {
|
|
Summary string
|
|
AsOf time.Time
|
|
Source string
|
|
}
|
|
testToolForSchema[any](t, &Tool{}, `{}`, weatherOutput{},
|
|
&schema{Type: "object"},
|
|
&schema{
|
|
Type: "object",
|
|
Required: []string{"Summary", "AsOf", "Source"},
|
|
AdditionalProperties: falseSchema,
|
|
Properties: map[string]*schema{
|
|
"Summary": {Type: "string"},
|
|
"AsOf": {Type: "string"},
|
|
"Source": {Type: "string"},
|
|
},
|
|
PropertyOrder: []string{"Summary", "AsOf", "Source"},
|
|
},
|
|
"")
|
|
}
|
|
|
|
// TestServerCapabilitiesOverWire verifies that server capabilities are
|
|
// correctly sent over the wire during initialization.
|
|
func TestServerCapabilitiesOverWire(t *testing.T) {
|
|
tool := &Tool{Name: "test-tool", InputSchema: &jsonschema.Schema{Type: "object"}}
|
|
|
|
testCases := []struct {
|
|
name string
|
|
serverOpts *ServerOptions
|
|
configureServer func(s *Server)
|
|
wantCapabilities *ServerCapabilities
|
|
}{
|
|
{
|
|
name: "Default capabilities",
|
|
serverOpts: nil,
|
|
configureServer: func(s *Server) {},
|
|
wantCapabilities: &ServerCapabilities{
|
|
Logging: &LoggingCapabilities{},
|
|
},
|
|
},
|
|
{
|
|
name: "Custom Capabilities with tools",
|
|
serverOpts: &ServerOptions{
|
|
Capabilities: &ServerCapabilities{
|
|
Tools: &ToolCapabilities{ListChanged: false},
|
|
},
|
|
},
|
|
configureServer: func(s *Server) {},
|
|
wantCapabilities: &ServerCapabilities{
|
|
Tools: &ToolCapabilities{ListChanged: false},
|
|
},
|
|
},
|
|
{
|
|
name: "Dynamic tool capability",
|
|
serverOpts: &ServerOptions{
|
|
Capabilities: &ServerCapabilities{
|
|
Logging: &LoggingCapabilities{},
|
|
},
|
|
},
|
|
configureServer: func(s *Server) {
|
|
s.AddTool(tool, nil)
|
|
},
|
|
wantCapabilities: &ServerCapabilities{
|
|
Logging: &LoggingCapabilities{},
|
|
Tools: &ToolCapabilities{ListChanged: true},
|
|
},
|
|
},
|
|
{
|
|
name: "Extensions over wire",
|
|
serverOpts: func() *ServerOptions {
|
|
caps := &ServerCapabilities{
|
|
Logging: &LoggingCapabilities{},
|
|
}
|
|
caps.AddExtension("io.example/ext", map[string]any{"key": "value"})
|
|
return &ServerOptions{Capabilities: caps}
|
|
}(),
|
|
configureServer: func(s *Server) {},
|
|
wantCapabilities: &ServerCapabilities{
|
|
Extensions: map[string]any{
|
|
"io.example/ext": map[string]any{"key": "value"},
|
|
},
|
|
Logging: &LoggingCapabilities{},
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, tc := range testCases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
ctx := context.Background()
|
|
|
|
// Create server.
|
|
impl := &Implementation{Name: "testServer", Version: "v1.0.0"}
|
|
server := NewServer(impl, tc.serverOpts)
|
|
tc.configureServer(server)
|
|
|
|
// Connect client and server.
|
|
cTransport, sTransport := NewInMemoryTransports()
|
|
ss, err := server.Connect(ctx, sTransport, nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer ss.Close()
|
|
|
|
client := NewClient(&Implementation{Name: "testClient", Version: "v1.0.0"}, nil)
|
|
cs, err := client.Connect(ctx, cTransport, nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer cs.Close()
|
|
|
|
// Check that the client received the expected capabilities.
|
|
initResult := cs.InitializeResult()
|
|
if initResult == nil {
|
|
t.Fatal("InitializeResult is nil")
|
|
}
|
|
|
|
if diff := cmp.Diff(tc.wantCapabilities, initResult.Capabilities); diff != "" {
|
|
t.Errorf("Capabilities mismatch (-want +got):\n%s", diff)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// SEP-2575 removes the initialization handshake. An `initialize` request
|
|
// that opts into the new protocol via `_meta.protocolVersion` must be
|
|
// rejected with `Method not found` (-32601).
|
|
func TestServerSessionHandle_RejectsInitializeOnNewProtocol(t *testing.T) {
|
|
|
|
tests := []struct {
|
|
name string
|
|
params any
|
|
wantReject bool
|
|
}{
|
|
{
|
|
name: "initialize with new-protocol _meta is rejected",
|
|
params: map[string]any{
|
|
"_meta": map[string]any{
|
|
MetaKeyProtocolVersion: protocolVersion20260728,
|
|
MetaKeyClientInfo: map[string]any{"name": "c", "version": "1"},
|
|
MetaKeyClientCapabilities: map[string]any{},
|
|
},
|
|
"protocolVersion": protocolVersion20260728,
|
|
},
|
|
wantReject: true,
|
|
},
|
|
{
|
|
name: "initialize without _meta is allowed (old protocol)",
|
|
params: map[string]any{
|
|
"protocolVersion": protocolVersion20251125,
|
|
},
|
|
wantReject: false,
|
|
},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
ss := &ServerSession{server: NewServer(testImpl, nil)}
|
|
id, err := jsonrpc.MakeID("test")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
req := &jsonrpc.Request{
|
|
ID: id,
|
|
Method: methodInitialize,
|
|
Params: mustMarshal(tc.params),
|
|
}
|
|
_, err = ss.handle(context.Background(), req)
|
|
if tc.wantReject {
|
|
if err == nil {
|
|
t.Fatal("expected error rejecting initialize, got nil")
|
|
}
|
|
var jerr *jsonrpc.Error
|
|
if !errors.As(err, &jerr) {
|
|
t.Fatalf("error type = %T, want *jsonrpc.Error so the wire returns the right code", err)
|
|
}
|
|
if jerr.Code != jsonrpc.CodeMethodNotFound {
|
|
t.Errorf("error code = %d, want %d (CodeMethodNotFound = -32601)", jerr.Code, jsonrpc.CodeMethodNotFound)
|
|
}
|
|
if !strings.Contains(jerr.Message, "initialize") {
|
|
t.Errorf("error message %q does not mention %q", jerr.Message, "initialize")
|
|
}
|
|
} else {
|
|
// Old-protocol initialize should be dispatched normally; any
|
|
// CodeMethodNotFound here would mean the rejection branch
|
|
// fired incorrectly.
|
|
var jerr *jsonrpc.Error
|
|
if errors.As(err, &jerr) && jerr.Code == jsonrpc.CodeMethodNotFound {
|
|
t.Errorf("old-protocol initialize was incorrectly rejected: %v", err)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
|
|
t.Run("rejection error encodes to wire as code -32601", func(t *testing.T) {
|
|
// Belt-and-braces check that the error type produced by handle()
|
|
// actually serializes to JSON-RPC code -32601, not a bare 0.
|
|
ss := &ServerSession{server: NewServer(testImpl, nil)}
|
|
id, err := jsonrpc.MakeID("test")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
req := &jsonrpc.Request{
|
|
ID: id,
|
|
Method: methodInitialize,
|
|
Params: mustMarshal(map[string]any{
|
|
"_meta": map[string]any{
|
|
MetaKeyProtocolVersion: protocolVersion20260728,
|
|
MetaKeyClientInfo: map[string]any{"name": "c", "version": "1"},
|
|
MetaKeyClientCapabilities: map[string]any{},
|
|
},
|
|
"protocolVersion": protocolVersion20260728,
|
|
}),
|
|
}
|
|
_, handleErr := ss.handle(context.Background(), req)
|
|
if handleErr == nil {
|
|
t.Fatal("expected rejection error, got nil")
|
|
}
|
|
data, encErr := jsonrpc.EncodeMessage(&jsonrpc.Response{ID: id, Error: handleErr.(*jsonrpc.Error)})
|
|
if encErr != nil {
|
|
t.Fatal(encErr)
|
|
}
|
|
var wire struct {
|
|
Error struct {
|
|
Code int `json:"code"`
|
|
Message string `json:"message"`
|
|
} `json:"error"`
|
|
}
|
|
if err := json.Unmarshal(data, &wire); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if wire.Error.Code != jsonrpc.CodeMethodNotFound {
|
|
t.Errorf("wire error code = %d, want %d; full response = %s", wire.Error.Code, jsonrpc.CodeMethodNotFound, data)
|
|
}
|
|
})
|
|
}
|
|
|
|
// newProtocolParams merges fields into the _meta that opts a request into the
|
|
// sessionless protocol (SEP-2575).
|
|
func newProtocolParams(fields map[string]any) map[string]any {
|
|
params := map[string]any{
|
|
"_meta": map[string]any{
|
|
MetaKeyProtocolVersion: protocolVersion20260728,
|
|
MetaKeyClientInfo: map[string]any{"name": "c", "version": "1"},
|
|
MetaKeyClientCapabilities: map[string]any{},
|
|
},
|
|
}
|
|
for k, v := range fields {
|
|
params[k] = v
|
|
}
|
|
return params
|
|
}
|
|
|
|
func TestServerSessionHandle_SetsResultTypeOnNewProtocol(t *testing.T) {
|
|
server := NewServer(testImpl, &ServerOptions{
|
|
CompletionHandler: func(context.Context, *CompleteRequest) (*CompleteResult, error) {
|
|
return &CompleteResult{
|
|
Completion: CompletionResultDetails{
|
|
Values: []string{"go"},
|
|
},
|
|
}, nil
|
|
},
|
|
})
|
|
AddTool(server, &Tool{Name: "tool"}, func(context.Context, *CallToolRequest, struct{}) (*CallToolResult, any, error) {
|
|
return &CallToolResult{
|
|
InputRequests: InputRequestMap{"confirm": &ElicitParams{Message: "Continue?"}},
|
|
}, nil, nil
|
|
})
|
|
server.AddPrompt(&Prompt{Name: "prompt"}, func(context.Context, *GetPromptRequest) (*GetPromptResult, error) {
|
|
return &GetPromptResult{
|
|
InputRequests: InputRequestMap{"confirm": &ElicitParams{Message: "Continue?"}},
|
|
}, nil
|
|
})
|
|
server.AddResource(&Resource{URI: "test://resource", Name: "resource"}, func(context.Context, *ReadResourceRequest) (*ReadResourceResult, error) {
|
|
return &ReadResourceResult{
|
|
InputRequests: InputRequestMap{"confirm": &ElicitParams{Message: "Continue?"}},
|
|
}, nil
|
|
})
|
|
AddTool(server, &Tool{Name: "toolComplete"}, func(context.Context, *CallToolRequest, struct{}) (*CallToolResult, any, error) {
|
|
return &CallToolResult{Content: []Content{&TextContent{Text: "ok"}}}, nil, nil
|
|
})
|
|
server.AddPrompt(&Prompt{Name: "promptComplete"}, func(context.Context, *GetPromptRequest) (*GetPromptResult, error) {
|
|
return &GetPromptResult{
|
|
Messages: []*PromptMessage{{Role: "assistant", Content: &TextContent{Text: "ok"}}},
|
|
}, nil
|
|
})
|
|
server.AddResource(&Resource{URI: "test://resource-complete", Name: "resourceComplete"}, func(context.Context, *ReadResourceRequest) (*ReadResourceResult, error) {
|
|
return &ReadResourceResult{
|
|
Contents: []*ResourceContents{{URI: "test://resource-complete", Text: "ok"}},
|
|
}, nil
|
|
})
|
|
|
|
tests := []struct {
|
|
name string
|
|
method string
|
|
params map[string]any
|
|
want resultType
|
|
}{
|
|
{
|
|
name: "discover",
|
|
method: methodDiscover,
|
|
params: newProtocolParams(nil),
|
|
want: resultTypeComplete,
|
|
},
|
|
{
|
|
name: "tools list",
|
|
method: methodListTools,
|
|
params: newProtocolParams(nil),
|
|
want: resultTypeComplete,
|
|
},
|
|
{
|
|
name: "prompts list",
|
|
method: methodListPrompts,
|
|
params: newProtocolParams(nil),
|
|
want: resultTypeComplete,
|
|
},
|
|
{
|
|
name: "resources list",
|
|
method: methodListResources,
|
|
params: newProtocolParams(nil),
|
|
want: resultTypeComplete,
|
|
},
|
|
{
|
|
name: "resource templates list",
|
|
method: methodListResourceTemplates,
|
|
params: newProtocolParams(nil),
|
|
want: resultTypeComplete,
|
|
},
|
|
{
|
|
name: "complete",
|
|
method: methodComplete,
|
|
params: newProtocolParams(map[string]any{
|
|
"argument": map[string]any{
|
|
"name": "language",
|
|
"value": "g",
|
|
},
|
|
"ref": map[string]any{
|
|
"type": "ref/prompt",
|
|
"name": "code_review",
|
|
},
|
|
}),
|
|
want: resultTypeComplete,
|
|
},
|
|
{
|
|
name: "tool input required",
|
|
method: methodCallTool,
|
|
params: newProtocolParams(map[string]any{"name": "tool", "arguments": map[string]any{}}),
|
|
want: resultTypeInputRequired,
|
|
},
|
|
{
|
|
name: "prompt input required",
|
|
method: methodGetPrompt,
|
|
params: newProtocolParams(map[string]any{"name": "prompt"}),
|
|
want: resultTypeInputRequired,
|
|
},
|
|
{
|
|
name: "resource input required",
|
|
method: methodReadResource,
|
|
params: newProtocolParams(map[string]any{"uri": "test://resource"}),
|
|
want: resultTypeInputRequired,
|
|
},
|
|
{
|
|
name: "tool complete",
|
|
method: methodCallTool,
|
|
params: newProtocolParams(map[string]any{"name": "toolComplete", "arguments": map[string]any{}}),
|
|
want: resultTypeComplete,
|
|
},
|
|
{
|
|
name: "prompt complete",
|
|
method: methodGetPrompt,
|
|
params: newProtocolParams(map[string]any{"name": "promptComplete"}),
|
|
want: resultTypeComplete,
|
|
},
|
|
{
|
|
name: "resource complete",
|
|
method: methodReadResource,
|
|
params: newProtocolParams(map[string]any{"uri": "test://resource-complete"}),
|
|
want: resultTypeComplete,
|
|
},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
if got := resultTypeOf(t, server, nil, tc.method, tc.params); got != string(tc.want) {
|
|
t.Errorf("resultType = %q, want %q", got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestServerSessionHandle_SetsResultTypeWhenMiddlewareShortCircuits is a
|
|
// regression test for #1225. User middleware wraps outside
|
|
// serverMultiRoundTripMiddleware, so a middleware that returns a result
|
|
// without calling next bypasses both that shim and the dispatcher.
|
|
func TestServerSessionHandle_SetsResultTypeWhenMiddlewareShortCircuits(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
method string
|
|
params map[string]any
|
|
result Result
|
|
want resultType
|
|
}{
|
|
{
|
|
name: "tools/call",
|
|
method: methodCallTool,
|
|
params: newProtocolParams(map[string]any{"name": "tool", "arguments": map[string]any{}}),
|
|
result: &CallToolResult{Content: []Content{&TextContent{Text: "denied"}}},
|
|
want: resultTypeComplete,
|
|
},
|
|
{
|
|
name: "prompts/get",
|
|
method: methodGetPrompt,
|
|
params: newProtocolParams(map[string]any{"name": "prompt"}),
|
|
result: &GetPromptResult{
|
|
Messages: []*PromptMessage{{Role: "assistant", Content: &TextContent{Text: "denied"}}},
|
|
},
|
|
want: resultTypeComplete,
|
|
},
|
|
{
|
|
name: "resources/read",
|
|
method: methodReadResource,
|
|
params: newProtocolParams(map[string]any{"uri": "test://resource"}),
|
|
result: &ReadResourceResult{
|
|
Contents: []*ResourceContents{{URI: "test://resource", Text: "denied"}},
|
|
},
|
|
want: resultTypeComplete,
|
|
},
|
|
{
|
|
// setResultType is unexported, so populating InputRequests is
|
|
// the only way a middleware can ask for input.
|
|
name: "tools/call input required",
|
|
method: methodCallTool,
|
|
params: newProtocolParams(map[string]any{"name": "tool", "arguments": map[string]any{}}),
|
|
result: &CallToolResult{
|
|
InputRequests: InputRequestMap{"confirm": &ElicitParams{Message: "Continue?"}},
|
|
},
|
|
want: resultTypeInputRequired,
|
|
},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
// Nothing is registered: if the middleware fell through to next,
|
|
// dispatch would fail rather than return a result.
|
|
server := NewServer(testImpl, nil)
|
|
server.AddReceivingMiddleware(func(next MethodHandler) MethodHandler {
|
|
return func(ctx context.Context, method string, req Request) (Result, error) {
|
|
if method == tc.method {
|
|
return tc.result, nil
|
|
}
|
|
return next(ctx, method, req)
|
|
}
|
|
})
|
|
if got := resultTypeOf(t, server, nil, tc.method, tc.params); got != string(tc.want) {
|
|
t.Errorf("resultType = %q, want %q", got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestServerSessionHandle_ResultTypeGate pins when a result carries resultType.
|
|
// annotateResultType is the only setter, and handle gates it on the request's
|
|
// _meta rather than on the version the session negotiated. So the last case
|
|
// below gets no resultType even though the session speaks 2026-07-28. Only a
|
|
// client that mixes protocol versions reaches that, so it is recorded rather
|
|
// than fixed.
|
|
func TestServerSessionHandle_ResultTypeGate(t *testing.T) {
|
|
version := func(v string) func(*ServerSessionState) {
|
|
return func(s *ServerSessionState) {
|
|
s.InitializeParams = &InitializeParams{ProtocolVersion: v}
|
|
}
|
|
}
|
|
callParams := map[string]any{"name": "tool", "arguments": map[string]any{}}
|
|
|
|
tests := []struct {
|
|
name string
|
|
state func(*ServerSessionState)
|
|
params map[string]any
|
|
want resultType // "" means the field must be absent
|
|
}{
|
|
{
|
|
name: "legacy session, no _meta",
|
|
state: version(protocolVersion20251125),
|
|
params: callParams,
|
|
},
|
|
{
|
|
name: "legacy session, new-protocol _meta",
|
|
state: version(protocolVersion20251125),
|
|
params: newProtocolParams(callParams),
|
|
want: resultTypeComplete,
|
|
},
|
|
{
|
|
name: "new-protocol session, no _meta",
|
|
state: version(protocolVersion20260728),
|
|
params: callParams,
|
|
},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
server := NewServer(testImpl, nil)
|
|
// Short-circuit so this measures handle's gate, not the
|
|
// dispatcher's.
|
|
server.AddReceivingMiddleware(func(next MethodHandler) MethodHandler {
|
|
return func(context.Context, string, Request) (Result, error) {
|
|
return &CallToolResult{Content: []Content{&TextContent{Text: "ok"}}}, nil
|
|
}
|
|
})
|
|
if got := resultTypeOf(t, server, tc.state, methodCallTool, tc.params); got != string(tc.want) {
|
|
t.Errorf("resultType = %q, want %q", got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestServerSessionHandle_NilResultFromMiddleware checks that a middleware
|
|
// returning no result does not panic. A typed nil satisfies Result, so the
|
|
// annotation helpers would otherwise dereference it.
|
|
func TestServerSessionHandle_NilResultFromMiddleware(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
result Result
|
|
}{
|
|
{"nil interface", nil},
|
|
{"typed nil", (*CallToolResult)(nil)},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
server := NewServer(testImpl, nil)
|
|
server.AddReceivingMiddleware(func(next MethodHandler) MethodHandler {
|
|
return func(context.Context, string, Request) (Result, error) {
|
|
return tc.result, nil
|
|
}
|
|
})
|
|
ss := &ServerSession{server: server}
|
|
id, err := jsonrpc.MakeID("test")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
res, err := ss.handle(context.Background(), &jsonrpc.Request{
|
|
ID: id,
|
|
Method: methodCallTool,
|
|
Params: mustMarshal(newProtocolParams(map[string]any{"name": "tool", "arguments": map[string]any{}})),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("handle: %v", err)
|
|
}
|
|
data, err := json.Marshal(res)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got := string(data); got != "null" {
|
|
t.Errorf("result = %s, want null", got)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// resultTypeOf handles one request on a transportless session and reports the
|
|
// resultType of the marshaled result, or "" if the field is absent.
|
|
func resultTypeOf(t *testing.T, server *Server, state func(*ServerSessionState), method string, params map[string]any) string {
|
|
t.Helper()
|
|
ss := &ServerSession{server: server}
|
|
if state != nil {
|
|
ss.updateState(state)
|
|
}
|
|
id, err := jsonrpc.MakeID("test")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
result, err := ss.handle(context.Background(), &jsonrpc.Request{
|
|
ID: id,
|
|
Method: method,
|
|
Params: mustMarshal(params),
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
data, err := json.Marshal(result)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var fields map[string]json.RawMessage
|
|
if err := json.Unmarshal(data, &fields); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
raw, ok := fields["resultType"]
|
|
if !ok {
|
|
return ""
|
|
}
|
|
var got string
|
|
if err := json.Unmarshal(raw, &got); err != nil {
|
|
t.Fatalf("unmarshaling resultType from %s: %v", data, err)
|
|
}
|
|
return got
|
|
}
|
|
|
|
// TestServerSessionHandle_RejectsRemovedMethodsOnNewProtocol verifies that
|
|
// the methods removed by SEP-2575 (`initialize`, `notifications/initialized`,
|
|
// `ping`) all return Method not found when the request opts into the new
|
|
// protocol via `_meta.protocolVersion`.
|
|
func TestServerSessionHandle_RejectsRemovedMethodsOnNewProtocol(t *testing.T) {
|
|
|
|
newProtoMeta := map[string]any{
|
|
"_meta": map[string]any{
|
|
MetaKeyProtocolVersion: protocolVersion20260728,
|
|
MetaKeyClientInfo: map[string]any{"name": "c", "version": "1"},
|
|
MetaKeyClientCapabilities: map[string]any{},
|
|
},
|
|
}
|
|
|
|
tests := []struct {
|
|
name string
|
|
method string
|
|
}{
|
|
{"initialize", methodInitialize},
|
|
{"ping", methodPing},
|
|
{"notifications/initialized", notificationInitialized},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name+" rejected on new protocol", func(t *testing.T) {
|
|
ss := &ServerSession{server: NewServer(testImpl, nil)}
|
|
id, err := jsonrpc.MakeID("test")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
req := &jsonrpc.Request{
|
|
ID: id,
|
|
Method: tc.method,
|
|
Params: mustMarshal(newProtoMeta),
|
|
}
|
|
_, err = ss.handle(context.Background(), req)
|
|
if err == nil {
|
|
t.Fatalf("method %q on new protocol: got nil error, want CodeMethodNotFound", tc.method)
|
|
}
|
|
var jerr *jsonrpc.Error
|
|
if !errors.As(err, &jerr) {
|
|
t.Fatalf("error type = %T, want *jsonrpc.Error", err)
|
|
}
|
|
if jerr.Code != jsonrpc.CodeMethodNotFound {
|
|
t.Errorf("method %q: code = %d, want %d", tc.method, jerr.Code, jsonrpc.CodeMethodNotFound)
|
|
}
|
|
if !strings.Contains(jerr.Message, tc.method) {
|
|
t.Errorf("method %q: message %q does not mention method name", tc.method, jerr.Message)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestServerSession_RejectsServerInitiatedRequests verifies that
|
|
// SEP-2322 / SEP-2575 is enforced at the API surface: [ServerSession.Elicit],
|
|
// [ServerSession.CreateMessage], [ServerSession.CreateMessageWithTools], and
|
|
// [ServerSession.ListRoots] must refuse to send a server-to-client request
|
|
// when the session is negotiated at protocol version >= 2026-07-28, and must
|
|
// remain functional on pre-2026-07-28 sessions.
|
|
func TestServerSession_RejectsServerInitiated(t *testing.T) {
|
|
ctx := context.Background()
|
|
|
|
tests := []struct {
|
|
name string
|
|
call func(*ServerSession) error
|
|
}{
|
|
{
|
|
name: "Elicit",
|
|
call: func(ss *ServerSession) error {
|
|
_, err := ss.Elicit(ctx, &ElicitParams{Message: "hi"})
|
|
return err
|
|
},
|
|
},
|
|
{
|
|
name: "CreateMessage",
|
|
call: func(ss *ServerSession) error {
|
|
_, err := ss.CreateMessage(ctx, &CreateMessageParams{})
|
|
return err
|
|
},
|
|
},
|
|
{
|
|
name: "CreateMessageWithTools",
|
|
call: func(ss *ServerSession) error {
|
|
_, err := ss.CreateMessageWithTools(ctx, &CreateMessageWithToolsParams{})
|
|
return err
|
|
},
|
|
},
|
|
{
|
|
name: "ListRoots",
|
|
call: func(ss *ServerSession) error {
|
|
_, err := ss.ListRoots(ctx, nil)
|
|
return err
|
|
},
|
|
},
|
|
}
|
|
|
|
// Cover both branches of the era gate: modern must reject, legacy must let
|
|
// the call proceed to the wire (where it either succeeds or fails on
|
|
// something unrelated to the guard, such as missing client capabilities).
|
|
for _, protoVer := range []string{protocolVersion20260728, protocolVersion20251125} {
|
|
for _, tc := range tests {
|
|
t.Run(fmt.Sprintf("%s/%s", protoVer, tc.name), func(t *testing.T) {
|
|
ct, st := NewInMemoryTransports()
|
|
s := NewServer(testImpl, nil)
|
|
ss, err := s.Connect(ctx, st, nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() { _ = ss.Close() })
|
|
|
|
// Client advertises every server-to-client capability so a
|
|
// legacy-era call is not rejected for capability reasons
|
|
// instead of era reasons.
|
|
c := NewClient(testImpl, &ClientOptions{
|
|
ElicitationHandler: func(context.Context, *ElicitRequest) (*ElicitResult, error) {
|
|
return &ElicitResult{Action: "cancel"}, nil
|
|
},
|
|
CreateMessageHandler: func(context.Context, *CreateMessageRequest) (*CreateMessageResult, error) {
|
|
return &CreateMessageResult{Model: "m", Role: "assistant", Content: &TextContent{Text: "ok"}}, nil
|
|
},
|
|
})
|
|
c.AddRoots(&Root{URI: "file:///workspace"})
|
|
cs, err := c.Connect(ctx, ct, &ClientSessionOptions{ProtocolVersion: protoVer})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() { _ = cs.Close() })
|
|
|
|
gotErr := tc.call(ss)
|
|
modern := protoVer >= protocolVersion20260728
|
|
if modern {
|
|
if gotErr == nil {
|
|
t.Fatalf("%s on %s: got nil error, want era-gate rejection", tc.name, protoVer)
|
|
}
|
|
wantSubstr := "cannot be sent while serving a request on protocol version " + protoVer
|
|
if !strings.Contains(gotErr.Error(), wantSubstr) {
|
|
t.Errorf("%s on %s: error %q does not contain %q", tc.name, protoVer, gotErr.Error(), wantSubstr)
|
|
}
|
|
if !strings.Contains(gotErr.Error(), "multi round-trip requests") {
|
|
t.Errorf("%s on %s: error %q does not steer to MRTR", tc.name, protoVer, gotErr.Error())
|
|
}
|
|
} else {
|
|
if gotErr != nil {
|
|
t.Errorf("%s on %s: got error %v, want nil (era gate must not fire on legacy)", tc.name, protoVer, gotErr)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestServerSetCacheable(t *testing.T) {
|
|
ctx := context.Background()
|
|
// The values an opinionated resource handler sets on its own result.
|
|
handlerValue := Cacheable{TTLMs: 9_000, CacheScope: "private"}
|
|
|
|
for _, tc := range []struct {
|
|
name string
|
|
callback func(context.Context, Request, *Cacheable)
|
|
wantSDK Cacheable // for a result whose fields nobody set
|
|
wantHandler Cacheable // for a result the handler set fields on
|
|
}{
|
|
{
|
|
name: "no callback",
|
|
wantSDK: Cacheable{TTLMs: 0, CacheScope: "public"},
|
|
wantHandler: handlerValue,
|
|
},
|
|
{
|
|
name: "callback overrules the handler",
|
|
callback: func(_ context.Context, _ Request, c *Cacheable) {
|
|
c.TTLMs, c.CacheScope = 60_000, "private"
|
|
},
|
|
wantSDK: Cacheable{TTLMs: 60_000, CacheScope: "private"},
|
|
wantHandler: Cacheable{TTLMs: 60_000, CacheScope: "private"},
|
|
},
|
|
{
|
|
// The callback is handed what the handler produced, so it can
|
|
// single out the results that expressed no opinion. The scope it
|
|
// leaves empty is still filled with the protocol default.
|
|
name: "callback defers to the handler",
|
|
callback: func(_ context.Context, _ Request, c *Cacheable) {
|
|
if c.CacheScope == "" {
|
|
c.TTLMs = 1_000
|
|
}
|
|
},
|
|
wantSDK: Cacheable{TTLMs: 1_000, CacheScope: "public"},
|
|
wantHandler: handlerValue,
|
|
},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
s := NewServer(testImpl, &ServerOptions{SetCacheable: tc.callback})
|
|
s.AddResource(&Resource{URI: "test://plain", Name: "plain"},
|
|
func(_ context.Context, req *ReadResourceRequest) (*ReadResourceResult, error) {
|
|
return &ReadResourceResult{Contents: []*ResourceContents{{URI: req.Params.URI, Text: "x"}}}, nil
|
|
})
|
|
s.AddResource(&Resource{URI: "test://opinionated", Name: "opinionated"},
|
|
func(_ context.Context, req *ReadResourceRequest) (*ReadResourceResult, error) {
|
|
return &ReadResourceResult{
|
|
Cacheable: handlerValue,
|
|
Contents: []*ResourceContents{{URI: req.Params.URI, Text: "x"}},
|
|
}, nil
|
|
})
|
|
cs := mustConnect(t, s, nil)
|
|
|
|
check := func(label string, got, want Cacheable) {
|
|
t.Helper()
|
|
if got != want {
|
|
t.Errorf("%s Cacheable = %+v, want %+v", label, got, want)
|
|
}
|
|
}
|
|
|
|
tools, err := cs.ListTools(ctx, nil) // built by the SDK, never by a handler
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
check("tools/list", tools.Cacheable, tc.wantSDK)
|
|
|
|
for uri, want := range map[string]Cacheable{
|
|
"test://plain": tc.wantSDK,
|
|
"test://opinionated": tc.wantHandler,
|
|
} {
|
|
res, err := cs.ReadResource(ctx, &ReadResourceParams{URI: uri})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
check(uri, res.Cacheable, want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
}
|
|
|
|
// TestServerUnknownProtocolVersion_NewProtocol verifies that a request whose
|
|
// `_meta.protocolVersion` names a version the SDK does not know is rejected
|
|
// with [CodeUnsupportedProtocolVersion], and not served as a legacy handshake
|
|
// because its version string happens to sort below 2026-07-28.
|
|
func TestServerUnknownProtocolVersion_NewProtocol(t *testing.T) {
|
|
ctx := context.Background()
|
|
// Each case names a version that is not in [supportedProtocolVersions] and
|
|
// sorts below 2026-07-28.
|
|
tests := []struct {
|
|
name string
|
|
version string
|
|
extraMeta string // further `_meta` entries, each with a leading comma
|
|
}{
|
|
// The versioning spec uses 1900-01-01 to illustrate this error.
|
|
{"documented example", "1900-01-01", fmt.Sprintf(`,%q:{}`, MetaKeyClientCapabilities)},
|
|
{"unreleased revision", "2025-11-24", fmt.Sprintf(`,%q:{}`, MetaKeyClientCapabilities)},
|
|
{"not a date", "1.0", fmt.Sprintf(`,%q:{}`, MetaKeyClientCapabilities)},
|
|
// The rest of the `_meta` triple is defined by the revision the request
|
|
// names, so its absence must not mask the version itself.
|
|
{"no clientCapabilities", "1900-01-01", ""},
|
|
}
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
server := NewServer(testImpl, nil)
|
|
_, st := NewInMemoryTransports()
|
|
ss, err := server.Connect(ctx, st, nil)
|
|
if err != nil {
|
|
t.Fatalf("server.Connect: %v", err)
|
|
}
|
|
defer ss.Close()
|
|
|
|
params := fmt.Sprintf(`{"_meta":{%q:%q%s}}`, MetaKeyProtocolVersion, tc.version, tc.extraMeta)
|
|
_, 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 data.Requested != tc.version {
|
|
t.Errorf("UnsupportedProtocolVersionData.Requested = %q, want %q", data.Requested, tc.version)
|
|
}
|
|
if diff := cmp.Diff(SupportedProtocolVersions(), data.Supported); diff != "" {
|
|
t.Errorf("UnsupportedProtocolVersionData.Supported mismatch (-want +got):\n%s", diff)
|
|
}
|
|
})
|
|
}
|
|
}
|