Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 38 additions & 8 deletions pkg/vmcp/client/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import (
"github.com/stacklok/toolhive/pkg/vmcp/conversion"
"github.com/stacklok/toolhive/pkg/vmcp/headerforward"
healthcontext "github.com/stacklok/toolhive/pkg/vmcp/health/context"
"github.com/stacklok/toolhive/pkg/vmcp/internal/pagination"
)

const (
Expand Down Expand Up @@ -697,39 +698,68 @@ func initializeClient(ctx context.Context, c *client.Client) (*mcp.ServerCapabil
}

// queryTools queries tools from a backend if the server advertises tool support.
// It follows MCP pagination cursors so backends that paginate (mcpcompat
// paginates at DefaultPageSize=1000) contribute their complete tool set rather
// than only the first page.
func queryTools(ctx context.Context, c *client.Client, supported bool, backendID string) (*mcp.ListToolsResult, error) {
if supported {
result, err := c.ListTools(ctx, mcp.ListToolsRequest{})
tools, err := pagination.ListAll(ctx, func(ctx context.Context, cursor mcp.Cursor) ([]mcp.Tool, mcp.Cursor, error) {
req := mcp.ListToolsRequest{}
req.Params.Cursor = cursor
result, err := c.ListTools(ctx, req)
if err != nil {
return nil, "", err
}
return result.Tools, result.NextCursor, nil
})
if err != nil {
return nil, fmt.Errorf("failed to list tools from backend %s: %w", backendID, err)
}
return result, nil
return &mcp.ListToolsResult{Tools: tools}, nil
}
slog.Debug("backend does not advertise tools capability, skipping tools query", "backend", backendID)
return &mcp.ListToolsResult{Tools: []mcp.Tool{}}, nil
}

// queryResources queries resources from a backend if the server advertises resource support.
// queryResources queries resources from a backend if the server advertises
// resource support. It follows MCP pagination cursors (see queryTools).
func queryResources(ctx context.Context, c *client.Client, supported bool, backendID string) (*mcp.ListResourcesResult, error) {
if supported {
result, err := c.ListResources(ctx, mcp.ListResourcesRequest{})
resources, err := pagination.ListAll(ctx, func(ctx context.Context, cursor mcp.Cursor) ([]mcp.Resource, mcp.Cursor, error) {
req := mcp.ListResourcesRequest{}
req.Params.Cursor = cursor
result, err := c.ListResources(ctx, req)
if err != nil {
return nil, "", err
}
return result.Resources, result.NextCursor, nil
})
if err != nil {
return nil, fmt.Errorf("failed to list resources from backend %s: %w", backendID, err)
}
return result, nil
return &mcp.ListResourcesResult{Resources: resources}, nil
}
slog.Debug("backend does not advertise resources capability, skipping resources query", "backend", backendID)
return &mcp.ListResourcesResult{Resources: []mcp.Resource{}}, nil
}

// queryPrompts queries prompts from a backend if the server advertises prompt support.
// queryPrompts queries prompts from a backend if the server advertises prompt
// support. It follows MCP pagination cursors (see queryTools).
func queryPrompts(ctx context.Context, c *client.Client, supported bool, backendID string) (*mcp.ListPromptsResult, error) {
if supported {
result, err := c.ListPrompts(ctx, mcp.ListPromptsRequest{})
prompts, err := pagination.ListAll(ctx, func(ctx context.Context, cursor mcp.Cursor) ([]mcp.Prompt, mcp.Cursor, error) {
req := mcp.ListPromptsRequest{}
req.Params.Cursor = cursor
result, err := c.ListPrompts(ctx, req)
if err != nil {
return nil, "", err
}
return result.Prompts, result.NextCursor, nil
})
if err != nil {
return nil, fmt.Errorf("failed to list prompts from backend %s: %w", backendID, err)
}
return result, nil
return &mcp.ListPromptsResult{Prompts: prompts}, nil
}
slog.Debug("backend does not advertise prompts capability, skipping prompts query", "backend", backendID)
return &mcp.ListPromptsResult{Prompts: []mcp.Prompt{}}, nil
Expand Down
75 changes: 75 additions & 0 deletions pkg/vmcp/internal/pagination/pagination.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc.
// SPDX-License-Identifier: Apache-2.0

// Package pagination provides a helper for following MCP list-pagination
// cursors when querying backends.
//
// MCP list operations (tools/list, resources/list, prompts/list) are
// paginated: a result may carry a NextCursor pointing at the next page, and a
// client that issues a single request without following the cursor silently
// drops every item beyond the first page. Backends served by the
// toolhive-core mcpcompat server paginate at DefaultPageSize=1000, so any
// backend advertising more than 1000 tools/resources/prompts would lose the
// tail without cursor following.
//
// This package lives under pkg/vmcp/internal so both pkg/vmcp/client and
// pkg/vmcp/session can depend on it without introducing an import cycle
// (pkg/vmcp/client already imports pkg/vmcp/session).
package pagination

import (
"context"
"fmt"

"github.com/stacklok/toolhive-core/mcpcompat/mcp"
)

// maxIterations caps the number of pages ListAll will fetch. It is a generous
// upper bound (well above the ~O(items/pageSize) pages any legitimate backend
// needs) that exists solely to prevent an infinite loop if a backend keeps
// returning a non-empty cursor forever.
const maxIterations = 10000

// ListAll repeatedly invokes fetch, following the MCP pagination cursor, until
// the backend returns an empty next cursor. It accumulates and returns every
// item across all pages.
//
// fetch is called with the cursor for the page to retrieve (empty on the first
// call) and must return that page's items, the cursor for the next page (empty
// when no more pages remain), and any error. A fetch error is returned to the
// caller immediately.
//
// ListAll guards against a misbehaving backend in two ways: it returns an error
// if the same non-empty cursor is seen twice (a cycle), and it caps the total
// number of pages at maxIterations.
func ListAll[T any](
ctx context.Context,
fetch func(ctx context.Context, cursor mcp.Cursor) (items []T, next mcp.Cursor, err error),
) ([]T, error) {
var all []T
var cursor mcp.Cursor
seen := make(map[mcp.Cursor]struct{})

for i := 0; i < maxIterations; i++ {
items, next, err := fetch(ctx, cursor)
if err != nil {
return nil, err
}
all = append(all, items...)

// An empty next cursor signals the last page.
if next == "" {
return all, nil
}

// A repeated cursor means the backend is not advancing; abort rather
// than loop forever.
if _, ok := seen[next]; ok {
return nil, fmt.Errorf("pagination cursor %q repeated; backend is not advancing pages", next)
}
seen[next] = struct{}{}
cursor = next
}

return nil, fmt.Errorf("pagination exceeded %d pages; aborting to avoid an unbounded loop", maxIterations)
}
96 changes: 96 additions & 0 deletions pkg/vmcp/internal/pagination/pagination_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc.
// SPDX-License-Identifier: Apache-2.0

package pagination

import (
"context"
"errors"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"github.com/stacklok/toolhive-core/mcpcompat/mcp"
)

func TestListAll(t *testing.T) {
t.Parallel()

errFetch := errors.New("boom")

tests := []struct {
name string
// pages maps the requested cursor to the page it returns.
pages map[mcp.Cursor]page
fetchErr error
wantItems []int
wantErr bool
}{
{
name: "single page with empty next cursor terminates after one call",
pages: map[mcp.Cursor]page{
"": {items: []int{1, 2, 3}, next: ""},
},
wantItems: []int{1, 2, 3},
},
{
name: "multiple pages accumulate in order",
pages: map[mcp.Cursor]page{
"": {items: []int{1, 2}, next: "p1"},
"p1": {items: []int{3, 4}, next: "p2"},
"p2": {items: []int{5}, next: ""},
},
wantItems: []int{1, 2, 3, 4, 5},
},
{
name: "empty result returns no items",
pages: map[mcp.Cursor]page{
"": {items: nil, next: ""},
},
wantItems: nil,
},
{
name: "repeated cursor aborts with error",
pages: map[mcp.Cursor]page{
"": {items: []int{1}, next: "loop"},
"loop": {items: []int{2}, next: "loop"},
},
wantErr: true,
},
{
name: "fetch error is propagated",
fetchErr: errFetch,
wantErr: true,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()

fetch := func(_ context.Context, cursor mcp.Cursor) ([]int, mcp.Cursor, error) {
if tt.fetchErr != nil {
return nil, "", tt.fetchErr
}
p, ok := tt.pages[cursor]
require.True(t, ok, "unexpected cursor requested: %q", cursor)
return p.items, p.next, nil
}

got, err := ListAll(t.Context(), fetch)
if tt.wantErr {
require.Error(t, err)
return
}
require.NoError(t, err)
assert.Equal(t, tt.wantItems, got)
})
}
}

// page is a canned pagination response used by the table test.
type page struct {
items []int
next mcp.Cursor
}
Loading
Loading