Skip to content

Commit d19198b

Browse files
Copilotlpcox
andauthored
fix pagination cursor cycle handling and add sdk canary coverage
Agent-Logs-Url: https://github.com/github/gh-aw-mcpg/sessions/4d4e8792-d37a-4028-8eaa-15752f44b0d7 Co-authored-by: lpcox <15877973+lpcox@users.noreply.github.com>
1 parent b557015 commit d19198b

4 files changed

Lines changed: 67 additions & 3 deletions

File tree

internal/mcp/connection.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -610,10 +610,15 @@ func paginateAll[T any](
610610
logConn.Printf("list%s: received page of %d %s from serverID=%s", itemKind, len(first.Items), itemKind, serverID)
611611

612612
cursor := first.NextCursor
613+
seenCursors := make(map[string]struct{})
613614
for pageCount := 1; cursor != ""; pageCount++ {
614615
if pageCount >= paginateAllMaxPages {
615616
return nil, fmt.Errorf("list%s: backend serverID=%s returned more than %d pages; aborting to prevent unbounded memory growth", itemKind, serverID, paginateAllMaxPages)
616617
}
618+
if _, seen := seenCursors[cursor]; seen {
619+
return nil, fmt.Errorf("list%s: backend serverID=%s returned cyclical cursor %q", itemKind, serverID, cursor)
620+
}
621+
seenCursors[cursor] = struct{}{}
617622
page, err := fetch(cursor)
618623
if err != nil {
619624
return nil, err

internal/mcp/connection_test.go

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -978,16 +978,43 @@ func TestPaginateAll(t *testing.T) {
978978
})
979979

980980
t.Run("exceeding max pages returns error", func(t *testing.T) {
981-
// Each call returns a cursor so the loop never ends naturally.
981+
// Each call returns a unique cursor so the loop never ends naturally.
982982
callCount := 0
983983
_, err := paginateAll("server1", "tools", func(cursor string) (paginatedPage[string], error) {
984984
callCount++
985-
return paginatedPage[string]{Items: []string{"x"}, NextCursor: "next"}, nil
985+
nextCursor := cursor + "next"
986+
if nextCursor == "" {
987+
nextCursor = "next"
988+
}
989+
return paginatedPage[string]{Items: []string{"x"}, NextCursor: nextCursor}, nil
986990
})
987991
require.Error(t, err)
988992
assert.Contains(t, err.Error(), "more than")
989993
assert.Contains(t, err.Error(), "pages")
990994
// Must stop at the page limit, not run forever.
991995
assert.Equal(t, paginateAllMaxPages, callCount)
992996
})
997+
998+
t.Run("cyclical cursor returns error", func(t *testing.T) {
999+
callCount := 0
1000+
_, err := paginateAll("server1", "tools", func(cursor string) (paginatedPage[string], error) {
1001+
callCount++
1002+
switch cursor {
1003+
case "":
1004+
return paginatedPage[string]{Items: []string{"a"}, NextCursor: "page2"}, nil
1005+
case "page2":
1006+
return paginatedPage[string]{Items: []string{"b"}, NextCursor: "page3"}, nil
1007+
case "page3":
1008+
return paginatedPage[string]{Items: []string{"c"}, NextCursor: "page2"}, nil
1009+
default:
1010+
return paginatedPage[string]{Items: nil, NextCursor: ""}, nil
1011+
}
1012+
})
1013+
1014+
require.Error(t, err)
1015+
assert.Contains(t, err.Error(), "cyclical cursor")
1016+
assert.Contains(t, err.Error(), "page2")
1017+
// Initial page + 2 unique cursor fetches, then cycle detected before another fetch.
1018+
assert.Equal(t, 3, callCount)
1019+
})
9931020
}

internal/mcp/http_transport.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,9 @@ const (
3333
HTTPTransportPlainJSON HTTPTransportType = "plain-json"
3434
)
3535

36-
// MCPProtocolVersion is the MCP protocol version used in initialization requests.
36+
// MCPProtocolVersion is the MCP protocol version used only by the plain JSON-RPC
37+
// fallback path in this package. Streamable and SSE transports are SDK-managed
38+
// and negotiate protocol versions internally.
3739
const MCPProtocolVersion = "2025-11-25"
3840

3941
// requestIDCounter is used to generate unique request IDs for HTTP requests
@@ -78,6 +80,8 @@ func isSessionNotFoundError(err error) bool {
7880
if errors.Is(err, sdk.ErrSessionMissing) {
7981
return true
8082
}
83+
// Plain JSON-RPC fallback requests bypass SDK session types, so they cannot
84+
// return sdk.ErrSessionMissing and are matched by backend error text instead.
8185
return strings.Contains(strings.ToLower(err.Error()), "session not found")
8286
}
8387

internal/server/routed_test.go

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -703,6 +703,24 @@ func TestRegisterToolWithoutValidation(t *testing.T) {
703703
},
704704
}, handler)
705705

706+
// This canary verifies the key behavior relied on by registerToolWithoutValidation:
707+
// tool calls are not rejected by SDK argument-value validation.
708+
var strictHandlerCalled bool
709+
registerToolWithoutValidation(server, &sdk.Tool{
710+
Name: "strict_tool",
711+
Description: "A strict-schema tool",
712+
InputSchema: map[string]interface{}{
713+
"type": "object",
714+
"properties": map[string]interface{}{
715+
"count": map[string]interface{}{"type": "integer"},
716+
},
717+
"required": []interface{}{"count"},
718+
},
719+
}, func(ctx context.Context, req *sdk.CallToolRequest, state interface{}) (*sdk.CallToolResult, interface{}, error) {
720+
strictHandlerCalled = true
721+
return &sdk.CallToolResult{IsError: false}, nil, nil
722+
})
723+
706724
// Use in-memory transports to connect a client to the server and invoke the tool
707725
serverTransport, clientTransport := sdk.NewInMemoryTransports()
708726
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
@@ -721,6 +739,16 @@ func TestRegisterToolWithoutValidation(t *testing.T) {
721739
require.NoError(err)
722740
assert.False(result.IsError)
723741
assert.True(handlerCalled, "Handler should have been called")
742+
743+
// Provide an intentionally invalid value for the strict schema ("count" must be integer).
744+
// If SDK starts validating argument values on this registration path, this call will fail.
745+
strictResult, err := clientSession.CallTool(ctx, &sdk.CallToolParams{
746+
Name: "strict_tool",
747+
Arguments: map[string]interface{}{"count": "not-an-integer"},
748+
})
749+
require.NoError(err)
750+
assert.False(strictResult.IsError)
751+
assert.True(strictHandlerCalled, "Strict handler should be called even with schema-invalid arguments")
724752
}
725753

726754
// TestCreateHTTPServerForRoutedMode_OAuth tests OAuth discovery endpoint in routed mode

0 commit comments

Comments
 (0)