From 864eb8949e27f153a21e0a461a58f003407a38c0 Mon Sep 17 00:00:00 2001 From: Amolith Date: Wed, 10 Jun 2026 18:30:22 -0600 Subject: [PATCH] recipes: mention next page for recipe reads --- internal/mcp/server.go | 61 ++++++++++++++++++--- internal/mcp/server_pagination_test.go | 75 ++++++++++++++++++++++++++ internal/mcp/server_test.go | 14 +++-- 3 files changed, 141 insertions(+), 9 deletions(-) create mode 100644 internal/mcp/server_pagination_test.go diff --git a/internal/mcp/server.go b/internal/mcp/server.go index 2187dbd74a307d56de27ebea6f8c15501387066b..039db2cb60c2448795eea8bd6440848c50676722 100644 --- a/internal/mcp/server.go +++ b/internal/mcp/server.go @@ -163,30 +163,52 @@ func (s *Server) callRecipesReadTool( arguments ReadArguments, ) (*sdk.CallToolResult, ReadOutput, error) { page, limit := normalizeRecipePage(arguments.Page, arguments.Limit) - recipes, err := s.readRecipeCards(ctx, strings.TrimSpace(arguments.Query), page, limit) + query := strings.TrimSpace(arguments.Query) + recipes, paginationHint, err := s.readRecipeCards(ctx, query, page, limit) if err != nil { return nil, ReadOutput{}, err } output := ReadOutput{Recipes: recipeSummaries(recipes)} + text := formatRecipes(recipes) + if paginationHint != "" { + text += "\n\n" + paginationHint + } - return &sdk.CallToolResult{Content: []sdk.Content{&sdk.TextContent{Text: formatRecipes(recipes)}}}, output, nil + return &sdk.CallToolResult{Content: []sdk.Content{&sdk.TextContent{Text: text}}}, output, nil } -func (s *Server) readRecipeCards(ctx context.Context, query string, page, limit int) ([]cooked.RecipeCard, error) { +func (s *Server) readRecipeCards( + ctx context.Context, + query string, + page, limit int, +) ([]cooked.RecipeCard, string, error) { if query == "" { - return s.backend.ListRecipes(ctx, page, limit) + recipes, err := s.backend.ListRecipes(ctx, page, limit) + if err != nil { + return nil, "", err + } + if len(recipes) == limit { + return recipes, formatRecipeNextPageHint(query, page+1, limit), nil + } + + return recipes, "", nil } recipes, err := s.backend.SearchRecipes(ctx, query, page) if err != nil { - return nil, err + return nil, "", err + } + if len(recipes) == limit { + return recipes, formatRecipeNextPageHint(query, page+1, limit), nil } if len(recipes) > limit { + available := len(recipes) recipes = recipes[:limit] + return recipes, formatRecipeSearchLimitHint(query, page, page+1, available), nil } - return recipes, nil + return recipes, "", nil } func (s *Server) callPreviewRecipeTextTool( @@ -880,6 +902,33 @@ func formatRecipes(recipes []cooked.RecipeCard) string { return strings.TrimRight(builder.String(), "\n") } +func formatRecipeNextPageHint(query string, nextPage, limit int) string { + if query == "" { + return fmt.Sprintf( + "More recipes may be available. Call read with target recipes, page %d, limit %d to continue.", + nextPage, + limit, + ) + } + + return fmt.Sprintf( + "More matching recipes may be available. Call read with target recipes, query %q, page %d, limit %d to continue.", + query, + nextPage, + limit, + ) +} + +func formatRecipeSearchLimitHint(query string, page, nextPage, limit int) string { + return fmt.Sprintf( + "More matching recipes may be available. Call read with target recipes, query %q, page %d, limit %d to include more results from this page before trying page %d.", + query, + page, + limit, + nextPage, + ) +} + func formatRecipe(recipe RecipeDetail) string { title := recipe.Title if title == "" { diff --git a/internal/mcp/server_pagination_test.go b/internal/mcp/server_pagination_test.go new file mode 100644 index 0000000000000000000000000000000000000000..79c5bd6d67478f676a7e2a5e37576e17b7322915 --- /dev/null +++ b/internal/mcp/server_pagination_test.go @@ -0,0 +1,75 @@ +// SPDX-FileCopyrightText: Amolith +// +// SPDX-License-Identifier: LicenseRef-MutuaL-1.2 + +package mcp + +import ( + "context" + "strings" + "testing" + + "git.secluded.site/cooked-mcp/internal/cooked" +) + +// recipes.PAGINATION.6 api-client.LIMITS.3 +func TestCallToolACIDRecipesPagination6MentionsNextPageForFullRecipeList(t *testing.T) { + backend := &fakeBackend{recipes: []cooked.RecipeCard{ + {ID: "recipe-1", Title: "Pasta", ThumbnailURL: "https://example.invalid/thumb.jpg"}, + {ID: "recipe-2", Title: "Soup", ThumbnailURL: "https://example.invalid/thumb2.jpg"}, + }} + server := NewServer(backend, "test") + + result, _, err := server.callReadTool( + context.Background(), + nil, + ReadArguments{Target: "recipes", Page: 2, Limit: 2}, + ) + if err != nil { + t.Fatalf("callReadTool() error = %v", err) + } + + text := requireTextContent(t, result) + if !strings.Contains(text, "More recipes may be available") { + t.Fatalf("text = %q, want more recipes hint", text) + } + if !strings.Contains(text, "page 3") || !strings.Contains(text, "limit 2") { + t.Fatalf("text = %q, want next page and limit", text) + } +} + +// recipes.PAGINATION.6 api-client.LIMITS.3 +func TestCallToolACIDRecipesPagination6MentionsNextPageForTruncatedSearch(t *testing.T) { + backend := &fakeBackend{searchRecipes: []cooked.RecipeCard{ + {ID: "recipe-1", Title: "Pasta", ThumbnailURL: "https://example.invalid/thumb.jpg"}, + {ID: "recipe-2", Title: "Tomato Pasta", ThumbnailURL: "https://example.invalid/thumb2.jpg"}, + {ID: "recipe-3", Title: "Pasta Salad", ThumbnailURL: "https://example.invalid/thumb3.jpg"}, + }} + server := NewServer(backend, "test") + + result, output, err := server.callReadTool( + context.Background(), + nil, + ReadArguments{Target: "recipes", Query: " pasta ", Page: 2, Limit: 1}, + ) + if err != nil { + t.Fatalf("callReadTool() error = %v", err) + } + + if len(output.Recipes) != 1 { + t.Fatalf("structured recipes = %d, want truncated result length 1", len(output.Recipes)) + } + text := requireTextContent(t, result) + if !strings.Contains(text, "More matching recipes may be available") { + t.Fatalf("text = %q, want more matching recipes hint", text) + } + if !strings.Contains(text, `query "pasta"`) { + t.Fatalf("text = %q, want preserved search query", text) + } + if !strings.Contains(text, "page 2") || !strings.Contains(text, "limit 3") { + t.Fatalf("text = %q, want same page and increased limit", text) + } + if !strings.Contains(text, "before trying page 3") { + t.Fatalf("text = %q, want next page ordering guidance", text) + } +} diff --git a/internal/mcp/server_test.go b/internal/mcp/server_test.go index 43bba776f8f64d301b211e7b458e2304d1f257d1..7b9e12172ac77abf2ba18841c58cba5f0c293aae 100644 --- a/internal/mcp/server_test.go +++ b/internal/mcp/server_test.go @@ -198,6 +198,15 @@ func requireNoImageLeak(t *testing.T, result *sdk.CallToolResult, output ReadOut strings.Contains(string(encodedOutput), "https://example.invalid/pasta.jpg") { t.Fatalf("structured output leaked image data: %s", encodedOutput) } + text := requireTextContent(t, result) + if strings.Contains(text, "https://example.invalid/pasta.jpg") { + t.Fatalf("text output leaked image URL: %s", text) + } +} + +func requireTextContent(t *testing.T, result *sdk.CallToolResult) string { + t.Helper() + if len(result.Content) != 1 { t.Fatalf("text content length = %d, want 1", len(result.Content)) } @@ -205,9 +214,8 @@ func requireNoImageLeak(t *testing.T, result *sdk.CallToolResult, output ReadOut if !ok { t.Fatalf("content type = %T, want *sdk.TextContent", result.Content[0]) } - if strings.Contains(textContent.Text, "https://example.invalid/pasta.jpg") { - t.Fatalf("text output leaked image URL: %s", textContent.Text) - } + + return textContent.Text } func TestCallToolACIDToolsReadTool3RequiresRecipeIDForRecipeTarget(t *testing.T) {