Skip to content

Commit 45ea1fa

Browse files
authored
fix(catalog): apply default orderBy for MCP server pagination (kubeflow#2592)
When filterQuery is provided without an explicit orderBy, the API layer set OrderBy/SortOrder to pointers to empty strings, bypassing the nil-based defaults ("id"/"ASC"). This caused non-deterministic results and empty subsequent pages with cursor-based pagination. Only set the pagination pointers when values are non-empty, so the defaults apply correctly. Assisted-by: Claude Code <noreply@anthropic.com> Signed-off-by: Alessio Pragliola <seth.pro@gmail.com>
1 parent 8c1aa86 commit 45ea1fa

4 files changed

Lines changed: 158 additions & 4 deletions

File tree

catalog/internal/catalog/mcpcatalog/db_mcp.go

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -107,8 +107,12 @@ func (d *dbMCPCatalogImpl) ListMCPServers(ctx context.Context, params ListMCPSer
107107
orderBy := strings.ToUpper(string(params.OrderBy))
108108
sortOrder := strings.ToUpper(string(params.SortOrder))
109109
listOptions.Pagination.PageSize = &params.PageSize
110-
listOptions.Pagination.OrderBy = &orderBy
111-
listOptions.Pagination.SortOrder = &sortOrder
110+
if orderBy != "" {
111+
listOptions.Pagination.OrderBy = &orderBy
112+
}
113+
if sortOrder != "" {
114+
listOptions.Pagination.SortOrder = &sortOrder
115+
}
112116
if params.NextPageToken != nil {
113117
listOptions.Pagination.NextPageToken = params.NextPageToken
114118
}

catalog/internal/catalog/mcpcatalog/db_mcp_test.go

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -586,6 +586,103 @@ func TestGetMCPServerTool_PassesToolNameFilter(t *testing.T) {
586586
assert.Equal(t, "list_problems", *toolRepo.capturedListOptions.ToolName)
587587
}
588588

589+
// --- Pagination default tests ---
590+
591+
func TestListMCPServers_EmptyOrderByUsesDefault(t *testing.T) {
592+
// When OrderBy and SortOrder are not specified (empty strings from the API layer),
593+
// the pagination must still apply default ordering ("id" / "ASC") so that
594+
// cursor-based pagination works correctly on subsequent pages.
595+
repo := &mockMCPServerRepo{listResult: emptyList()}
596+
cat := newTestCatalog(repo, nil)
597+
598+
_, err := cat.ListMCPServers(context.Background(), ListMCPServersParams{
599+
FilterQuery: "license='Apache 2.0'",
600+
PageSize: 10,
601+
// OrderBy and SortOrder are zero values (empty strings)
602+
})
603+
require.NoError(t, err)
604+
605+
pagination := repo.capturedOptions.Pagination
606+
// The pagination must resolve to the defaults ("id" / "ASC").
607+
// Either OrderBy is nil (so GetOrderBy falls through to DefaultOrderBy),
608+
// or it is set to the explicit default value — but it must NOT be a
609+
// pointer to an empty string, which would skip the ORDER BY clause.
610+
orderBy := pagination.GetOrderBy()
611+
sortOrder := pagination.GetSortOrder()
612+
assert.Equal(t, "id", orderBy, "expected default orderBy 'id' when param is empty")
613+
assert.Equal(t, "ASC", sortOrder, "expected default sortOrder 'ASC' when param is empty")
614+
}
615+
616+
func TestListMCPServers_EmptyOrderByPaginationNotNilEmptyString(t *testing.T) {
617+
// Regression: when OrderBy/SortOrder are empty, they must NOT be set as
618+
// pointers to empty strings. The downstream paginator skips ORDER BY when
619+
// both are empty, but still applies the cursor WHERE clause, causing
620+
// non-deterministic results and empty second pages.
621+
repo := &mockMCPServerRepo{listResult: emptyList()}
622+
cat := newTestCatalog(repo, nil)
623+
624+
_, err := cat.ListMCPServers(context.Background(), ListMCPServersParams{
625+
FilterQuery: "license='Apache 2.0'",
626+
PageSize: 1,
627+
// OrderBy and SortOrder deliberately left as zero values
628+
})
629+
require.NoError(t, err)
630+
631+
pagination := repo.capturedOptions.Pagination
632+
633+
// If OrderBy is non-nil, it must not point to an empty string.
634+
if pagination.OrderBy != nil {
635+
assert.NotEmpty(t, *pagination.OrderBy, "OrderBy must not be a pointer to an empty string")
636+
}
637+
// If SortOrder is non-nil, it must not point to an empty string.
638+
if pagination.SortOrder != nil {
639+
assert.NotEmpty(t, *pagination.SortOrder, "SortOrder must not be a pointer to an empty string")
640+
}
641+
}
642+
643+
func TestListMCPServers_ExplicitOrderByIsPreserved(t *testing.T) {
644+
// When OrderBy/SortOrder are explicitly provided, they should be passed through.
645+
repo := &mockMCPServerRepo{listResult: emptyList()}
646+
cat := newTestCatalog(repo, nil)
647+
648+
_, err := cat.ListMCPServers(context.Background(), ListMCPServersParams{
649+
FilterQuery: "license='Apache 2.0'",
650+
PageSize: 10,
651+
OrderBy: "CREATE_TIME",
652+
SortOrder: "DESC",
653+
})
654+
require.NoError(t, err)
655+
656+
pagination := repo.capturedOptions.Pagination
657+
assert.Equal(t, "CREATE_TIME", pagination.GetOrderBy())
658+
assert.Equal(t, "DESC", pagination.GetSortOrder())
659+
}
660+
661+
func TestListMCPServerTools_EmptyOrderByUsesDefault(t *testing.T) {
662+
// Same bug applies to ListMCPServerTools — empty OrderBy/SortOrder must
663+
// resolve to defaults for correct cursor-based pagination.
664+
serverEntity := &models.MCPServerImpl{
665+
ID: serverID(1),
666+
Attributes: &models.MCPServerAttributes{Name: serverName("test-server")},
667+
}
668+
repo := &mockMCPServerRepo{getResult: serverEntity}
669+
toolRepo := &mockMCPServerToolRepo{listResult: toolList()}
670+
cat := newTestCatalogWithToolRepo(repo, toolRepo, nil)
671+
672+
_, err := cat.ListMCPServerTools(context.Background(), "1", ListMCPServerToolsParams{
673+
PageSize: 10,
674+
// OrderBy and SortOrder are zero values (empty strings)
675+
})
676+
require.NoError(t, err)
677+
678+
require.NotNil(t, toolRepo.capturedListOptions)
679+
pagination := toolRepo.capturedListOptions.Pagination
680+
orderBy := pagination.GetOrderBy()
681+
sortOrder := pagination.GetSortOrder()
682+
assert.Equal(t, "id", orderBy, "expected default orderBy 'id' when param is empty")
683+
assert.Equal(t, "ASC", sortOrder, "expected default sortOrder 'ASC' when param is empty")
684+
}
685+
589686
func TestGetMCPServer_IncludeToolsUsesAccurateCount(t *testing.T) {
590687
serverEntity := &models.MCPServerImpl{
591688
ID: serverID(1),

internal/db/models/pagination.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,15 +29,15 @@ func (p *Pagination) GetNextPageToken() string {
2929
}
3030

3131
func (p *Pagination) GetOrderBy() string {
32-
if p.OrderBy == nil {
32+
if p.OrderBy == nil || *p.OrderBy == "" {
3333
return DefaultOrderBy
3434
}
3535

3636
return *p.OrderBy
3737
}
3838

3939
func (p *Pagination) GetSortOrder() string {
40-
if p.SortOrder == nil {
40+
if p.SortOrder == nil || *p.SortOrder == "" {
4141
return DefaultSortOrder
4242
}
4343

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
package models
2+
3+
import (
4+
"testing"
5+
6+
"github.com/stretchr/testify/assert"
7+
)
8+
9+
// TestGetOrderBy_NilReturnsDefault verifies that a nil OrderBy returns the default.
10+
func TestGetOrderBy_NilReturnsDefault(t *testing.T) {
11+
p := &Pagination{}
12+
assert.Equal(t, DefaultOrderBy, p.GetOrderBy(), "nil OrderBy should return default")
13+
}
14+
15+
// TestGetOrderBy_EmptyStringReturnsDefault verifies that a pointer to an empty
16+
// string still returns the default OrderBy. This is the root cause of
17+
// RHOAIENG-57798: the API layer sets OrderBy to a pointer to "" which bypasses
18+
// the nil check and results in no ORDER BY clause, breaking cursor-based pagination.
19+
func TestGetOrderBy_EmptyStringReturnsDefault(t *testing.T) {
20+
empty := ""
21+
p := &Pagination{OrderBy: &empty}
22+
assert.Equal(t, DefaultOrderBy, p.GetOrderBy(),
23+
"empty string OrderBy should return default 'id', not empty string")
24+
}
25+
26+
// TestGetOrderBy_ExplicitValuePreserved verifies that an explicit value is returned as-is.
27+
func TestGetOrderBy_ExplicitValuePreserved(t *testing.T) {
28+
val := "CREATE_TIME"
29+
p := &Pagination{OrderBy: &val}
30+
assert.Equal(t, "CREATE_TIME", p.GetOrderBy())
31+
}
32+
33+
// TestGetSortOrder_NilReturnsDefault verifies that a nil SortOrder returns the default.
34+
func TestGetSortOrder_NilReturnsDefault(t *testing.T) {
35+
p := &Pagination{}
36+
assert.Equal(t, DefaultSortOrder, p.GetSortOrder(), "nil SortOrder should return default")
37+
}
38+
39+
// TestGetSortOrder_EmptyStringReturnsDefault verifies that a pointer to an empty
40+
// string still returns the default SortOrder. Same root cause as RHOAIENG-57798.
41+
func TestGetSortOrder_EmptyStringReturnsDefault(t *testing.T) {
42+
empty := ""
43+
p := &Pagination{SortOrder: &empty}
44+
assert.Equal(t, DefaultSortOrder, p.GetSortOrder(),
45+
"empty string SortOrder should return default 'ASC', not empty string")
46+
}
47+
48+
// TestGetSortOrder_ExplicitValuePreserved verifies that an explicit value is returned as-is.
49+
func TestGetSortOrder_ExplicitValuePreserved(t *testing.T) {
50+
val := "DESC"
51+
p := &Pagination{SortOrder: &val}
52+
assert.Equal(t, "DESC", p.GetSortOrder())
53+
}

0 commit comments

Comments
 (0)