An onClose function is passed to the ServerSession and ClientSession to help cleanup resources from the caller. The onClose function will be executed as part of the ServerSession and ClientSession closure. Fixes #258
1293 lines
40 KiB
Go
1293 lines
40 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"
|
|
"io"
|
|
"maps"
|
|
"net"
|
|
"net/http"
|
|
"net/http/cookiejar"
|
|
"net/http/httptest"
|
|
"net/http/httputil"
|
|
"net/url"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/google/go-cmp/cmp"
|
|
"github.com/google/go-cmp/cmp/cmpopts"
|
|
"github.com/google/jsonschema-go/jsonschema"
|
|
"github.com/modelcontextprotocol/go-sdk/auth"
|
|
"github.com/modelcontextprotocol/go-sdk/internal/jsonrpc2"
|
|
"github.com/modelcontextprotocol/go-sdk/jsonrpc"
|
|
)
|
|
|
|
func TestStreamableTransports(t *testing.T) {
|
|
// This test checks that the streamable server and client transports can
|
|
// communicate.
|
|
|
|
ctx := context.Background()
|
|
|
|
for _, useJSON := range []bool{false, true} {
|
|
t.Run(fmt.Sprintf("JSONResponse=%v", useJSON), func(t *testing.T) {
|
|
// Create a server with some simple tools.
|
|
server := NewServer(testImpl, nil)
|
|
AddTool(server, &Tool{Name: "greet", Description: "say hi"}, sayHi)
|
|
// The "hang" tool checks that context cancellation is propagated.
|
|
// It hangs until the context is cancelled.
|
|
var (
|
|
start = make(chan struct{})
|
|
cancelled = make(chan struct{}, 1) // don't block the request
|
|
)
|
|
hang := func(ctx context.Context, req *CallToolRequest, args any) (*CallToolResult, any, error) {
|
|
start <- struct{}{}
|
|
select {
|
|
case <-ctx.Done():
|
|
cancelled <- struct{}{}
|
|
case <-time.After(5 * time.Second):
|
|
return nil, nil, nil
|
|
}
|
|
return nil, nil, nil
|
|
}
|
|
AddTool(server, &Tool{Name: "hang"}, hang)
|
|
AddTool(server, &Tool{Name: "sample"}, func(ctx context.Context, req *CallToolRequest, args any) (*CallToolResult, any, error) {
|
|
// Test that we can make sampling requests during tool handling.
|
|
//
|
|
// Try this on both the request context and a background context, so
|
|
// that messages may be delivered on either the POST or GET connection.
|
|
for _, ctx := range map[string]context.Context{
|
|
"request context": ctx,
|
|
"background context": context.Background(),
|
|
} {
|
|
res, err := req.Session.CreateMessage(ctx, &CreateMessageParams{})
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
if g, w := res.Model, "aModel"; g != w {
|
|
return nil, nil, fmt.Errorf("got %q, want %q", g, w)
|
|
}
|
|
}
|
|
return &CallToolResult{}, nil, nil
|
|
})
|
|
|
|
// Start an httptest.Server with the StreamableHTTPHandler, wrapped in a
|
|
// cookie-checking middleware.
|
|
handler := NewStreamableHTTPHandler(func(req *http.Request) *Server { return server }, &StreamableHTTPOptions{
|
|
jsonResponse: useJSON,
|
|
})
|
|
|
|
var (
|
|
headerMu sync.Mutex
|
|
lastHeader http.Header
|
|
)
|
|
httpServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
headerMu.Lock()
|
|
lastHeader = r.Header
|
|
headerMu.Unlock()
|
|
cookie, err := r.Cookie("test-cookie")
|
|
if err != nil {
|
|
t.Errorf("missing cookie: %v", err)
|
|
} else if cookie.Value != "test-value" {
|
|
t.Errorf("got cookie %q, want %q", cookie.Value, "test-value")
|
|
}
|
|
handler.ServeHTTP(w, r)
|
|
}))
|
|
defer httpServer.Close()
|
|
|
|
// Create a client and connect it to the server using our StreamableClientTransport.
|
|
// Check that all requests honor a custom client.
|
|
jar, err := cookiejar.New(nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
u, err := url.Parse(httpServer.URL)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
jar.SetCookies(u, []*http.Cookie{{Name: "test-cookie", Value: "test-value"}})
|
|
httpClient := &http.Client{Jar: jar}
|
|
transport := &StreamableClientTransport{
|
|
Endpoint: httpServer.URL,
|
|
HTTPClient: httpClient,
|
|
}
|
|
client := NewClient(testImpl, &ClientOptions{
|
|
CreateMessageHandler: func(context.Context, *CreateMessageRequest) (*CreateMessageResult, error) {
|
|
return &CreateMessageResult{Model: "aModel", Content: &TextContent{}}, nil
|
|
},
|
|
})
|
|
session, err := client.Connect(ctx, transport, nil)
|
|
if err != nil {
|
|
t.Fatalf("client.Connect() failed: %v", err)
|
|
}
|
|
defer session.Close()
|
|
sid := session.ID()
|
|
if sid == "" {
|
|
t.Error("empty session ID")
|
|
}
|
|
if g, w := session.mcpConn.(*streamableClientConn).initializedResult.ProtocolVersion, latestProtocolVersion; g != w {
|
|
t.Fatalf("got protocol version %q, want %q", g, w)
|
|
}
|
|
|
|
// Verify the behavior of various tools.
|
|
|
|
// The "greet" tool should just work.
|
|
params := &CallToolParams{
|
|
Name: "greet",
|
|
Arguments: map[string]any{"name": "foo"},
|
|
}
|
|
got, err := session.CallTool(ctx, params)
|
|
if err != nil {
|
|
t.Fatalf("CallTool() failed: %v", err)
|
|
}
|
|
if g := session.ID(); g != sid {
|
|
t.Errorf("session ID: got %q, want %q", g, sid)
|
|
}
|
|
if g, w := lastHeader.Get(protocolVersionHeader), latestProtocolVersion; g != w {
|
|
t.Errorf("got protocol version header %q, want %q", g, w)
|
|
}
|
|
want := &CallToolResult{
|
|
Content: []Content{&TextContent{Text: "hi foo"}},
|
|
}
|
|
if diff := cmp.Diff(want, got); diff != "" {
|
|
t.Errorf("CallTool() returned unexpected content (-want +got):\n%s", diff)
|
|
}
|
|
|
|
// The "hang" tool should be cancellable.
|
|
ctx2, cancel := context.WithCancel(context.Background())
|
|
go session.CallTool(ctx2, &CallToolParams{Name: "hang"})
|
|
<-start
|
|
cancel()
|
|
select {
|
|
case <-cancelled:
|
|
case <-time.After(5 * time.Second):
|
|
t.Fatal("timeout waiting for cancellation")
|
|
}
|
|
|
|
// The "sampling" tool should be able to issue sampling requests during
|
|
// tool operation.
|
|
result, err := session.CallTool(ctx, &CallToolParams{
|
|
Name: "sample",
|
|
Arguments: map[string]any{},
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if result.IsError {
|
|
t.Fatalf("tool failed: %s", result.Content[0].(*TextContent).Text)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestClientReplay verifies that the client can recover from a mid-stream
|
|
// network failure and receive replayed messages (if replay is configured). It
|
|
// uses a proxy that is killed and restarted to simulate a recoverable network
|
|
// outage.
|
|
func TestClientReplay(t *testing.T) {
|
|
for _, test := range []clientReplayTest{
|
|
{"default", 0, true},
|
|
{"no retries", -1, false},
|
|
} {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
testClientReplay(t, test)
|
|
})
|
|
}
|
|
}
|
|
|
|
type clientReplayTest struct {
|
|
name string
|
|
maxRetries int
|
|
wantRecovered bool
|
|
}
|
|
|
|
func testClientReplay(t *testing.T, test clientReplayTest) {
|
|
notifications := make(chan string)
|
|
// Configure the real MCP server.
|
|
server := NewServer(testImpl, nil)
|
|
|
|
// Use a channel to synchronize the server's message sending with the test's
|
|
// proxy-killing action.
|
|
serverReadyToKillProxy := make(chan struct{})
|
|
serverClosed := make(chan struct{})
|
|
AddTool(server, &Tool{Name: "multiMessageTool", InputSchema: &jsonschema.Schema{Type: "object"}},
|
|
func(ctx context.Context, req *CallToolRequest, args map[string]any) (*CallToolResult, any, error) {
|
|
// Send one message to the request context, and another to a background
|
|
// context (which will end up on the hanging GET).
|
|
|
|
bgCtx := context.Background()
|
|
req.Session.NotifyProgress(ctx, &ProgressNotificationParams{Message: "msg1"})
|
|
req.Session.NotifyProgress(bgCtx, &ProgressNotificationParams{Message: "msg2"})
|
|
|
|
// Signal the test that it can now kill the proxy.
|
|
close(serverReadyToKillProxy)
|
|
<-serverClosed
|
|
|
|
// These messages should be queued for replay by the server after
|
|
// the client's connection drops.
|
|
req.Session.NotifyProgress(ctx, &ProgressNotificationParams{Message: "msg3"})
|
|
req.Session.NotifyProgress(bgCtx, &ProgressNotificationParams{Message: "msg4"})
|
|
return new(CallToolResult), nil, nil
|
|
})
|
|
|
|
realServer := httptest.NewServer(NewStreamableHTTPHandler(func(*http.Request) *Server { return server }, nil))
|
|
defer realServer.Close()
|
|
realServerURL, err := url.Parse(realServer.URL)
|
|
if err != nil {
|
|
t.Fatalf("Failed to parse real server URL: %v", err)
|
|
}
|
|
|
|
// Configure a proxy that sits between the client and the real server.
|
|
proxyHandler := httputil.NewSingleHostReverseProxy(realServerURL)
|
|
proxy := httptest.NewServer(proxyHandler)
|
|
proxyAddr := proxy.Listener.Addr().String() // Get the address to restart it later.
|
|
|
|
// Configure the client to connect to the proxy with default options.
|
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
defer cancel()
|
|
client := NewClient(testImpl, &ClientOptions{
|
|
ProgressNotificationHandler: func(ctx context.Context, req *ProgressNotificationClientRequest) {
|
|
notifications <- req.Params.Message
|
|
},
|
|
})
|
|
clientSession, err := client.Connect(ctx, &StreamableClientTransport{
|
|
Endpoint: proxy.URL,
|
|
MaxRetries: test.maxRetries,
|
|
}, nil)
|
|
if err != nil {
|
|
t.Fatalf("client.Connect() failed: %v", err)
|
|
}
|
|
defer clientSession.Close()
|
|
|
|
var (
|
|
wg sync.WaitGroup
|
|
callErr error
|
|
)
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
_, callErr = clientSession.CallTool(ctx, &CallToolParams{Name: "multiMessageTool"})
|
|
}()
|
|
|
|
select {
|
|
case <-serverReadyToKillProxy:
|
|
// Server has sent the first two messages and is paused.
|
|
case <-ctx.Done():
|
|
t.Fatalf("Context timed out before server was ready to kill proxy")
|
|
}
|
|
|
|
// We should always get the first two notifications.
|
|
msgs := readNotifications(t, ctx, notifications, 2)
|
|
sort.Strings(msgs) // notifications may arrive in either order
|
|
want := []string{"msg1", "msg2"}
|
|
if diff := cmp.Diff(want, msgs); diff != "" {
|
|
t.Errorf("Recovered notifications mismatch (-want +got):\n%s", diff)
|
|
}
|
|
|
|
// Simulate a total network failure by closing the proxy.
|
|
t.Log("--- Killing proxy to simulate network failure ---")
|
|
proxy.CloseClientConnections()
|
|
proxy.Close()
|
|
close(serverClosed)
|
|
|
|
// Simulate network recovery by restarting the proxy on the same address.
|
|
t.Logf("--- Restarting proxy on %s ---", proxyAddr)
|
|
listener, err := net.Listen("tcp", proxyAddr)
|
|
if err != nil {
|
|
t.Fatalf("Failed to listen on proxy address: %v", err)
|
|
}
|
|
|
|
restartedProxy := &http.Server{Handler: proxyHandler}
|
|
go restartedProxy.Serve(listener)
|
|
defer restartedProxy.Close()
|
|
|
|
wg.Wait()
|
|
|
|
if test.wantRecovered {
|
|
// If we've recovered, we should get all 4 notifications and the tool call
|
|
// should have succeeded.
|
|
msgs := readNotifications(t, ctx, notifications, 2)
|
|
sort.Strings(msgs)
|
|
want := []string{"msg3", "msg4"}
|
|
if diff := cmp.Diff(want, msgs); diff != "" {
|
|
t.Errorf("Recovered notifications mismatch (-want +got):\n%s", diff)
|
|
}
|
|
if callErr != nil {
|
|
t.Errorf("CallTool failed unexpectedly: %v", err)
|
|
}
|
|
} else {
|
|
// Otherwise, the call should fail.
|
|
if callErr == nil {
|
|
t.Errorf("CallTool succeeded unexpectedly")
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestServerTransportCleanup(t *testing.T) {
|
|
server := NewServer(testImpl, &ServerOptions{KeepAlive: 10 * time.Millisecond})
|
|
|
|
nClient := 3
|
|
|
|
var mu sync.Mutex
|
|
var id int = -1 // session id starting from "0", "1", "2"...
|
|
chans := make(map[string]chan struct{}, nClient)
|
|
|
|
handler := NewStreamableHTTPHandler(func(*http.Request) *Server { return server }, &StreamableHTTPOptions{
|
|
GetSessionID: func() string {
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
id++
|
|
if id == nClient {
|
|
t.Errorf("creating more than %v session", nClient)
|
|
}
|
|
chans[fmt.Sprint(id)] = make(chan struct{}, 1)
|
|
return fmt.Sprint(id)
|
|
},
|
|
})
|
|
|
|
handler.onTransportDeletion = func(sessionID string) {
|
|
chans[sessionID] <- struct{}{}
|
|
}
|
|
|
|
httpServer := httptest.NewServer(handler)
|
|
defer httpServer.Close()
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
defer cancel()
|
|
|
|
// Spin up clients connect to the same server but refuse to ping request.
|
|
for range nClient {
|
|
client := NewClient(testImpl, nil)
|
|
pingMiddleware := func(next MethodHandler) MethodHandler {
|
|
return func(
|
|
ctx context.Context,
|
|
method string,
|
|
req Request,
|
|
) (Result, error) {
|
|
if method == "ping" {
|
|
return &emptyResult{}, errors.New("ping error")
|
|
}
|
|
return next(ctx, method, req)
|
|
}
|
|
}
|
|
client.AddReceivingMiddleware(pingMiddleware)
|
|
clientSession, err := client.Connect(ctx, &StreamableClientTransport{Endpoint: httpServer.URL}, nil)
|
|
if err != nil {
|
|
t.Fatalf("client.Connect() failed: %v", err)
|
|
}
|
|
defer clientSession.Close()
|
|
}
|
|
|
|
for _, ch := range chans {
|
|
select {
|
|
case <-ctx.Done():
|
|
t.Errorf("did not capture transport deletion event from all session in 10 seconds")
|
|
case <-ch: // Received transport deletion signal of this session
|
|
}
|
|
}
|
|
|
|
handler.mu.Lock()
|
|
if len(handler.transports) != 0 {
|
|
t.Errorf("want empty transports map, find %v entries from handler's transports map", len(handler.transports))
|
|
}
|
|
handler.mu.Unlock()
|
|
}
|
|
|
|
// TestServerInitiatedSSE verifies that the persistent SSE connection remains
|
|
// open and can receive server-initiated events.
|
|
func TestServerInitiatedSSE(t *testing.T) {
|
|
notifications := make(chan string)
|
|
server := NewServer(testImpl, nil)
|
|
|
|
httpServer := httptest.NewServer(NewStreamableHTTPHandler(func(*http.Request) *Server { return server }, nil))
|
|
defer httpServer.Close()
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
defer cancel()
|
|
client := NewClient(testImpl, &ClientOptions{
|
|
ToolListChangedHandler: func(context.Context, *ToolListChangedRequest) {
|
|
notifications <- "toolListChanged"
|
|
},
|
|
})
|
|
clientSession, err := client.Connect(ctx, &StreamableClientTransport{Endpoint: httpServer.URL}, nil)
|
|
if err != nil {
|
|
t.Fatalf("client.Connect() failed: %v", err)
|
|
}
|
|
defer clientSession.Close()
|
|
AddTool(server, &Tool{Name: "testTool", InputSchema: &jsonschema.Schema{Type: "object"}},
|
|
func(context.Context, *CallToolRequest, map[string]any) (*CallToolResult, any, error) {
|
|
return &CallToolResult{}, nil, nil
|
|
})
|
|
receivedNotifications := readNotifications(t, ctx, notifications, 1)
|
|
wantReceived := []string{"toolListChanged"}
|
|
if diff := cmp.Diff(wantReceived, receivedNotifications); diff != "" {
|
|
t.Errorf("Received notifications mismatch (-want +got):\n%s", diff)
|
|
}
|
|
}
|
|
|
|
// Helper to read a specific number of notifications.
|
|
func readNotifications(t *testing.T, ctx context.Context, notifications chan string, count int) []string {
|
|
t.Helper()
|
|
var collectedNotifications []string
|
|
for {
|
|
select {
|
|
case n := <-notifications:
|
|
collectedNotifications = append(collectedNotifications, n)
|
|
if len(collectedNotifications) == count {
|
|
return collectedNotifications
|
|
}
|
|
case <-ctx.Done():
|
|
if len(collectedNotifications) != count {
|
|
t.Fatalf("readProgressNotifications(): did not receive expected notifications, got %d, want %d", len(collectedNotifications), count)
|
|
}
|
|
return collectedNotifications
|
|
}
|
|
}
|
|
}
|
|
|
|
// JSON-RPC message constructors.
|
|
func req(id int64, method string, params any) *jsonrpc.Request {
|
|
r := &jsonrpc.Request{
|
|
Method: method,
|
|
Params: mustMarshal(params),
|
|
}
|
|
if id > 0 {
|
|
r.ID = jsonrpc2.Int64ID(id)
|
|
}
|
|
return r
|
|
}
|
|
|
|
func resp(id int64, result any, err error) *jsonrpc.Response {
|
|
return &jsonrpc.Response{
|
|
ID: jsonrpc2.Int64ID(id),
|
|
Result: mustMarshal(result),
|
|
Error: err,
|
|
}
|
|
}
|
|
|
|
func TestStreamableServerTransport(t *testing.T) {
|
|
// This test checks detailed behavior of the streamable server transport, by
|
|
// faking the behavior of a streamable client using a sequence of HTTP
|
|
// requests.
|
|
|
|
// Predefined steps, to avoid repetition below.
|
|
initReq := req(1, methodInitialize, &InitializeParams{})
|
|
initResp := resp(1, &InitializeResult{
|
|
Capabilities: &ServerCapabilities{
|
|
Logging: &LoggingCapabilities{},
|
|
Tools: &ToolCapabilities{ListChanged: true},
|
|
},
|
|
ProtocolVersion: latestProtocolVersion,
|
|
ServerInfo: &Implementation{Name: "testServer", Version: "v1.0.0"},
|
|
}, nil)
|
|
initializedMsg := req(0, notificationInitialized, &InitializedParams{})
|
|
initialize := streamableRequest{
|
|
method: "POST",
|
|
messages: []jsonrpc.Message{initReq},
|
|
wantStatusCode: http.StatusOK,
|
|
wantMessages: []jsonrpc.Message{initResp},
|
|
wantSessionID: true,
|
|
}
|
|
initialized := streamableRequest{
|
|
method: "POST",
|
|
messages: []jsonrpc.Message{initializedMsg},
|
|
wantStatusCode: http.StatusAccepted,
|
|
wantSessionID: false, // TODO: should this be true?
|
|
}
|
|
|
|
tests := []struct {
|
|
name string
|
|
tool func(*testing.T, context.Context, *ServerSession)
|
|
requests []streamableRequest // http requests
|
|
}{
|
|
{
|
|
name: "basic",
|
|
requests: []streamableRequest{
|
|
initialize,
|
|
initialized,
|
|
{
|
|
method: "POST",
|
|
messages: []jsonrpc.Message{req(2, "tools/call", &CallToolParams{Name: "tool"})},
|
|
wantStatusCode: http.StatusOK,
|
|
wantMessages: []jsonrpc.Message{resp(2, &CallToolResult{}, nil)},
|
|
wantSessionID: true,
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "accept headers",
|
|
requests: []streamableRequest{
|
|
initialize,
|
|
initialized,
|
|
// Test various accept headers.
|
|
{
|
|
method: "POST",
|
|
headers: http.Header{"Accept": {"text/plain", "application/*"}},
|
|
messages: []jsonrpc.Message{req(3, "tools/call", &CallToolParams{Name: "tool"})},
|
|
wantStatusCode: http.StatusBadRequest, // missing text/event-stream
|
|
wantSessionID: false,
|
|
},
|
|
{
|
|
method: "POST",
|
|
headers: http.Header{"Accept": {"text/event-stream"}},
|
|
messages: []jsonrpc.Message{req(3, "tools/call", &CallToolParams{Name: "tool"})},
|
|
wantStatusCode: http.StatusBadRequest, // missing application/json
|
|
wantSessionID: false,
|
|
},
|
|
{
|
|
method: "POST",
|
|
headers: http.Header{"Accept": {"text/plain", "*/*"}},
|
|
messages: []jsonrpc.Message{req(4, "tools/call", &CallToolParams{Name: "tool"})},
|
|
wantStatusCode: http.StatusOK,
|
|
wantMessages: []jsonrpc.Message{resp(4, &CallToolResult{}, nil)},
|
|
wantSessionID: true,
|
|
},
|
|
{
|
|
method: "POST",
|
|
headers: http.Header{"Accept": {"text/*, application/*"}},
|
|
messages: []jsonrpc.Message{req(4, "tools/call", &CallToolParams{Name: "tool"})},
|
|
wantStatusCode: http.StatusOK,
|
|
wantMessages: []jsonrpc.Message{resp(4, &CallToolResult{}, nil)},
|
|
wantSessionID: true,
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "protocol version headers",
|
|
requests: []streamableRequest{
|
|
initialize,
|
|
initialized,
|
|
{
|
|
method: "POST",
|
|
headers: http.Header{"mcp-protocol-version": {"2025-01-01"}}, // an invalid protocol version
|
|
messages: []jsonrpc.Message{req(2, "tools/call", &CallToolParams{Name: "tool"})},
|
|
wantStatusCode: http.StatusBadRequest,
|
|
wantBodyContaining: "2025-03-26", // a supported version
|
|
wantSessionID: false, // could be true, but shouldn't matter
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "tool notification",
|
|
tool: func(t *testing.T, ctx context.Context, ss *ServerSession) {
|
|
// Send an arbitrary notification.
|
|
if err := ss.NotifyProgress(ctx, &ProgressNotificationParams{}); err != nil {
|
|
t.Errorf("Notify failed: %v", err)
|
|
}
|
|
},
|
|
requests: []streamableRequest{
|
|
initialize,
|
|
initialized,
|
|
{
|
|
method: "POST",
|
|
messages: []jsonrpc.Message{
|
|
req(2, "tools/call", &CallToolParams{Name: "tool"}),
|
|
},
|
|
wantStatusCode: http.StatusOK,
|
|
wantMessages: []jsonrpc.Message{
|
|
req(0, "notifications/progress", &ProgressNotificationParams{}),
|
|
resp(2, &CallToolResult{}, nil),
|
|
},
|
|
wantSessionID: true,
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "tool upcall",
|
|
tool: func(t *testing.T, ctx context.Context, ss *ServerSession) {
|
|
// Make an arbitrary call.
|
|
if _, err := ss.ListRoots(ctx, &ListRootsParams{}); err != nil {
|
|
t.Errorf("Call failed: %v", err)
|
|
}
|
|
},
|
|
requests: []streamableRequest{
|
|
initialize,
|
|
initialized,
|
|
{
|
|
method: "POST",
|
|
onRequest: 1,
|
|
messages: []jsonrpc.Message{
|
|
resp(1, &ListRootsResult{}, nil),
|
|
},
|
|
wantStatusCode: http.StatusAccepted,
|
|
wantSessionID: false,
|
|
},
|
|
{
|
|
method: "POST",
|
|
messages: []jsonrpc.Message{
|
|
req(2, "tools/call", &CallToolParams{Name: "tool"}),
|
|
},
|
|
wantStatusCode: http.StatusOK,
|
|
wantMessages: []jsonrpc.Message{
|
|
req(1, "roots/list", &ListRootsParams{}),
|
|
resp(2, &CallToolResult{}, nil),
|
|
},
|
|
wantSessionID: true,
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "background",
|
|
tool: func(t *testing.T, _ context.Context, ss *ServerSession) {
|
|
// Perform operations on a background context, and ensure the client
|
|
// receives it.
|
|
ctx := context.Background()
|
|
if err := ss.NotifyProgress(ctx, &ProgressNotificationParams{}); err != nil {
|
|
t.Errorf("Notify failed: %v", err)
|
|
}
|
|
// TODO(rfindley): finish implementing logging.
|
|
// if err := ss.LoggingMessage(ctx, &LoggingMessageParams{}); err != nil {
|
|
// t.Errorf("Logging failed: %v", err)
|
|
// }
|
|
if _, err := ss.ListRoots(ctx, &ListRootsParams{}); err != nil {
|
|
t.Errorf("Notify failed: %v", err)
|
|
}
|
|
},
|
|
requests: []streamableRequest{
|
|
initialize,
|
|
initialized,
|
|
{
|
|
method: "POST",
|
|
onRequest: 1,
|
|
messages: []jsonrpc.Message{
|
|
resp(1, &ListRootsResult{}, nil),
|
|
},
|
|
wantStatusCode: http.StatusAccepted,
|
|
wantSessionID: false,
|
|
},
|
|
{
|
|
method: "GET",
|
|
async: true,
|
|
wantStatusCode: http.StatusOK,
|
|
closeAfter: 2,
|
|
wantMessages: []jsonrpc.Message{
|
|
req(0, "notifications/progress", &ProgressNotificationParams{}),
|
|
req(1, "roots/list", &ListRootsParams{}),
|
|
},
|
|
wantSessionID: true,
|
|
},
|
|
{
|
|
method: "POST",
|
|
messages: []jsonrpc.Message{
|
|
req(2, "tools/call", &CallToolParams{Name: "tool"}),
|
|
},
|
|
wantStatusCode: http.StatusOK,
|
|
wantMessages: []jsonrpc.Message{
|
|
resp(2, &CallToolResult{}, nil),
|
|
},
|
|
wantSessionID: true,
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "errors",
|
|
requests: []streamableRequest{
|
|
{
|
|
method: "PUT",
|
|
wantStatusCode: http.StatusMethodNotAllowed,
|
|
},
|
|
{
|
|
method: "DELETE",
|
|
wantStatusCode: http.StatusBadRequest,
|
|
},
|
|
{
|
|
method: "POST",
|
|
messages: []jsonrpc.Message{req(1, "notamethod", nil)},
|
|
wantStatusCode: http.StatusBadRequest, // notamethod is an invalid method
|
|
},
|
|
{
|
|
method: "POST",
|
|
messages: []jsonrpc.Message{req(0, "tools/call", &CallToolParams{Name: "tool"})},
|
|
wantStatusCode: http.StatusBadRequest, // tools/call must have an ID
|
|
},
|
|
{
|
|
method: "POST",
|
|
messages: []jsonrpc.Message{req(2, "tools/call", &CallToolParams{Name: "tool"})},
|
|
wantStatusCode: http.StatusOK,
|
|
wantMessages: []jsonrpc.Message{resp(2, nil, &jsonrpc2.WireError{
|
|
Message: `method "tools/call" is invalid during session initialization`,
|
|
})},
|
|
wantSessionID: true, // TODO: this is probably wrong; we don't have a valid session
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
// Create a server containing a single tool, which runs the test tool
|
|
// behavior, if any.
|
|
server := NewServer(&Implementation{Name: "testServer", Version: "v1.0.0"}, nil)
|
|
server.AddTool(
|
|
&Tool{Name: "tool", InputSchema: &jsonschema.Schema{Type: "object"}},
|
|
func(ctx context.Context, req *CallToolRequest) (*CallToolResult, error) {
|
|
if test.tool != nil {
|
|
test.tool(t, ctx, req.Session)
|
|
}
|
|
return &CallToolResult{}, nil
|
|
})
|
|
|
|
// Start the streamable handler.
|
|
handler := NewStreamableHTTPHandler(func(req *http.Request) *Server { return server }, nil)
|
|
defer handler.closeAll()
|
|
|
|
testStreamableHandler(t, handler, test.requests)
|
|
})
|
|
}
|
|
}
|
|
|
|
func testStreamableHandler(t *testing.T, handler http.Handler, requests []streamableRequest) {
|
|
httpServer := httptest.NewServer(handler)
|
|
defer httpServer.Close()
|
|
|
|
// blocks records request blocks by jsonrpc. ID.
|
|
//
|
|
// When an OnRequest step is encountered, it waits on the corresponding
|
|
// block. When a request with that ID is received, the block is closed.
|
|
var mu sync.Mutex
|
|
blocks := make(map[int64]chan struct{})
|
|
for _, req := range requests {
|
|
if req.onRequest > 0 {
|
|
blocks[req.onRequest] = make(chan struct{})
|
|
}
|
|
}
|
|
|
|
// signal when all synchronous requests have executed, so we can fail
|
|
// async requests that are blocked.
|
|
syncRequestsDone := make(chan struct{})
|
|
|
|
// To avoid complicated accounting for session ID, just set the first
|
|
// non-empty session ID from a response.
|
|
var sessionID atomic.Value
|
|
sessionID.Store("")
|
|
|
|
// doStep executes a single step.
|
|
doStep := func(t *testing.T, i int, request streamableRequest) {
|
|
if request.onRequest > 0 {
|
|
// Block the step until we've received the server->client request.
|
|
mu.Lock()
|
|
block := blocks[request.onRequest]
|
|
mu.Unlock()
|
|
select {
|
|
case <-block:
|
|
case <-syncRequestsDone:
|
|
t.Errorf("after all sync requests are complete, request still blocked on %d", request.onRequest)
|
|
return
|
|
}
|
|
}
|
|
|
|
// Collect messages received during this request, unblock other steps
|
|
// when requests are received.
|
|
var got []jsonrpc.Message
|
|
out := make(chan jsonrpc.Message)
|
|
// Cancel the step if we encounter a request that isn't going to be
|
|
// handled.
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
|
|
var wg sync.WaitGroup
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
|
|
for m := range out {
|
|
if req, ok := m.(*jsonrpc.Request); ok && req.IsCall() {
|
|
// Encountered a server->client request. We should have a
|
|
// response queued. Otherwise, we may deadlock.
|
|
mu.Lock()
|
|
if block, ok := blocks[req.ID.Raw().(int64)]; ok {
|
|
close(block)
|
|
} else {
|
|
t.Errorf("no queued response for %v", req.ID)
|
|
cancel()
|
|
}
|
|
mu.Unlock()
|
|
}
|
|
got = append(got, m)
|
|
if request.closeAfter > 0 && len(got) == request.closeAfter {
|
|
cancel()
|
|
}
|
|
}
|
|
}()
|
|
|
|
gotSessionID, gotStatusCode, gotBody, err := request.do(ctx, httpServer.URL, sessionID.Load().(string), out)
|
|
|
|
// Don't fail on cancelled requests: error (if any) is handled
|
|
// elsewhere.
|
|
if err != nil && ctx.Err() == nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
if gotStatusCode != request.wantStatusCode {
|
|
t.Errorf("request #%d: got status %d, want %d", i, gotStatusCode, request.wantStatusCode)
|
|
}
|
|
if got := gotSessionID != ""; got != request.wantSessionID {
|
|
t.Errorf("request #%d: got session id: %t, want %t", i, got, request.wantSessionID)
|
|
}
|
|
wg.Wait()
|
|
|
|
if request.wantBodyContaining != "" {
|
|
body := string(gotBody)
|
|
if !strings.Contains(body, request.wantBodyContaining) {
|
|
t.Errorf("body does not contain %q:\n%s", request.wantBodyContaining, body)
|
|
}
|
|
} else {
|
|
transform := cmpopts.AcyclicTransformer("jsonrpcid", func(id jsonrpc.ID) any { return id.Raw() })
|
|
if diff := cmp.Diff(request.wantMessages, got, transform); diff != "" {
|
|
t.Errorf("request #%d: received unexpected messages (-want +got):\n%s", i, diff)
|
|
}
|
|
}
|
|
sessionID.CompareAndSwap("", gotSessionID)
|
|
}
|
|
|
|
var wg sync.WaitGroup
|
|
for i, request := range requests {
|
|
if request.async || request.onRequest > 0 {
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
doStep(t, i, request)
|
|
}()
|
|
} else {
|
|
doStep(t, i, request)
|
|
}
|
|
}
|
|
|
|
// Fail any blocked responses if they weren't needed by a synchronous
|
|
// request.
|
|
close(syncRequestsDone)
|
|
|
|
wg.Wait()
|
|
}
|
|
|
|
// A streamableRequest describes a single streamable HTTP request, consisting
|
|
// of a request payload and expected response.
|
|
type streamableRequest struct {
|
|
// If onRequest is > 0, this step only executes after a request with the
|
|
// given ID is received.
|
|
//
|
|
// All onRequest steps must occur before the step that creates the request.
|
|
//
|
|
// To avoid tests hanging when there's a bug, it's expected that this
|
|
// request is received in the course of a *synchronous* request to the
|
|
// server (otherwise, we wouldn't be able to terminate the test without
|
|
// analyzing a dependency graph).
|
|
onRequest int64
|
|
// If set, async causes the step to run asynchronously to other steps.
|
|
// Redundant with OnRequest: all OnRequest steps are asynchronous.
|
|
async bool
|
|
|
|
// Request attributes
|
|
method string // HTTP request method (required)
|
|
headers http.Header // additional headers to set, overlaid on top of the default headers
|
|
messages []jsonrpc.Message // messages to send
|
|
|
|
closeAfter int // if nonzero, close after receiving this many messages
|
|
wantStatusCode int // expected status code
|
|
wantBodyContaining string // if set, expect the response body to contain this text; overrides wantMessages
|
|
wantMessages []jsonrpc.Message // expected messages to receive; ignored if wantBodyContaining is set
|
|
wantSessionID bool // whether or not a session ID is expected in the response
|
|
}
|
|
|
|
// streamingRequest makes a request to the given streamable server with the
|
|
// given url, sessionID, and method.
|
|
//
|
|
// If provided, the in messages are encoded in the request body. A single
|
|
// message is encoded as a JSON object. Multiple messages are batched as a JSON
|
|
// array.
|
|
//
|
|
// Any received messages are sent to the out channel, which is closed when the
|
|
// request completes.
|
|
//
|
|
// Returns the sessionID and http status code from the response. If an error is
|
|
// returned, sessionID and status code may still be set if the error occurs
|
|
// after the response headers have been received.
|
|
func (s streamableRequest) do(ctx context.Context, serverURL, sessionID string, out chan<- jsonrpc.Message) (string, int, []byte, error) {
|
|
defer close(out)
|
|
|
|
var body []byte
|
|
if len(s.messages) == 1 {
|
|
data, err := jsonrpc2.EncodeMessage(s.messages[0])
|
|
if err != nil {
|
|
return "", 0, nil, fmt.Errorf("encoding message: %w", err)
|
|
}
|
|
body = data
|
|
} else {
|
|
var rawMsgs []json.RawMessage
|
|
for _, msg := range s.messages {
|
|
data, err := jsonrpc2.EncodeMessage(msg)
|
|
if err != nil {
|
|
return "", 0, nil, fmt.Errorf("encoding message: %w", err)
|
|
}
|
|
rawMsgs = append(rawMsgs, data)
|
|
}
|
|
data, err := json.Marshal(rawMsgs)
|
|
if err != nil {
|
|
return "", 0, nil, fmt.Errorf("marshaling batch: %w", err)
|
|
}
|
|
body = data
|
|
}
|
|
|
|
req, err := http.NewRequestWithContext(ctx, s.method, serverURL, bytes.NewReader(body))
|
|
if err != nil {
|
|
return "", 0, nil, fmt.Errorf("creating request: %w", err)
|
|
}
|
|
if sessionID != "" {
|
|
req.Header.Set("Mcp-Session-Id", sessionID)
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set("Accept", "application/json, text/event-stream")
|
|
maps.Copy(req.Header, s.headers)
|
|
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
return "", 0, nil, fmt.Errorf("request failed: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
newSessionID := resp.Header.Get("Mcp-Session-Id")
|
|
|
|
contentType := resp.Header.Get("Content-Type")
|
|
var respBody []byte
|
|
if strings.HasPrefix(contentType, "text/event-stream") {
|
|
r := readerInto{resp.Body, new(bytes.Buffer)}
|
|
for evt, err := range scanEvents(r) {
|
|
if err != nil {
|
|
return newSessionID, resp.StatusCode, nil, fmt.Errorf("reading events: %v", err)
|
|
}
|
|
// TODO(rfindley): do we need to check evt.name?
|
|
// Does the MCP spec say anything about this?
|
|
msg, err := jsonrpc2.DecodeMessage(evt.Data)
|
|
if err != nil {
|
|
return newSessionID, resp.StatusCode, nil, fmt.Errorf("decoding message: %w", err)
|
|
}
|
|
out <- msg
|
|
}
|
|
respBody = r.w.Bytes()
|
|
} else if strings.HasPrefix(contentType, "application/json") {
|
|
data, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return newSessionID, resp.StatusCode, nil, fmt.Errorf("reading json body: %w", err)
|
|
}
|
|
respBody = data
|
|
msg, err := jsonrpc2.DecodeMessage(data)
|
|
if err != nil {
|
|
return newSessionID, resp.StatusCode, nil, fmt.Errorf("decoding message: %w", err)
|
|
}
|
|
out <- msg
|
|
} else {
|
|
respBody, err = io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return newSessionID, resp.StatusCode, nil, fmt.Errorf("reading response: %v", err)
|
|
}
|
|
}
|
|
|
|
return newSessionID, resp.StatusCode, respBody, nil
|
|
}
|
|
|
|
// readerInto is an io.Reader that writes any bytes read from r into w.
|
|
type readerInto struct {
|
|
r io.Reader
|
|
w *bytes.Buffer
|
|
}
|
|
|
|
// Read implements io.Reader.
|
|
func (r readerInto) Read(p []byte) (n int, err error) {
|
|
n, err = r.r.Read(p)
|
|
if err == nil || err == io.EOF {
|
|
n2, err2 := r.w.Write(p[:n])
|
|
if err2 != nil {
|
|
return n, fmt.Errorf("failed to write: %v", err)
|
|
}
|
|
if n2 != n {
|
|
return n, fmt.Errorf("short write: %d != %d", n2, n)
|
|
}
|
|
}
|
|
return n, err
|
|
}
|
|
|
|
func mustMarshal(v any) json.RawMessage {
|
|
if v == nil {
|
|
return nil
|
|
}
|
|
data, err := json.Marshal(v)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
return data
|
|
}
|
|
|
|
func TestStreamableClientTransport(t *testing.T) {
|
|
// This test verifies various behavior of the streamable client transport:
|
|
// - check that it can handle application/json responses
|
|
// - check that it sends the negotiated protocol version
|
|
//
|
|
// TODO(rfindley): make this test more comprehensive, similar to
|
|
// [TestStreamableServerTransport].
|
|
ctx := context.Background()
|
|
resp := func(id int64, result any, err error) *jsonrpc.Response {
|
|
return &jsonrpc.Response{
|
|
ID: jsonrpc2.Int64ID(id),
|
|
Result: mustMarshal(result),
|
|
Error: err,
|
|
}
|
|
}
|
|
initResult := &InitializeResult{
|
|
Capabilities: &ServerCapabilities{
|
|
Completions: &CompletionCapabilities{},
|
|
Logging: &LoggingCapabilities{},
|
|
Tools: &ToolCapabilities{ListChanged: true},
|
|
},
|
|
ProtocolVersion: latestProtocolVersion,
|
|
ServerInfo: &Implementation{Name: "testServer", Version: "v1.0.0"},
|
|
}
|
|
initResp := resp(1, initResult, nil)
|
|
|
|
var reqN atomic.Int32 // request count
|
|
serverHandler := func(w http.ResponseWriter, r *http.Request) {
|
|
rN := reqN.Add(1)
|
|
|
|
// TODO(rfindley): if the status code is NoContent or Accepted, we should
|
|
// probably be tolerant of when the content type is not application/json.
|
|
w.Header().Set("Content-Type", "application/json")
|
|
if rN == 1 {
|
|
data, err := jsonrpc2.EncodeMessage(initResp)
|
|
if err != nil {
|
|
t.Errorf("encoding failed: %v", err)
|
|
}
|
|
w.Header().Set("Mcp-Session-Id", "123")
|
|
w.Write(data)
|
|
} else {
|
|
if v := r.Header.Get(protocolVersionHeader); v != latestProtocolVersion {
|
|
t.Errorf("bad protocol version header: got %q, want %q", v, latestProtocolVersion)
|
|
}
|
|
}
|
|
}
|
|
|
|
httpServer := httptest.NewServer(http.HandlerFunc(serverHandler))
|
|
defer httpServer.Close()
|
|
|
|
transport := &StreamableClientTransport{Endpoint: httpServer.URL}
|
|
client := NewClient(testImpl, nil)
|
|
session, err := client.Connect(ctx, transport, nil)
|
|
if err != nil {
|
|
t.Fatalf("client.Connect() failed: %v", err)
|
|
}
|
|
if err := session.Close(); err != nil {
|
|
t.Errorf("closing session: %v", err)
|
|
}
|
|
|
|
if got, want := reqN.Load(), int32(3); got < want {
|
|
// Expect at least 3 requests: initialize, initialized, and DELETE.
|
|
// We may or may not observe the GET, depending on timing.
|
|
t.Errorf("unexpected number of requests: got %d, want at least %d", got, want)
|
|
}
|
|
|
|
if diff := cmp.Diff(initResult, session.state.InitializeResult); diff != "" {
|
|
t.Errorf("mismatch (-want, +got):\n%s", diff)
|
|
}
|
|
}
|
|
|
|
func TestEventID(t *testing.T) {
|
|
tests := []struct {
|
|
sid StreamID
|
|
idx int
|
|
}{
|
|
{"0", 0},
|
|
{"0", 1},
|
|
{"1", 0},
|
|
{"1", 1},
|
|
{"", 1},
|
|
{"1234", 5678},
|
|
}
|
|
|
|
for _, test := range tests {
|
|
t.Run(fmt.Sprintf("%s_%d", test.sid, test.idx), func(t *testing.T) {
|
|
eventID := formatEventID(test.sid, test.idx)
|
|
gotSID, gotIdx, ok := parseEventID(eventID)
|
|
if !ok {
|
|
t.Fatalf("parseEventID(%q) failed, want ok", eventID)
|
|
}
|
|
if gotSID != test.sid || gotIdx != test.idx {
|
|
t.Errorf("parseEventID(%q) = %s, %d, want %s, %d", eventID, gotSID, gotIdx, test.sid, test.idx)
|
|
}
|
|
})
|
|
}
|
|
|
|
invalid := []string{
|
|
"",
|
|
"_",
|
|
"1_",
|
|
"1_a",
|
|
"1_-1",
|
|
}
|
|
|
|
for _, eventID := range invalid {
|
|
t.Run(fmt.Sprintf("invalid_%q", eventID), func(t *testing.T) {
|
|
if _, _, ok := parseEventID(eventID); ok {
|
|
t.Errorf("parseEventID(%q) succeeded, want failure", eventID)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestStreamableStateless(t *testing.T) {
|
|
// This version of sayHi expects
|
|
// that request from our client).
|
|
sayHi := func(ctx context.Context, req *CallToolRequest, args hiParams) (*CallToolResult, any, error) {
|
|
if err := req.Session.Ping(ctx, nil); err == nil {
|
|
// ping should fail, but not break the connection
|
|
t.Errorf("ping succeeded unexpectedly")
|
|
}
|
|
return &CallToolResult{Content: []Content{&TextContent{Text: "hi " + args.Name}}}, nil, nil
|
|
}
|
|
server := NewServer(testImpl, nil)
|
|
AddTool(server, &Tool{Name: "greet", Description: "say hi"}, sayHi)
|
|
|
|
requests := []streamableRequest{
|
|
{
|
|
method: "POST",
|
|
wantStatusCode: http.StatusOK,
|
|
messages: []jsonrpc.Message{req(1, "tools/list", struct{}{})},
|
|
wantBodyContaining: "greet",
|
|
wantSessionID: false,
|
|
},
|
|
{
|
|
method: "POST",
|
|
wantStatusCode: http.StatusOK,
|
|
messages: []jsonrpc.Message{
|
|
req(2, "tools/call", &CallToolParams{Name: "greet", Arguments: hiParams{Name: "World"}}),
|
|
},
|
|
wantMessages: []jsonrpc.Message{
|
|
resp(2, &CallToolResult{Content: []Content{&TextContent{Text: "hi World"}}}, nil),
|
|
},
|
|
wantSessionID: false,
|
|
},
|
|
{
|
|
method: "POST",
|
|
wantStatusCode: http.StatusOK,
|
|
messages: []jsonrpc.Message{
|
|
req(2, "tools/call", &CallToolParams{Name: "greet", Arguments: hiParams{Name: "foo"}}),
|
|
},
|
|
wantMessages: []jsonrpc.Message{
|
|
resp(2, &CallToolResult{Content: []Content{&TextContent{Text: "hi foo"}}}, nil),
|
|
},
|
|
wantSessionID: false,
|
|
},
|
|
}
|
|
|
|
testClientCompatibility := func(t *testing.T, handler http.Handler) {
|
|
ctx := context.Background()
|
|
httpServer := httptest.NewServer(handler)
|
|
defer httpServer.Close()
|
|
cs, err := NewClient(testImpl, nil).Connect(ctx, &StreamableClientTransport{Endpoint: httpServer.URL}, nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
res, err := cs.CallTool(ctx, &CallToolParams{Name: "greet", Arguments: hiParams{Name: "bar"}})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got, want := textContent(t, res), "hi bar"; got != want {
|
|
t.Errorf("Result = %q, want %q", got, want)
|
|
}
|
|
}
|
|
|
|
handler := NewStreamableHTTPHandler(func(*http.Request) *Server { return server }, &StreamableHTTPOptions{
|
|
GetSessionID: func() string { return "" },
|
|
Stateless: true,
|
|
})
|
|
|
|
// Test the default stateless mode.
|
|
t.Run("stateless", func(t *testing.T) {
|
|
testStreamableHandler(t, handler, requests)
|
|
testClientCompatibility(t, handler)
|
|
})
|
|
|
|
// Test a "distributed" variant of stateless mode, where it has non-empty
|
|
// session IDs, but is otherwise stateless.
|
|
//
|
|
// This can be used by tools to look up application state preserved across
|
|
// subsequent requests.
|
|
for i, req := range requests {
|
|
// Now, we want a session for all requests.
|
|
req.wantSessionID = true
|
|
requests[i] = req
|
|
}
|
|
distributableHandler := NewStreamableHTTPHandler(func(*http.Request) *Server { return server }, &StreamableHTTPOptions{
|
|
Stateless: true,
|
|
})
|
|
t.Run("distributed", func(t *testing.T) {
|
|
testStreamableHandler(t, distributableHandler, requests)
|
|
testClientCompatibility(t, handler)
|
|
})
|
|
}
|
|
|
|
func textContent(t *testing.T, res *CallToolResult) string {
|
|
t.Helper()
|
|
if len(res.Content) != 1 {
|
|
t.Fatalf("len(Content) = %d, want 1", len(res.Content))
|
|
}
|
|
text, ok := res.Content[0].(*TextContent)
|
|
if !ok {
|
|
t.Fatalf("Content[0] is %T, want *TextContent", res.Content[0])
|
|
}
|
|
return text.Text
|
|
}
|
|
|
|
func TestTokenInfo(t *testing.T) {
|
|
defer func(b bool) { testAuth = b }(testAuth)
|
|
testAuth = true
|
|
ctx := context.Background()
|
|
|
|
// Create a server with a tool that returns TokenInfo.
|
|
tokenInfo := func(ctx context.Context, req *CallToolRequest, _ struct{}) (*CallToolResult, any, error) {
|
|
return &CallToolResult{Content: []Content{&TextContent{Text: fmt.Sprintf("%v", req.Extra.TokenInfo)}}}, nil, nil
|
|
}
|
|
server := NewServer(testImpl, nil)
|
|
AddTool(server, &Tool{Name: "tokenInfo", Description: "return token info"}, tokenInfo)
|
|
|
|
streamHandler := NewStreamableHTTPHandler(func(req *http.Request) *Server { return server }, nil)
|
|
verifier := func(context.Context, string) (*auth.TokenInfo, error) {
|
|
return &auth.TokenInfo{
|
|
Scopes: []string{"scope"},
|
|
// Expiration is far, far in the future.
|
|
Expiration: time.Date(5000, 1, 2, 3, 4, 5, 0, time.UTC),
|
|
}, nil
|
|
}
|
|
handler := auth.RequireBearerToken(verifier, nil)(streamHandler)
|
|
httpServer := httptest.NewServer(handler)
|
|
defer httpServer.Close()
|
|
|
|
transport := NewStreamableClientTransport(httpServer.URL, nil)
|
|
client := NewClient(testImpl, nil)
|
|
session, err := client.Connect(ctx, transport, nil)
|
|
if err != nil {
|
|
t.Fatalf("client.Connect() failed: %v", err)
|
|
}
|
|
defer session.Close()
|
|
|
|
res, err := session.CallTool(ctx, &CallToolParams{Name: "tokenInfo"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(res.Content) == 0 {
|
|
t.Fatal("missing content")
|
|
}
|
|
tc, ok := res.Content[0].(*TextContent)
|
|
if !ok {
|
|
t.Fatal("not TextContent")
|
|
}
|
|
if g, w := tc.Text, "&{[scope] 5000-01-02 03:04:05 +0000 UTC map[]}"; g != w {
|
|
t.Errorf("got %q, want %q", g, w)
|
|
}
|
|
}
|