Closes #1271. `Server.handle` refuses a call made before `initialize` on a legacy session, and that refusal lives in the `default` branch of its method switch, so any method with a `case` of its own skips it. Three are served on a session that never handshook: `logging/setLevel`, `resources/subscribe` and `resources/unsubscribe`. `resources/subscribe` is the one that reaches state rather than merely answering: it starts a watcher and registers a subscriber, so a client that subscribes before `initialize` is delivered `notifications/resources/updated` for the lifetime of a session the server never agreed to. The check now runs after the switch, for every call carrying no new-protocol `_meta`, with `initialize` and `ping` exempt. That is the same condition the `default` branch already applied and the same error it produced; the change is that the three methods above stop skipping it. `ping` stays allowed because the lifecycle page exempts it. `server/discover` is deliberately left alone: a legacy request there already receives method-not-found, which tells the client more than an initialization error would. The two notification cases stay outside, since the gate covers calls and a notification carries no refusal. The SEP-2575 path is untouched. A session without an `initialize` handshake is exactly what that proposal is for, so the exemption is the request's own `_meta.protocolVersion` rather than anything about the session, and a new-protocol client is served as it is today. Tests: `TestServerHandle_LegacyCallBeforeInitialize` drives each affected method on a fresh session and asserts the refusal, that `resources/subscribe` runs no handler and leaves no subscriber, and that `initialize` and `ping` are served. `TestServerHandle_NewProtocolCallWithoutInitialize` asserts a new-protocol session with no handshake still gets `tools/list`, and that the three methods keep their existing method-not-found rather than the initialization refusal. Without the change, the setLevel, subscribe and unsubscribe rows fail. `go test ./mcp/ -count=1` is green, `gofmt -l` is empty and `go vet ./mcp/` is clean. This does not depend on #1266 and rebases onto it cleanly, verified with the suite green on the combination. One decision worth your view: the refusal is a plain error, which on the wire carries code 0, exactly as the `default` branch produced before this change. The specification prescribes no code for it. If you would rather it were `-32600` or `-32603`, say so and I will change it here. --------- Co-authored-by: Guglielmo Colombo <guglielmoc@google.com>
2413 lines
73 KiB
Go
2413 lines
73 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)
|
|
}
|
|
}
|
|
|
|
// TestServerHandle_LegacyCallBeforeInitialize asserts that a call carrying no
|
|
// new-protocol `_meta` is refused on a session that has not completed
|
|
// 'initialize', whichever branch of the method switch it lands in. The
|
|
// handshake itself is what ends that state, and the lifecycle spec exempts
|
|
// pings, so those two are served.
|
|
//
|
|
// The gate used to sit in the default branch, so 'logging/setLevel',
|
|
// 'resources/subscribe' and 'resources/unsubscribe', which share a case that
|
|
// only refuses new-protocol requests, were served before the handshake. For
|
|
// subscribe that registered a session that never handshook as a subscriber,
|
|
// so the subtest also asserts that the refusal leaves no subscriber and runs
|
|
// no handler.
|
|
func TestServerHandle_LegacyCallBeforeInitialize(t *testing.T) {
|
|
ctx := context.Background()
|
|
const uri = "test://resource"
|
|
|
|
tests := []struct {
|
|
name string
|
|
method string
|
|
params string
|
|
served bool
|
|
}{
|
|
{
|
|
name: "initialize",
|
|
method: methodInitialize,
|
|
params: fmt.Sprintf(`{"protocolVersion":%q,"capabilities":{},"clientInfo":{"name":"c","version":"1"}}`, protocolVersion20251125),
|
|
served: true,
|
|
},
|
|
{name: "ping", method: methodPing, params: `{}`, served: true},
|
|
{name: "tools/list", method: methodListTools, params: `{}`},
|
|
{name: "logging/setLevel", method: methodSetLevel, params: `{"level":"info"}`},
|
|
{name: "resources/subscribe", method: methodSubscribe, params: fmt.Sprintf(`{"uri":%q}`, uri)},
|
|
{name: "resources/unsubscribe", method: methodUnsubscribe, params: fmt.Sprintf(`{"uri":%q}`, uri)},
|
|
}
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
var handlerRan bool
|
|
server := NewServer(testImpl, &ServerOptions{
|
|
SubscribeHandler: func(context.Context, *SubscribeRequest) error {
|
|
handlerRan = true
|
|
return nil
|
|
},
|
|
UnsubscribeHandler: func(context.Context, *UnsubscribeRequest) error {
|
|
handlerRan = true
|
|
return nil
|
|
},
|
|
})
|
|
_, st := NewInMemoryTransports()
|
|
ss, err := server.Connect(ctx, st, nil)
|
|
if err != nil {
|
|
t.Fatalf("server.Connect: %v", err)
|
|
}
|
|
defer ss.Close()
|
|
|
|
res, err := ss.handle(ctx, &jsonrpc.Request{
|
|
ID: jsonrpc2.Int64ID(1),
|
|
Method: tc.method,
|
|
Params: json.RawMessage(tc.params),
|
|
})
|
|
if tc.served {
|
|
if err != nil {
|
|
t.Fatalf("handle(%q) before initialize returned %v, want it served", tc.method, err)
|
|
}
|
|
if res == nil {
|
|
t.Fatalf("handle(%q) before initialize returned no result", tc.method)
|
|
}
|
|
if tc.method == methodInitialize && ss.InitializeParams() == nil {
|
|
t.Errorf("initialize was answered without recording InitializeParams")
|
|
}
|
|
return
|
|
}
|
|
if err == nil {
|
|
t.Fatalf("handle(%q) served the call before initialize, want a refusal", tc.method)
|
|
}
|
|
if want := fmt.Sprintf("method %q is invalid during session initialization", tc.method); err.Error() != want {
|
|
t.Errorf("handle(%q) error = %q, want %q", tc.method, err.Error(), want)
|
|
}
|
|
if handlerRan {
|
|
t.Errorf("handle(%q) ran the server's handler for a call it refused", tc.method)
|
|
}
|
|
server.mu.Lock()
|
|
_, subscribed := server.resourceSubscriptions[uri][ss]
|
|
server.mu.Unlock()
|
|
if subscribed {
|
|
t.Errorf("handle(%q) left the session subscribed to %q", tc.method, uri)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestServerHandle_NewProtocolCallWithoutInitialize asserts that the gate
|
|
// stays off the SEP-2575 path. A fresh session that never sends 'initialize'
|
|
// is served a new-protocol 'tools/list', and the methods the gate now covers
|
|
// are answered, when a new-protocol request names them, with the error the
|
|
// new protocol already gave them (the method does not exist there), and not
|
|
// with the initialization refusal.
|
|
func TestServerHandle_NewProtocolCallWithoutInitialize(t *testing.T) {
|
|
ctx := context.Background()
|
|
const uri = "test://resource"
|
|
|
|
tests := []struct {
|
|
name string
|
|
method string
|
|
params map[string]any
|
|
served bool
|
|
}{
|
|
{name: "tools/list", method: methodListTools, params: newProtocolParams(nil), served: true},
|
|
{name: "logging/setLevel", method: methodSetLevel, params: newProtocolParams(map[string]any{"level": "info"})},
|
|
{name: "resources/subscribe", method: methodSubscribe, params: newProtocolParams(map[string]any{"uri": uri})},
|
|
{name: "resources/unsubscribe", method: methodUnsubscribe, params: newProtocolParams(map[string]any{"uri": uri})},
|
|
}
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
server := NewServer(testImpl, &ServerOptions{
|
|
SubscribeHandler: func(context.Context, *SubscribeRequest) error { return nil },
|
|
UnsubscribeHandler: func(context.Context, *UnsubscribeRequest) error { return nil },
|
|
})
|
|
_, st := NewInMemoryTransports()
|
|
ss, err := server.Connect(ctx, st, nil)
|
|
if err != nil {
|
|
t.Fatalf("server.Connect: %v", err)
|
|
}
|
|
defer ss.Close()
|
|
|
|
params, err := json.Marshal(tc.params)
|
|
if err != nil {
|
|
t.Fatalf("marshalling params: %v", err)
|
|
}
|
|
res, err := ss.handle(ctx, &jsonrpc.Request{
|
|
ID: jsonrpc2.Int64ID(1),
|
|
Method: tc.method,
|
|
Params: params,
|
|
})
|
|
if tc.served {
|
|
if err != nil {
|
|
t.Fatalf("handle(%q) on a new-protocol session returned %v, want it served", tc.method, err)
|
|
}
|
|
if res == nil {
|
|
t.Fatalf("handle(%q) on a new-protocol session returned no result", tc.method)
|
|
}
|
|
return
|
|
}
|
|
var jerr *jsonrpc.Error
|
|
if !errors.As(err, &jerr) {
|
|
t.Fatalf("handle(%q) returned %v, want a *jsonrpc.Error", tc.method, err)
|
|
}
|
|
if jerr.Code != jsonrpc.CodeMethodNotFound {
|
|
t.Errorf("handle(%q) error code = %d, want %d (removed in the new protocol)", tc.method, jerr.Code, jsonrpc.CodeMethodNotFound)
|
|
}
|
|
if strings.Contains(err.Error(), "invalid during session initialization") {
|
|
t.Errorf("handle(%q) applied the initialization gate to a new-protocol request: %v", tc.method, err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
})
|
|
}
|
|
}
|