@@ -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 == "" {
@@ -0,0 +1,75 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+//
+// 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)
+ }
+}
@@ -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) {