tools: add shopping-list selection

Amolith created

Change summary

internal/mcp/server.go      | 224 +++++++++++++++++++++++++++-----------
internal/mcp/server_test.go |  72 ++++++++++++
2 files changed, 230 insertions(+), 66 deletions(-)

Detailed changes

internal/mcp/server.go 🔗

@@ -339,83 +339,139 @@ func (s *Server) callChangeShoppingListTool(
 	action := strings.TrimSpace(arguments.Action)
 	switch action {
 	case "add":
-		if strings.TrimSpace(arguments.Ingredients) == "" {
-			return nil, ChangeShoppingListOutput{}, fmt.Errorf("ingredients is required when action is add")
-		}
-
-		result, err := s.backend.AddShoppingListIngredients(
-			ctx,
-			arguments.Ingredients,
-			strings.TrimSpace(arguments.RecipeID),
+		return s.addShoppingListIngredients(ctx, arguments)
+	case "clear":
+		return s.clearShoppingList(ctx)
+	case "remove":
+		return s.removeShoppingListProductGroups(ctx, arguments)
+	case "replace_selection":
+		return s.replaceShoppingListSelection(ctx, arguments)
+	case "add_selection":
+		return s.addShoppingListSelection(ctx, arguments)
+	case "":
+		return nil, ChangeShoppingListOutput{}, fmt.Errorf("action is required")
+	default:
+		return nil, ChangeShoppingListOutput{}, fmt.Errorf(
+			"unsupported change_shopping_list action %q in this slice",
+			action,
 		)
-		if err != nil {
-			return nil, ChangeShoppingListOutput{}, err
-		}
+	}
+}
 
-		output := ChangeShoppingListOutput{AddedCount: result.AddedCount, Ingredients: result.Ingredients}
+func (s *Server) addShoppingListIngredients(
+	ctx context.Context,
+	arguments ChangeShoppingListArguments,
+) (*sdk.CallToolResult, ChangeShoppingListOutput, error) {
+	if strings.TrimSpace(arguments.Ingredients) == "" {
+		return nil, ChangeShoppingListOutput{}, fmt.Errorf("ingredients is required when action is add")
+	}
 
-		return &sdk.CallToolResult{
-			Content: []sdk.Content{&sdk.TextContent{Text: fmt.Sprintf(
-				"Added %d shopping-list ingredients.",
-				result.AddedCount,
-			)}},
-		}, output, nil
-	case "clear":
-		if err := s.backend.ClearShoppingList(ctx); err != nil {
-			return nil, ChangeShoppingListOutput{}, err
-		}
+	result, err := s.backend.AddShoppingListIngredients(
+		ctx,
+		arguments.Ingredients,
+		strings.TrimSpace(arguments.RecipeID),
+	)
+	if err != nil {
+		return nil, ChangeShoppingListOutput{}, err
+	}
 
-		output := ChangeShoppingListOutput{Cleared: true}
+	output := ChangeShoppingListOutput{AddedCount: result.AddedCount, Ingredients: result.Ingredients}
 
-		return &sdk.CallToolResult{
-			Content: []sdk.Content{&sdk.TextContent{Text: "Cleared shopping list."}},
-		}, output, nil
-	case "remove":
-		ids := normalizeProductGroupIDs(arguments.ProductGroupIDs)
-		if len(ids) == 0 {
-			return nil, ChangeShoppingListOutput{}, fmt.Errorf("product_group_ids is required when action is remove")
-		}
+	return &sdk.CallToolResult{
+		Content: []sdk.Content{&sdk.TextContent{Text: fmt.Sprintf(
+			"Added %d shopping-list ingredients.",
+			result.AddedCount,
+		)}},
+	}, output, nil
+}
 
-		if err := s.backend.RemoveShoppingListProductGroups(ctx, ids); err != nil {
-			return nil, ChangeShoppingListOutput{}, err
-		}
+func (s *Server) clearShoppingList(ctx context.Context) (*sdk.CallToolResult, ChangeShoppingListOutput, error) {
+	if err := s.backend.ClearShoppingList(ctx); err != nil {
+		return nil, ChangeShoppingListOutput{}, err
+	}
 
-		output := ChangeShoppingListOutput{RemovedProductGroupIDs: ids}
+	output := ChangeShoppingListOutput{Cleared: true}
 
-		return &sdk.CallToolResult{
-			Content: []sdk.Content{&sdk.TextContent{Text: fmt.Sprintf(
-				"Removed %d shopping-list product groups.",
-				len(ids),
-			)}},
-		}, output, nil
-	case "replace_selection":
-		ids := normalizeProductGroupIDs(arguments.ProductGroupIDs)
-		if len(ids) == 0 {
-			return nil, ChangeShoppingListOutput{}, fmt.Errorf(
-				"product_group_ids is required when action is replace_selection",
-			)
-		}
+	return &sdk.CallToolResult{
+		Content: []sdk.Content{&sdk.TextContent{Text: "Cleared shopping list."}},
+	}, output, nil
+}
 
-		if err := s.backend.ReplaceShoppingListSelection(ctx, ids); err != nil {
-			return nil, ChangeShoppingListOutput{}, err
-		}
+func (s *Server) removeShoppingListProductGroups(
+	ctx context.Context,
+	arguments ChangeShoppingListArguments,
+) (*sdk.CallToolResult, ChangeShoppingListOutput, error) {
+	ids := normalizeProductGroupIDs(arguments.ProductGroupIDs)
+	if len(ids) == 0 {
+		return nil, ChangeShoppingListOutput{}, fmt.Errorf("product_group_ids is required when action is remove")
+	}
 
-		output := ChangeShoppingListOutput{SelectedProductGroupIDs: ids}
+	if err := s.backend.RemoveShoppingListProductGroups(ctx, ids); err != nil {
+		return nil, ChangeShoppingListOutput{}, err
+	}
 
-		return &sdk.CallToolResult{
-			Content: []sdk.Content{&sdk.TextContent{Text: fmt.Sprintf(
-				"Selected %d shopping-list product groups.",
-				len(ids),
-			)}},
-		}, output, nil
-	case "":
-		return nil, ChangeShoppingListOutput{}, fmt.Errorf("action is required")
-	default:
+	output := ChangeShoppingListOutput{RemovedProductGroupIDs: ids}
+
+	return &sdk.CallToolResult{
+		Content: []sdk.Content{&sdk.TextContent{Text: fmt.Sprintf(
+			"Removed %d shopping-list product groups.",
+			len(ids),
+		)}},
+	}, output, nil
+}
+
+func (s *Server) replaceShoppingListSelection(
+	ctx context.Context,
+	arguments ChangeShoppingListArguments,
+) (*sdk.CallToolResult, ChangeShoppingListOutput, error) {
+	ids := normalizeProductGroupIDs(arguments.ProductGroupIDs)
+	if len(ids) == 0 {
 		return nil, ChangeShoppingListOutput{}, fmt.Errorf(
-			"unsupported change_shopping_list action %q in this slice",
-			action,
+			"product_group_ids is required when action is replace_selection",
+		)
+	}
+
+	return s.setShoppingListSelection(ctx, ids)
+}
+
+func (s *Server) addShoppingListSelection(
+	ctx context.Context,
+	arguments ChangeShoppingListArguments,
+) (*sdk.CallToolResult, ChangeShoppingListOutput, error) {
+	ids := normalizeProductGroupIDs(arguments.ProductGroupIDs)
+	if len(ids) == 0 {
+		return nil, ChangeShoppingListOutput{}, fmt.Errorf(
+			"product_group_ids is required when action is add_selection",
 		)
 	}
+
+	shoppingList, err := s.backend.ReadShoppingList(ctx)
+	if err != nil {
+		return nil, ChangeShoppingListOutput{}, err
+	}
+
+	return s.setShoppingListSelection(
+		ctx,
+		appendMissingProductGroupIDs(selectedProductGroupIDs(shoppingList), ids),
+	)
+}
+
+func (s *Server) setShoppingListSelection(
+	ctx context.Context,
+	ids []string,
+) (*sdk.CallToolResult, ChangeShoppingListOutput, error) {
+	if err := s.backend.ReplaceShoppingListSelection(ctx, ids); err != nil {
+		return nil, ChangeShoppingListOutput{}, err
+	}
+
+	output := ChangeShoppingListOutput{SelectedProductGroupIDs: ids}
+
+	return &sdk.CallToolResult{
+		Content: []sdk.Content{&sdk.TextContent{Text: fmt.Sprintf(
+			"Selected %d shopping-list product groups.",
+			len(ids),
+		)}},
+	}, output, nil
 }
 
 func readTool() *sdk.Tool {
@@ -480,7 +536,7 @@ func changeShoppingListTool() *sdk.Tool {
 	return &sdk.Tool{
 		Name:        changeShoppingListToolName,
 		Title:       "Change shopping list",
-		Description: "Change the Cooked shopping list. This slice supports action add with newline-separated ingredients and optional recipe_id, action remove or replace_selection with product_group_ids, and action clear. Remove and clear are destructive. Other actions are reserved for later slices.",
+		Description: "Change the Cooked shopping list. This slice supports action add with newline-separated ingredients and optional recipe_id, action remove, replace_selection, or add_selection with product_group_ids, and action clear. Remove and clear are destructive. Other actions are reserved for later slices.",
 		Annotations: &sdk.ToolAnnotations{
 			Title:           "Change shopping list",
 			DestructiveHint: &destructive,
@@ -515,6 +571,42 @@ func normalizeProductGroupIDs(ids []string) []string {
 	return normalized
 }
 
+func selectedProductGroupIDs(shoppingList cooked.ShoppingList) []string {
+	var ids []string
+	for _, aisle := range shoppingList.Aisles {
+		for _, product := range aisle.ProductGroups {
+			if product.Selected {
+				ids = append(ids, product.ID)
+			}
+		}
+	}
+
+	return ids
+}
+
+func appendMissingProductGroupIDs(existing, additions []string) []string {
+	seen := make(map[string]struct{}, len(existing)+len(additions))
+	result := make([]string, 0, len(existing)+len(additions))
+
+	for _, id := range existing {
+		if _, ok := seen[id]; ok {
+			continue
+		}
+		seen[id] = struct{}{}
+		result = append(result, id)
+	}
+
+	for _, id := range additions {
+		if _, ok := seen[id]; ok {
+			continue
+		}
+		seen[id] = struct{}{}
+		result = append(result, id)
+	}
+
+	return result
+}
+
 func formatShoppingList(shoppingList cooked.ShoppingList) string {
 	if len(shoppingList.Aisles) == 0 {
 		return "Shopping list is empty."
@@ -714,10 +806,10 @@ type DeleteRecipeOutput struct {
 
 // ChangeShoppingListArguments contains change_shopping_list tool arguments.
 type ChangeShoppingListArguments struct {
-	Action          string   `json:"action"                      jsonschema:"Shopping-list action. Supported now: add, remove, replace_selection, and clear."`
+	Action          string   `json:"action"                      jsonschema:"Shopping-list action. Supported now: add, remove, replace_selection, add_selection, and clear."`
 	Ingredients     string   `json:"ingredients,omitempty"       jsonschema:"Newline-separated ingredients for action add."`
 	RecipeID        string   `json:"recipe_id,omitempty"         jsonschema:"Optional saved recipe ID or import draft ID for action add."`
-	ProductGroupIDs []string `json:"product_group_ids,omitempty" jsonschema:"Product group IDs for action remove or replace_selection."`
+	ProductGroupIDs []string `json:"product_group_ids,omitempty" jsonschema:"Product group IDs for action remove, replace_selection, or add_selection."`
 }
 
 // ChangeShoppingListOutput is the structured output for change_shopping_list.

internal/mcp/server_test.go 🔗

@@ -7,6 +7,7 @@ package mcp
 import (
 	"context"
 	"encoding/json"
+	"slices"
 	"strings"
 	"testing"
 
@@ -755,6 +756,74 @@ func TestCallToolACIDShoppingListSelection7RequiresReplaceSelectionProductGroupI
 	}
 }
 
+func TestCallToolACIDShoppingListSelection2And4And5And6And9AddsSelectedSet(t *testing.T) {
+	backend := &fakeBackend{shoppingList: cooked.ShoppingList{Aisles: []cooked.Aisle{{
+		ProductGroups: []cooked.ProductGroup{
+			{ID: "pasta", Selected: true},
+			{ID: "tomato"},
+			{ID: "salt", Selected: true},
+		},
+	}}}}
+	server := NewServer(backend, "test")
+
+	_, output, err := server.callChangeShoppingListTool(
+		context.Background(),
+		nil,
+		ChangeShoppingListArguments{
+			Action:          " add_selection ",
+			ProductGroupIDs: []string{" tomato ", "pasta", "   ", "pepper"},
+		},
+	)
+	if err != nil {
+		t.Fatalf("callChangeShoppingListTool() error = %v", err)
+	}
+
+	if backend.readShoppingListCalls != 1 {
+		t.Fatalf("read shopping-list calls = %d, want 1", backend.readShoppingListCalls)
+	}
+	if backend.replaceSelectionCalls != 1 {
+		t.Fatalf("replace selection calls = %d, want 1", backend.replaceSelectionCalls)
+	}
+	expectedIDs := []string{"pasta", "salt", "tomato", "pepper"}
+	if !slices.Equal(backend.replaceSelectionProductGroupIDs, expectedIDs) {
+		t.Fatalf("backend selected IDs = %#v, want %#v", backend.replaceSelectionProductGroupIDs, expectedIDs)
+	}
+	if !slices.Equal(output.SelectedProductGroupIDs, expectedIDs) {
+		t.Fatalf("output selected IDs = %#v, want %#v", output.SelectedProductGroupIDs, expectedIDs)
+	}
+}
+
+func TestCallToolACIDShoppingListSelection7RequiresAddSelectionProductGroupIDs(t *testing.T) {
+	tests := []struct {
+		name      string
+		arguments ChangeShoppingListArguments
+	}{
+		{name: "nil", arguments: ChangeShoppingListArguments{Action: "add_selection"}},
+		{
+			name:      "blank",
+			arguments: ChangeShoppingListArguments{Action: "add_selection", ProductGroupIDs: []string{"  "}},
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			backend := &fakeBackend{}
+			server := NewServer(backend, "test")
+
+			_, _, err := server.callChangeShoppingListTool(context.Background(), nil, tt.arguments)
+			if err == nil {
+				t.Fatal("callChangeShoppingListTool() error = nil, want missing product_group_ids error")
+			}
+			if backend.readShoppingListCalls != 0 {
+				t.Fatalf("read shopping-list calls = %d, want none", backend.readShoppingListCalls)
+			}
+			if backend.replaceSelectionCalls != 0 {
+				t.Fatalf("replace selection calls = %d, want none", backend.replaceSelectionCalls)
+			}
+		})
+	}
+}
+
 func TestCallToolACIDShoppingListSafety1RequiresRemoveProductGroupIDs(t *testing.T) {
 	tests := []struct {
 		name      string
@@ -834,6 +903,7 @@ type fakeBackend struct {
 	removeProductGroupIDs           []string
 	replaceSelectionProductGroupIDs []string
 	metadataCalls                   int
+	readShoppingListCalls           int
 	contentCalls                    int
 	previewCalls                    int
 	saveCalls                       int
@@ -846,6 +916,8 @@ type fakeBackend struct {
 }
 
 func (f *fakeBackend) ReadShoppingList(context.Context) (cooked.ShoppingList, error) {
+	f.readShoppingListCalls++
+
 	return f.shoppingList, nil
 }