recipes: mention next page for recipe reads

Amolith created

Change summary

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(-)

Detailed changes

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 == "" {

internal/mcp/server_pagination_test.go 🔗

@@ -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)
+	}
+}

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) {