mcp: factor out reusable functions from TestStreamableServerTransport
The next CL will test stateless and distributable server transport configurations, using the HTTP testing strategy of TestStreamableServerTransport.
This commit is contained in:
parent
732b97fdaf
commit
0eb03e0d76
2 changed files with 182 additions and 187 deletions
|
|
@ -355,6 +355,8 @@ func (s *Server) callTool(ctx context.Context, req *ServerRequest[*CallToolParam
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil, fmt.Errorf("%s: unknown tool %q", jsonrpc2.ErrInvalidParams, req.Params.Name)
|
return nil, fmt.Errorf("%s: unknown tool %q", jsonrpc2.ErrInvalidParams, req.Params.Name)
|
||||||
}
|
}
|
||||||
|
// TODO: if handler returns nil content, it will serialize as null.
|
||||||
|
// Add a test and fix.
|
||||||
return st.handler(ctx, req)
|
return st.handler(ctx, req)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -382,29 +382,30 @@ func readNotifications(t *testing.T, ctx context.Context, notifications chan str
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestStreamableServerTransport(t *testing.T) {
|
// JSON-RPC message constructors.
|
||||||
// This test checks detailed behavior of the streamable server transport, by
|
func req(id int64, method string, params any) *jsonrpc.Request {
|
||||||
// faking the behavior of a streamable client using a sequence of HTTP
|
|
||||||
// requests.
|
|
||||||
|
|
||||||
// JSON-RPC message constructors.
|
|
||||||
req := func(id int64, method string, params any) *jsonrpc.Request {
|
|
||||||
r := &jsonrpc.Request{
|
r := &jsonrpc.Request{
|
||||||
Method: method,
|
Method: method,
|
||||||
Params: mustMarshal(t, params),
|
Params: mustMarshal(params),
|
||||||
}
|
}
|
||||||
if id > 0 {
|
if id > 0 {
|
||||||
r.ID = jsonrpc2.Int64ID(id)
|
r.ID = jsonrpc2.Int64ID(id)
|
||||||
}
|
}
|
||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
resp := func(id int64, result any, err error) *jsonrpc.Response {
|
|
||||||
|
func resp(id int64, result any, err error) *jsonrpc.Response {
|
||||||
return &jsonrpc.Response{
|
return &jsonrpc.Response{
|
||||||
ID: jsonrpc2.Int64ID(id),
|
ID: jsonrpc2.Int64ID(id),
|
||||||
Result: mustMarshal(t, result),
|
Result: mustMarshal(result),
|
||||||
Error: err,
|
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.
|
// Predefined steps, to avoid repetition below.
|
||||||
initReq := req(1, methodInitialize, &InitializeParams{})
|
initReq := req(1, methodInitialize, &InitializeParams{})
|
||||||
|
|
@ -422,21 +423,23 @@ func TestStreamableServerTransport(t *testing.T) {
|
||||||
messages: []jsonrpc.Message{initReq},
|
messages: []jsonrpc.Message{initReq},
|
||||||
wantStatusCode: http.StatusOK,
|
wantStatusCode: http.StatusOK,
|
||||||
wantMessages: []jsonrpc.Message{initResp},
|
wantMessages: []jsonrpc.Message{initResp},
|
||||||
|
wantSessionID: true,
|
||||||
}
|
}
|
||||||
initialized := streamableRequest{
|
initialized := streamableRequest{
|
||||||
method: "POST",
|
method: "POST",
|
||||||
messages: []jsonrpc.Message{initializedMsg},
|
messages: []jsonrpc.Message{initializedMsg},
|
||||||
wantStatusCode: http.StatusAccepted,
|
wantStatusCode: http.StatusAccepted,
|
||||||
|
wantSessionID: false, // TODO: should this be true?
|
||||||
}
|
}
|
||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
tool func(*testing.T, context.Context, *ServerSession)
|
tool func(*testing.T, context.Context, *ServerSession)
|
||||||
steps []streamableRequest
|
requests []streamableRequest // http requests
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "basic",
|
name: "basic",
|
||||||
steps: []streamableRequest{
|
requests: []streamableRequest{
|
||||||
initialize,
|
initialize,
|
||||||
initialized,
|
initialized,
|
||||||
{
|
{
|
||||||
|
|
@ -444,12 +447,13 @@ func TestStreamableServerTransport(t *testing.T) {
|
||||||
messages: []jsonrpc.Message{req(2, "tools/call", &CallToolParams{Name: "tool"})},
|
messages: []jsonrpc.Message{req(2, "tools/call", &CallToolParams{Name: "tool"})},
|
||||||
wantStatusCode: http.StatusOK,
|
wantStatusCode: http.StatusOK,
|
||||||
wantMessages: []jsonrpc.Message{resp(2, &CallToolResult{}, nil)},
|
wantMessages: []jsonrpc.Message{resp(2, &CallToolResult{}, nil)},
|
||||||
|
wantSessionID: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "accept headers",
|
name: "accept headers",
|
||||||
steps: []streamableRequest{
|
requests: []streamableRequest{
|
||||||
initialize,
|
initialize,
|
||||||
initialized,
|
initialized,
|
||||||
// Test various accept headers.
|
// Test various accept headers.
|
||||||
|
|
@ -458,12 +462,14 @@ func TestStreamableServerTransport(t *testing.T) {
|
||||||
accept: []string{"text/plain", "application/*"},
|
accept: []string{"text/plain", "application/*"},
|
||||||
messages: []jsonrpc.Message{req(3, "tools/call", &CallToolParams{Name: "tool"})},
|
messages: []jsonrpc.Message{req(3, "tools/call", &CallToolParams{Name: "tool"})},
|
||||||
wantStatusCode: http.StatusBadRequest, // missing text/event-stream
|
wantStatusCode: http.StatusBadRequest, // missing text/event-stream
|
||||||
|
wantSessionID: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
method: "POST",
|
method: "POST",
|
||||||
accept: []string{"text/event-stream"},
|
accept: []string{"text/event-stream"},
|
||||||
messages: []jsonrpc.Message{req(3, "tools/call", &CallToolParams{Name: "tool"})},
|
messages: []jsonrpc.Message{req(3, "tools/call", &CallToolParams{Name: "tool"})},
|
||||||
wantStatusCode: http.StatusBadRequest, // missing application/json
|
wantStatusCode: http.StatusBadRequest, // missing application/json
|
||||||
|
wantSessionID: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
|
@ -471,6 +477,7 @@ func TestStreamableServerTransport(t *testing.T) {
|
||||||
messages: []jsonrpc.Message{req(4, "tools/call", &CallToolParams{Name: "tool"})},
|
messages: []jsonrpc.Message{req(4, "tools/call", &CallToolParams{Name: "tool"})},
|
||||||
wantStatusCode: http.StatusOK,
|
wantStatusCode: http.StatusOK,
|
||||||
wantMessages: []jsonrpc.Message{resp(4, &CallToolResult{}, nil)},
|
wantMessages: []jsonrpc.Message{resp(4, &CallToolResult{}, nil)},
|
||||||
|
wantSessionID: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
|
@ -478,6 +485,7 @@ func TestStreamableServerTransport(t *testing.T) {
|
||||||
messages: []jsonrpc.Message{req(4, "tools/call", &CallToolParams{Name: "tool"})},
|
messages: []jsonrpc.Message{req(4, "tools/call", &CallToolParams{Name: "tool"})},
|
||||||
wantStatusCode: http.StatusOK,
|
wantStatusCode: http.StatusOK,
|
||||||
wantMessages: []jsonrpc.Message{resp(4, &CallToolResult{}, nil)},
|
wantMessages: []jsonrpc.Message{resp(4, &CallToolResult{}, nil)},
|
||||||
|
wantSessionID: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
@ -489,7 +497,7 @@ func TestStreamableServerTransport(t *testing.T) {
|
||||||
t.Errorf("Notify failed: %v", err)
|
t.Errorf("Notify failed: %v", err)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
steps: []streamableRequest{
|
requests: []streamableRequest{
|
||||||
initialize,
|
initialize,
|
||||||
initialized,
|
initialized,
|
||||||
{
|
{
|
||||||
|
|
@ -502,6 +510,7 @@ func TestStreamableServerTransport(t *testing.T) {
|
||||||
req(0, "notifications/progress", &ProgressNotificationParams{}),
|
req(0, "notifications/progress", &ProgressNotificationParams{}),
|
||||||
resp(2, &CallToolResult{}, nil),
|
resp(2, &CallToolResult{}, nil),
|
||||||
},
|
},
|
||||||
|
wantSessionID: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
@ -513,7 +522,7 @@ func TestStreamableServerTransport(t *testing.T) {
|
||||||
t.Errorf("Call failed: %v", err)
|
t.Errorf("Call failed: %v", err)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
steps: []streamableRequest{
|
requests: []streamableRequest{
|
||||||
initialize,
|
initialize,
|
||||||
initialized,
|
initialized,
|
||||||
{
|
{
|
||||||
|
|
@ -523,6 +532,7 @@ func TestStreamableServerTransport(t *testing.T) {
|
||||||
resp(1, &ListRootsResult{}, nil),
|
resp(1, &ListRootsResult{}, nil),
|
||||||
},
|
},
|
||||||
wantStatusCode: http.StatusAccepted,
|
wantStatusCode: http.StatusAccepted,
|
||||||
|
wantSessionID: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
|
@ -534,6 +544,7 @@ func TestStreamableServerTransport(t *testing.T) {
|
||||||
req(1, "roots/list", &ListRootsParams{}),
|
req(1, "roots/list", &ListRootsParams{}),
|
||||||
resp(2, &CallToolResult{}, nil),
|
resp(2, &CallToolResult{}, nil),
|
||||||
},
|
},
|
||||||
|
wantSessionID: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
@ -554,7 +565,7 @@ func TestStreamableServerTransport(t *testing.T) {
|
||||||
t.Errorf("Notify failed: %v", err)
|
t.Errorf("Notify failed: %v", err)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
steps: []streamableRequest{
|
requests: []streamableRequest{
|
||||||
initialize,
|
initialize,
|
||||||
initialized,
|
initialized,
|
||||||
{
|
{
|
||||||
|
|
@ -564,6 +575,7 @@ func TestStreamableServerTransport(t *testing.T) {
|
||||||
resp(1, &ListRootsResult{}, nil),
|
resp(1, &ListRootsResult{}, nil),
|
||||||
},
|
},
|
||||||
wantStatusCode: http.StatusAccepted,
|
wantStatusCode: http.StatusAccepted,
|
||||||
|
wantSessionID: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
method: "GET",
|
method: "GET",
|
||||||
|
|
@ -574,6 +586,7 @@ func TestStreamableServerTransport(t *testing.T) {
|
||||||
req(0, "notifications/progress", &ProgressNotificationParams{}),
|
req(0, "notifications/progress", &ProgressNotificationParams{}),
|
||||||
req(1, "roots/list", &ListRootsParams{}),
|
req(1, "roots/list", &ListRootsParams{}),
|
||||||
},
|
},
|
||||||
|
wantSessionID: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
|
@ -584,12 +597,13 @@ func TestStreamableServerTransport(t *testing.T) {
|
||||||
wantMessages: []jsonrpc.Message{
|
wantMessages: []jsonrpc.Message{
|
||||||
resp(2, &CallToolResult{}, nil),
|
resp(2, &CallToolResult{}, nil),
|
||||||
},
|
},
|
||||||
|
wantSessionID: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "errors",
|
name: "errors",
|
||||||
steps: []streamableRequest{
|
requests: []streamableRequest{
|
||||||
{
|
{
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
wantStatusCode: http.StatusMethodNotAllowed,
|
wantStatusCode: http.StatusMethodNotAllowed,
|
||||||
|
|
@ -615,6 +629,7 @@ func TestStreamableServerTransport(t *testing.T) {
|
||||||
wantMessages: []jsonrpc.Message{resp(2, nil, &jsonrpc2.WireError{
|
wantMessages: []jsonrpc.Message{resp(2, nil, &jsonrpc2.WireError{
|
||||||
Message: `method "tools/call" is invalid during session initialization`,
|
Message: `method "tools/call" is invalid during session initialization`,
|
||||||
})},
|
})},
|
||||||
|
wantSessionID: true, // TODO: this is probably wrong; we don't have a valid session
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
@ -636,6 +651,12 @@ func TestStreamableServerTransport(t *testing.T) {
|
||||||
handler := NewStreamableHTTPHandler(func(req *http.Request) *Server { return server }, nil)
|
handler := NewStreamableHTTPHandler(func(req *http.Request) *Server { return server }, nil)
|
||||||
defer handler.closeAll()
|
defer handler.closeAll()
|
||||||
|
|
||||||
|
testStreamableHandler(t, handler, test.requests)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testStreamableHandler(t *testing.T, handler http.Handler, requests []streamableRequest) {
|
||||||
httpServer := httptest.NewServer(handler)
|
httpServer := httptest.NewServer(handler)
|
||||||
defer httpServer.Close()
|
defer httpServer.Close()
|
||||||
|
|
||||||
|
|
@ -645,9 +666,9 @@ func TestStreamableServerTransport(t *testing.T) {
|
||||||
// block. When a request with that ID is received, the block is closed.
|
// block. When a request with that ID is received, the block is closed.
|
||||||
var mu sync.Mutex
|
var mu sync.Mutex
|
||||||
blocks := make(map[int64]chan struct{})
|
blocks := make(map[int64]chan struct{})
|
||||||
for _, step := range test.steps {
|
for _, req := range requests {
|
||||||
if step.onRequest > 0 {
|
if req.onRequest > 0 {
|
||||||
blocks[step.onRequest] = make(chan struct{})
|
blocks[req.onRequest] = make(chan struct{})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -661,16 +682,16 @@ func TestStreamableServerTransport(t *testing.T) {
|
||||||
sessionID.Store("")
|
sessionID.Store("")
|
||||||
|
|
||||||
// doStep executes a single step.
|
// doStep executes a single step.
|
||||||
doStep := func(t *testing.T, step streamableRequest) {
|
doStep := func(t *testing.T, i int, request streamableRequest) {
|
||||||
if step.onRequest > 0 {
|
if request.onRequest > 0 {
|
||||||
// Block the step until we've received the server->client request.
|
// Block the step until we've received the server->client request.
|
||||||
mu.Lock()
|
mu.Lock()
|
||||||
block := blocks[step.onRequest]
|
block := blocks[request.onRequest]
|
||||||
mu.Unlock()
|
mu.Unlock()
|
||||||
select {
|
select {
|
||||||
case <-block:
|
case <-block:
|
||||||
case <-syncRequestsDone:
|
case <-syncRequestsDone:
|
||||||
t.Errorf("after all sync requests are complete, request still blocked on %d", step.onRequest)
|
t.Errorf("after all sync requests are complete, request still blocked on %d", request.onRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -702,13 +723,13 @@ func TestStreamableServerTransport(t *testing.T) {
|
||||||
mu.Unlock()
|
mu.Unlock()
|
||||||
}
|
}
|
||||||
got = append(got, m)
|
got = append(got, m)
|
||||||
if step.closeAfter > 0 && len(got) == step.closeAfter {
|
if request.closeAfter > 0 && len(got) == request.closeAfter {
|
||||||
cancel()
|
cancel()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
gotSessionID, gotStatusCode, err := step.do(ctx, httpServer.URL, sessionID.Load().(string), out)
|
gotSessionID, gotStatusCode, err := request.do(ctx, httpServer.URL, sessionID.Load().(string), out)
|
||||||
|
|
||||||
// Don't fail on cancelled requests: error (if any) is handled
|
// Don't fail on cancelled requests: error (if any) is handled
|
||||||
// elsewhere.
|
// elsewhere.
|
||||||
|
|
@ -716,28 +737,33 @@ func TestStreamableServerTransport(t *testing.T) {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if gotStatusCode != step.wantStatusCode {
|
if gotStatusCode != request.wantStatusCode {
|
||||||
t.Errorf("got status %d, want %d", gotStatusCode, step.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()
|
wg.Wait()
|
||||||
|
|
||||||
|
if !request.ignoreResponse {
|
||||||
transform := cmpopts.AcyclicTransformer("jsonrpcid", func(id jsonrpc.ID) any { return id.Raw() })
|
transform := cmpopts.AcyclicTransformer("jsonrpcid", func(id jsonrpc.ID) any { return id.Raw() })
|
||||||
if diff := cmp.Diff(step.wantMessages, got, transform); diff != "" {
|
if diff := cmp.Diff(request.wantMessages, got, transform); diff != "" {
|
||||||
t.Errorf("received unexpected messages (-want +got):\n%s", diff)
|
t.Errorf("received unexpected messages (-want +got):\n%s", diff)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
sessionID.CompareAndSwap("", gotSessionID)
|
sessionID.CompareAndSwap("", gotSessionID)
|
||||||
}
|
}
|
||||||
|
|
||||||
var wg sync.WaitGroup
|
var wg sync.WaitGroup
|
||||||
for _, step := range test.steps {
|
for i, request := range requests {
|
||||||
if step.async || step.onRequest > 0 {
|
if request.async || request.onRequest > 0 {
|
||||||
wg.Add(1)
|
wg.Add(1)
|
||||||
go func() {
|
go func() {
|
||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
doStep(t, step)
|
doStep(t, i, request)
|
||||||
}()
|
}()
|
||||||
} else {
|
} else {
|
||||||
doStep(t, step)
|
doStep(t, i, request)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -746,8 +772,6 @@ func TestStreamableServerTransport(t *testing.T) {
|
||||||
close(syncRequestsDone)
|
close(syncRequestsDone)
|
||||||
|
|
||||||
wg.Wait()
|
wg.Wait()
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// A streamableRequest describes a single streamable HTTP request, consisting
|
// A streamableRequest describes a single streamable HTTP request, consisting
|
||||||
|
|
@ -768,13 +792,15 @@ type streamableRequest struct {
|
||||||
async bool
|
async bool
|
||||||
|
|
||||||
// Request attributes
|
// Request attributes
|
||||||
method string // HTTP request method
|
method string // HTTP request method (required)
|
||||||
accept []string // if non-empty, the Accept header to use; otherwise the default header is used
|
accept []string // if non-empty, the Accept header to use; otherwise the default header is used
|
||||||
messages []jsonrpc.Message // messages to send
|
messages []jsonrpc.Message // messages to send
|
||||||
|
|
||||||
closeAfter int // if nonzero, close after receiving this many messages
|
closeAfter int // if nonzero, close after receiving this many messages
|
||||||
wantStatusCode int // expected status code
|
wantStatusCode int // expected status code
|
||||||
|
ignoreResponse bool // if set, don't check the response messages
|
||||||
wantMessages []jsonrpc.Message // expected messages to receive
|
wantMessages []jsonrpc.Message // expected messages to receive
|
||||||
|
wantSessionID bool // whether or not a session ID is expected in the response
|
||||||
}
|
}
|
||||||
|
|
||||||
// streamingRequest makes a request to the given streamable server with the
|
// streamingRequest makes a request to the given streamable server with the
|
||||||
|
|
@ -840,7 +866,8 @@ func (s streamableRequest) do(ctx context.Context, serverURL, sessionID string,
|
||||||
|
|
||||||
newSessionID := resp.Header.Get("Mcp-Session-Id")
|
newSessionID := resp.Header.Get("Mcp-Session-Id")
|
||||||
|
|
||||||
if strings.HasPrefix(resp.Header.Get("Content-Type"), "text/event-stream") {
|
contentType := resp.Header.Get("Content-Type")
|
||||||
|
if strings.HasPrefix(contentType, "text/event-stream") {
|
||||||
for evt, err := range scanEvents(resp.Body) {
|
for evt, err := range scanEvents(resp.Body) {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return newSessionID, resp.StatusCode, fmt.Errorf("reading events: %v", err)
|
return newSessionID, resp.StatusCode, fmt.Errorf("reading events: %v", err)
|
||||||
|
|
@ -853,7 +880,7 @@ func (s streamableRequest) do(ctx context.Context, serverURL, sessionID string,
|
||||||
}
|
}
|
||||||
out <- msg
|
out <- msg
|
||||||
}
|
}
|
||||||
} else if strings.HasPrefix(resp.Header.Get("Content-Type"), "application/json") {
|
} else if strings.HasPrefix(contentType, "application/json") {
|
||||||
data, err := io.ReadAll(resp.Body)
|
data, err := io.ReadAll(resp.Body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return newSessionID, resp.StatusCode, fmt.Errorf("reading json body: %w", err)
|
return newSessionID, resp.StatusCode, fmt.Errorf("reading json body: %w", err)
|
||||||
|
|
@ -868,14 +895,13 @@ func (s streamableRequest) do(ctx context.Context, serverURL, sessionID string,
|
||||||
return newSessionID, resp.StatusCode, nil
|
return newSessionID, resp.StatusCode, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func mustMarshal(t *testing.T, v any) json.RawMessage {
|
func mustMarshal(v any) json.RawMessage {
|
||||||
if v == nil {
|
if v == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
t.Helper()
|
|
||||||
data, err := json.Marshal(v)
|
data, err := json.Marshal(v)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
panic(err)
|
||||||
}
|
}
|
||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
|
|
@ -886,7 +912,7 @@ func TestStreamableClientTransportApplicationJSON(t *testing.T) {
|
||||||
resp := func(id int64, result any, err error) *jsonrpc.Response {
|
resp := func(id int64, result any, err error) *jsonrpc.Response {
|
||||||
return &jsonrpc.Response{
|
return &jsonrpc.Response{
|
||||||
ID: jsonrpc2.Int64ID(id),
|
ID: jsonrpc2.Int64ID(id),
|
||||||
Result: mustMarshal(t, result),
|
Result: mustMarshal(result),
|
||||||
Error: err,
|
Error: err,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -970,13 +996,10 @@ func TestEventID(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestStreamableStateless(t *testing.T) {
|
func TestStreamableStateless(t *testing.T) {
|
||||||
// Test stateless mode behavior
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
// This version of sayHi doesn't make a ping request (we can't respond to
|
// This version of sayHi doesn't make a ping request (we can't respond to
|
||||||
// that request from our client).
|
// that request from our client).
|
||||||
sayHi := func(ctx context.Context, req *ServerRequest[*CallToolParamsFor[hiParams]]) (*CallToolResultFor[any], error) {
|
sayHi := func(ctx context.Context, req *ServerRequest[*CallToolParamsFor[hiParams]]) (*CallToolResult, error) {
|
||||||
return &CallToolResultFor[any]{Content: []Content{&TextContent{Text: "hi " + req.Params.Arguments.Name}}}, nil
|
return &CallToolResult{Content: []Content{&TextContent{Text: "hi " + req.Params.Arguments.Name}}}, nil
|
||||||
}
|
}
|
||||||
server := NewServer(testImpl, nil)
|
server := NewServer(testImpl, nil)
|
||||||
AddTool(server, &Tool{Name: "greet", Description: "say hi"}, sayHi)
|
AddTool(server, &Tool{Name: "greet", Description: "say hi"}, sayHi)
|
||||||
|
|
@ -985,57 +1008,27 @@ func TestStreamableStateless(t *testing.T) {
|
||||||
handler := NewStreamableHTTPHandler(func(*http.Request) *Server { return server }, &StreamableHTTPOptions{
|
handler := NewStreamableHTTPHandler(func(*http.Request) *Server { return server }, &StreamableHTTPOptions{
|
||||||
GetSessionID: func() string { return "" },
|
GetSessionID: func() string { return "" },
|
||||||
})
|
})
|
||||||
httpServer := httptest.NewServer(handler)
|
|
||||||
defer httpServer.Close()
|
|
||||||
|
|
||||||
checkRequest := func(body string) {
|
requests := []streamableRequest{
|
||||||
// Verify we can call tools/list directly without initialization in stateless mode
|
{
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, httpServer.URL, strings.NewReader(body))
|
method: "POST",
|
||||||
if err != nil {
|
wantStatusCode: http.StatusOK,
|
||||||
t.Fatal(err)
|
messages: []jsonrpc.Message{req(1, "tools/list", struct{}{})},
|
||||||
}
|
ignoreResponse: true,
|
||||||
req.Header.Set("Content-Type", "application/json")
|
wantSessionID: false,
|
||||||
req.Header.Set("Accept", "application/json, text/event-stream")
|
},
|
||||||
|
{
|
||||||
resp, err := http.DefaultClient.Do(req)
|
method: "POST",
|
||||||
if err != nil {
|
wantStatusCode: http.StatusOK,
|
||||||
t.Fatal(err)
|
messages: []jsonrpc.Message{
|
||||||
}
|
req(2, "tools/call", &CallToolParams{Name: "greet", Arguments: hiParams{Name: "World"}}),
|
||||||
defer resp.Body.Close()
|
},
|
||||||
|
wantMessages: []jsonrpc.Message{
|
||||||
// Verify that no session ID header is returned in stateless mode
|
resp(2, &CallToolResult{Content: []Content{&TextContent{Text: "hi World"}}}, nil),
|
||||||
if sessionID := resp.Header.Get(sessionIDHeader); sessionID != "" {
|
},
|
||||||
t.Errorf("%s = %s, want no session ID header", sessionIDHeader, sessionID)
|
wantSessionID: false,
|
||||||
}
|
},
|
||||||
if resp.StatusCode != http.StatusOK {
|
|
||||||
t.Errorf("Status code = %d; want successful response", resp.StatusCode)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var events []Event
|
testStreamableHandler(t, handler, requests)
|
||||||
for event, err := range scanEvents(resp.Body) {
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
events = append(events, event)
|
|
||||||
}
|
|
||||||
if len(events) != 1 {
|
|
||||||
t.Fatalf("got %d SSE events, want 1; events: %v", len(events), events)
|
|
||||||
}
|
|
||||||
msg, err := jsonrpc.DecodeMessage(events[0].Data)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
jsonResp, ok := msg.(*jsonrpc.Response)
|
|
||||||
if !ok {
|
|
||||||
t.Errorf("event is %T, want response", jsonResp)
|
|
||||||
}
|
|
||||||
if jsonResp.Error != nil {
|
|
||||||
t.Errorf("request failed: %v", jsonResp.Error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
checkRequest(`{"jsonrpc":"2.0","method":"tools/list","id":1,"params":{}}`)
|
|
||||||
|
|
||||||
// Verify we can make another request without session ID
|
|
||||||
checkRequest(`{"jsonrpc":"2.0","method":"tools/call","id":2,"params":{"name":"greet","arguments":{"name":"World"}}}`)
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue