Skip to content

Commit b0f3648

Browse files
authored
feat: add pagination support for list_resources (#100)
- Add limit and continue parameters to list_resources MCP handler - Update k8s client ListClusteredResources and ListNamespacedResources to support pagination - Preserve continue token in response metadata for pagination flow - Add comprehensive tests for pagination functionality This addresses issue #89 where metadata output was getting truncated on larger clusters. Users can now: - Set a limit on the number of items returned - Use continue tokens to fetch subsequent pages - Combine both for controlled pagination through large result sets Signed-off-by: Juan Antonio Osorio <[email protected]>
1 parent 84776dd commit b0f3648

File tree

3 files changed

+159
-11
lines changed

3 files changed

+159
-11
lines changed

pkg/k8s/client.go

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -134,32 +134,58 @@ func (c *Client) ListAPIResources(_ context.Context) ([]*metav1.APIResourceList,
134134
}
135135

136136
// ListClusteredResources returns all clustered resources of the specified group/version/kind
137+
// with optional pagination support via limit and continueToken parameters
137138
func (c *Client) ListClusteredResources(
138139
ctx context.Context,
139140
gvr schema.GroupVersionResource,
140141
labelSelector string,
142+
limit int64,
143+
continueToken string,
141144
) (*unstructured.UnstructuredList, error) {
142145
c.mu.RLock()
143146
defer c.mu.RUnlock()
144147

145-
return c.dynamicClient.Resource(gvr).List(ctx, metav1.ListOptions{
148+
listOptions := metav1.ListOptions{
146149
LabelSelector: labelSelector,
147-
})
150+
}
151+
152+
// Add pagination options if provided
153+
if limit > 0 {
154+
listOptions.Limit = limit
155+
}
156+
if continueToken != "" {
157+
listOptions.Continue = continueToken
158+
}
159+
160+
return c.dynamicClient.Resource(gvr).List(ctx, listOptions)
148161
}
149162

150163
// ListNamespacedResources returns all namespaced resources of the specified group/version/kind in the given namespace
164+
// with optional pagination support via limit and continueToken parameters
151165
func (c *Client) ListNamespacedResources(
152166
ctx context.Context,
153167
gvr schema.GroupVersionResource,
154168
namespace string,
155169
labelSelector string,
170+
limit int64,
171+
continueToken string,
156172
) (*unstructured.UnstructuredList, error) {
157173
c.mu.RLock()
158174
defer c.mu.RUnlock()
159175

160-
return c.dynamicClient.Resource(gvr).Namespace(namespace).List(ctx, metav1.ListOptions{
176+
listOptions := metav1.ListOptions{
161177
LabelSelector: labelSelector,
162-
})
178+
}
179+
180+
// Add pagination options if provided
181+
if limit > 0 {
182+
listOptions.Limit = limit
183+
}
184+
if continueToken != "" {
185+
listOptions.Continue = continueToken
186+
}
187+
188+
return c.dynamicClient.Resource(gvr).Namespace(namespace).List(ctx, listOptions)
163189
}
164190

165191
// ApplyClusteredResource creates or updates a clustered resource

pkg/k8s/client_test.go

Lines changed: 121 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,7 @@ func TestListClusteredResources(t *testing.T) {
8585

8686
// Test ListClusteredResources
8787
ctx := context.Background()
88-
list, err := testClient.ListClusteredResources(ctx, gvr, "")
88+
list, err := testClient.ListClusteredResources(ctx, gvr, "", 0, "")
8989

9090
// Verify there was no error
9191
assert.NoError(t, err, "ListClusteredResources should not return an error")
@@ -95,7 +95,7 @@ func TestListClusteredResources(t *testing.T) {
9595
assert.Equal(t, "test-cluster-role", list.Items[0].GetName(), "Expected name 'test-cluster-role'")
9696

9797
// Test ListClusteredResources with label selector
98-
list, err = testClient.ListClusteredResources(ctx, gvr, "app=test-app")
98+
list, err = testClient.ListClusteredResources(ctx, gvr, "app=test-app", 0, "")
9999

100100
// Verify there was no error
101101
assert.NoError(t, err, "ListClusteredResources should not return an error")
@@ -178,7 +178,7 @@ func TestListNamespacedResources(t *testing.T) {
178178

179179
// Test ListNamespacedResources
180180
ctx := context.Background()
181-
list, err := testClient.ListNamespacedResources(ctx, gvr, "default", "")
181+
list, err := testClient.ListNamespacedResources(ctx, gvr, "default", "", 0, "")
182182

183183
// Verify there was no error
184184
assert.NoError(t, err, "ListNamespacedResources should not return an error")
@@ -188,7 +188,7 @@ func TestListNamespacedResources(t *testing.T) {
188188
assert.Equal(t, "test-service", list.Items[0].GetName(), "Expected name 'test-service'")
189189

190190
// Test ListNamespacedResources with label selector
191-
list, err = testClient.ListNamespacedResources(ctx, gvr, "default", "app=test-app")
191+
list, err = testClient.ListNamespacedResources(ctx, gvr, "default", "app=test-app", 0, "")
192192

193193
// Verify there was no error
194194
assert.NoError(t, err, "ListNamespacedResources should not return an error")
@@ -198,6 +198,123 @@ func TestListNamespacedResources(t *testing.T) {
198198
assert.Equal(t, "test-service-2", list.Items[0].GetName(), "Expected name 'test-service-2'")
199199
}
200200

201+
// TestListClusteredResourcesWithPagination tests that pagination parameters are correctly handled
202+
func TestListClusteredResourcesWithPagination(t *testing.T) {
203+
// Since the fake client doesn't properly pass through ListOptions in the reactor,
204+
// we'll test that our functions correctly build the ListOptions and that the
205+
// response's continue token is properly preserved
206+
207+
scheme := runtime.NewScheme()
208+
listKinds := map[schema.GroupVersionResource]string{
209+
{Group: "rbac.authorization.k8s.io", Version: "v1", Resource: "clusterroles"}: "ClusterRoleList",
210+
}
211+
212+
client := dynamicfake.NewSimpleDynamicClientWithCustomListKinds(scheme, listKinds)
213+
testClient := &Client{
214+
dynamicClient: client,
215+
}
216+
217+
gvr := schema.GroupVersionResource{
218+
Group: "rbac.authorization.k8s.io",
219+
Version: "v1",
220+
Resource: "clusterroles",
221+
}
222+
223+
// Add a reactor that returns a list with a continue token
224+
client.PrependReactor("list", "clusterroles", func(_ ktesting.Action) (handled bool, ret runtime.Object, err error) {
225+
list := &unstructured.UnstructuredList{
226+
Object: map[string]interface{}{
227+
"metadata": map[string]interface{}{
228+
"continue": "test-continue-token",
229+
},
230+
},
231+
Items: []unstructured.Unstructured{
232+
{
233+
Object: map[string]interface{}{
234+
"apiVersion": "rbac.authorization.k8s.io/v1",
235+
"kind": "ClusterRole",
236+
"metadata": map[string]interface{}{
237+
"name": "test-role",
238+
},
239+
},
240+
},
241+
},
242+
}
243+
return true, list, nil
244+
})
245+
246+
// Test that the function accepts pagination parameters and returns results
247+
ctx := context.Background()
248+
249+
// Test with limit
250+
list, err := testClient.ListClusteredResources(ctx, gvr, "", 10, "")
251+
assert.NoError(t, err, "ListClusteredResources with limit should not return an error")
252+
assert.NotNil(t, list, "List should not be nil")
253+
assert.Equal(t, "test-continue-token", list.GetContinue(), "Continue token should be preserved in response")
254+
255+
// Test with continue token
256+
list, err = testClient.ListClusteredResources(ctx, gvr, "", 0, "my-continue-token")
257+
assert.NoError(t, err, "ListClusteredResources with continue token should not return an error")
258+
assert.NotNil(t, list, "List should not be nil")
259+
}
260+
261+
// TestListNamespacedResourcesWithPagination tests that pagination parameters are correctly handled
262+
func TestListNamespacedResourcesWithPagination(t *testing.T) {
263+
scheme := runtime.NewScheme()
264+
listKinds := map[schema.GroupVersionResource]string{
265+
{Group: "", Version: "v1", Resource: "pods"}: "PodList",
266+
}
267+
268+
client := dynamicfake.NewSimpleDynamicClientWithCustomListKinds(scheme, listKinds)
269+
testClient := &Client{
270+
dynamicClient: client,
271+
}
272+
273+
gvr := schema.GroupVersionResource{
274+
Group: "",
275+
Version: "v1",
276+
Resource: "pods",
277+
}
278+
279+
// Add a reactor that returns a list with a continue token
280+
client.PrependReactor("list", "pods", func(_ ktesting.Action) (handled bool, ret runtime.Object, err error) {
281+
list := &unstructured.UnstructuredList{
282+
Object: map[string]interface{}{
283+
"metadata": map[string]interface{}{
284+
"continue": "pod-continue-token",
285+
},
286+
},
287+
Items: []unstructured.Unstructured{
288+
{
289+
Object: map[string]interface{}{
290+
"apiVersion": "v1",
291+
"kind": "Pod",
292+
"metadata": map[string]interface{}{
293+
"name": "test-pod",
294+
"namespace": "default",
295+
},
296+
},
297+
},
298+
},
299+
}
300+
return true, list, nil
301+
})
302+
303+
// Test that the function accepts pagination parameters and returns results
304+
ctx := context.Background()
305+
306+
// Test with limit
307+
list, err := testClient.ListNamespacedResources(ctx, gvr, "default", "", 5, "")
308+
assert.NoError(t, err, "ListNamespacedResources with limit should not return an error")
309+
assert.NotNil(t, list, "List should not be nil")
310+
assert.Equal(t, "pod-continue-token", list.GetContinue(), "Continue token should be preserved in response")
311+
312+
// Test with continue token
313+
list, err = testClient.ListNamespacedResources(ctx, gvr, "default", "", 0, "pod-token")
314+
assert.NoError(t, err, "ListNamespacedResources with continue token should not return an error")
315+
assert.NotNil(t, list, "List should not be nil")
316+
}
317+
201318
func TestApplyClusteredResource(t *testing.T) {
202319
// Create a fake dynamic client
203320
scheme := runtime.NewScheme()

pkg/mcp/list_resources.go

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,10 @@ func (m *Implementation) HandleListResources(ctx context.Context, request mcp.Ca
3939
"exclude_annotation_keys", []string{"kubectl.kubernetes.io/last-applied-configuration"})
4040
includeAnnotationKeys := request.GetStringSlice("include_annotation_keys", []string{})
4141

42+
// Parse pagination parameters
43+
limit := request.GetInt("limit", 0) // 0 means no limit
44+
continueToken := request.GetString("continue", "")
45+
4246
// Validate parameters
4347
if resourceType == "" {
4448
return mcp.NewToolResultError("resource_type is required"), nil
@@ -66,14 +70,14 @@ func (m *Implementation) HandleListResources(ctx context.Context, request mcp.Ca
6670
Resource: resource,
6771
}
6872

69-
// List resources
73+
// List resources with pagination support
7074
var list *unstructured.UnstructuredList
7175
var err error
7276
switch resourceType {
7377
case types.ResourceTypeClustered:
74-
list, err = m.k8sClient.ListClusteredResources(ctx, gvr, labelSelector)
78+
list, err = m.k8sClient.ListClusteredResources(ctx, gvr, labelSelector, int64(limit), continueToken)
7579
case types.ResourceTypeNamespaced:
76-
list, err = m.k8sClient.ListNamespacedResources(ctx, gvr, namespace, labelSelector)
80+
list, err = m.k8sClient.ListNamespacedResources(ctx, gvr, namespace, labelSelector, int64(limit), continueToken)
7781
default:
7882
return mcp.NewToolResultError(fmt.Sprintf("Invalid resource_type: %s", resourceType)), nil
7983
}
@@ -90,6 +94,7 @@ func (m *Implementation) HandleListResources(ctx context.Context, request mcp.Ca
9094
},
9195
ListMeta: metav1.ListMeta{
9296
ResourceVersion: list.GetResourceVersion(),
97+
Continue: list.GetContinue(),
9398
},
9499
Items: make([]metav1.PartialObjectMetadata, 0, len(list.Items)),
95100
}

0 commit comments

Comments
 (0)