mcp: summarize shopping list text

Amolith created

Change summary

internal/mcp/server.go                 | 91 ++++++++++++++++++++-------
internal/mcp/server_structured_test.go | 69 +++++++++++++++++++++
2 files changed, 137 insertions(+), 23 deletions(-)

Detailed changes

internal/mcp/server.go 🔗

@@ -17,6 +17,7 @@ import (
 
 const (
 	serverName                 = "Cooked"
+	maxShoppingListTextItems   = 30
 	readToolName               = "read"
 	previewRecipeTextToolName  = "preview_recipe_text"
 	saveRecipeToolName         = "save_recipe"
@@ -831,36 +832,80 @@ func formatShoppingList(shoppingList cooked.ShoppingList) string {
 		return "Shopping list is empty."
 	}
 
+	totalItems := shoppingListProductGroupCount(shoppingList)
 	var builder strings.Builder
-	builder.WriteString("Shopping list:\n")
+	if totalItems > maxShoppingListTextItems {
+		fmt.Fprintf(&builder, "Shopping list (showing first %d of %d items):\n",
+			maxShoppingListTextItems,
+			totalItems)
+	} else {
+		builder.WriteString("Shopping list:\n")
+	}
+
+	shownItems := 0
 	for _, aisle := range shoppingList.Aisles {
-		builder.WriteString("- ")
-		builder.WriteString(aisle.Name)
-		builder.WriteString(":")
-		if len(aisle.ProductGroups) == 0 {
-			builder.WriteString(" no items\n")
-			continue
+		if shownItems >= maxShoppingListTextItems {
+			break
 		}
-		builder.WriteByte('\n')
+		shownItems = writeShoppingListAisle(&builder, aisle, shownItems)
+	}
+	if totalItems > maxShoppingListTextItems {
+		fmt.Fprintf(
+			&builder,
+			"Additional shopping-list items are available in structured_content from this tool response: %d.\n",
+			totalItems-maxShoppingListTextItems,
+		)
+	}
 
-		for _, product := range aisle.ProductGroups {
-			builder.WriteString("  - ")
-			builder.WriteString(product.Name)
-			if product.Quantity != "" {
-				builder.WriteString(" — ")
-				builder.WriteString(product.Quantity)
-			}
-			builder.WriteString(" (id: ")
-			builder.WriteString(product.ID)
-			builder.WriteString(")")
-			if product.Selected {
-				builder.WriteString(" selected")
-			}
-			builder.WriteByte('\n')
+	return strings.TrimRight(builder.String(), "\n")
+}
+
+func writeShoppingListAisle(builder *strings.Builder, aisle cooked.Aisle, shownItems int) int {
+	builder.WriteString("- ")
+	builder.WriteString(aisle.Name)
+	builder.WriteString(":")
+	if len(aisle.ProductGroups) == 0 {
+		builder.WriteString(" no items\n")
+
+		return shownItems
+	}
+	builder.WriteByte('\n')
+
+	for _, product := range aisle.ProductGroups {
+		if shownItems >= maxShoppingListTextItems {
+			break
 		}
+
+		writeShoppingListProductGroup(builder, product)
+		shownItems++
 	}
 
-	return strings.TrimRight(builder.String(), "\n")
+	return shownItems
+}
+
+func writeShoppingListProductGroup(builder *strings.Builder, product cooked.ProductGroup) {
+	builder.WriteString("  - ")
+	builder.WriteString(product.Name)
+	if product.Quantity != "" {
+		builder.WriteString(" — ")
+		builder.WriteString(product.Quantity)
+	}
+	builder.WriteString(" (id: ")
+	builder.WriteString(product.ID)
+	builder.WriteString(")")
+	if product.Selected {
+		builder.WriteString(" selected")
+	}
+	builder.WriteByte('\n')
+}
+
+func shoppingListProductGroupCount(shoppingList cooked.ShoppingList) int {
+	count := 0
+	for _, aisle := range shoppingList.Aisles {
+		count += len(aisle.ProductGroups)
+	}
+
+	return count
 }
 
 func formatRecipes(recipes []cooked.RecipeCard) string {

internal/mcp/server_structured_test.go 🔗

@@ -5,12 +5,81 @@
 package mcp
 
 import (
+	"context"
 	"encoding/json"
+	"fmt"
+	"strings"
 	"testing"
 
 	sdk "github.com/modelcontextprotocol/go-sdk/mcp"
+
+	"git.secluded.site/cooked-mcp/internal/cooked"
 )
 
+func TestCallToolACIDAPIClientLimits2SummarizesLongShoppingListText(t *testing.T) {
+	productGroups := make([]cooked.ProductGroup, maxShoppingListTextItems+1)
+	for index := range productGroups {
+		productGroups[index] = cooked.ProductGroup{
+			ID:   fmt.Sprintf("item-%02d", index+1),
+			Name: fmt.Sprintf("Item %02d", index+1),
+		}
+	}
+
+	backend := &fakeBackend{shoppingList: cooked.ShoppingList{Aisles: []cooked.Aisle{{
+		ID:            "pantry",
+		Name:          "Pantry",
+		ProductGroups: productGroups,
+	}}}}
+	server := NewServer(backend, "test")
+
+	result, output, err := server.callShoppingListReadTool(context.Background())
+	if err != nil {
+		t.Fatalf("callShoppingListReadTool() error = %v", err)
+	}
+
+	text := requireTextContent(t, result)
+	if !strings.Contains(text, "Shopping list (showing first 30 of 31 items):") {
+		t.Fatalf("text output missing shopping-list summary: %q", text)
+	}
+	if strings.Contains(text, "Item 31") || strings.Contains(text, "item-31") {
+		t.Fatalf("text output included truncated item: %q", text)
+	}
+	if !strings.Contains(text, "Additional shopping-list items are available in structured_content") {
+		t.Fatalf("text output missing structured_content hint: %q", text)
+	}
+	if len(output.Aisles) != 1 || len(output.Aisles[0].ProductGroups) != maxShoppingListTextItems+1 {
+		t.Fatalf("structured shopping-list item count = %#v, want all items", output.Aisles)
+	}
+	requireStructuredContent(t, result, output)
+}
+
+func TestCallToolACIDAPIClientLimits2PreservesShortShoppingListText(t *testing.T) {
+	backend := &fakeBackend{shoppingList: cooked.ShoppingList{Aisles: []cooked.Aisle{{
+		ID:   "pantry",
+		Name: "Pantry",
+		ProductGroups: []cooked.ProductGroup{{
+			ID:       "pasta",
+			Name:     "Pasta",
+			Quantity: "200g",
+		}},
+	}}}}
+	server := NewServer(backend, "test")
+
+	result, _, err := server.callShoppingListReadTool(context.Background())
+	if err != nil {
+		t.Fatalf("callShoppingListReadTool() error = %v", err)
+	}
+
+	text := requireTextContent(t, result)
+	want := "Shopping list:\n- Pantry:\n  - Pasta — 200g (id: pasta)"
+	if text != want {
+		t.Fatalf("short shopping-list text = %q, want %q", text, want)
+	}
+	if strings.Contains(text, "structured_content") || strings.Contains(text, "showing first") {
+		t.Fatalf("short shopping-list text included truncation hint: %q", text)
+	}
+}
+
 func requireTextContent(t *testing.T, result *sdk.CallToolResult) string {
 	t.Helper()