From fe9dce9773de0fdf8c5fbb4f75e2749428df8515 Mon Sep 17 00:00:00 2001 From: Amolith Date: Wed, 10 Jun 2026 16:07:18 -0600 Subject: [PATCH] tools: add shopping-list selection --- internal/mcp/server.go | 224 +++++++++++++++++++++++++----------- internal/mcp/server_test.go | 72 ++++++++++++ 2 files changed, 230 insertions(+), 66 deletions(-) diff --git a/internal/mcp/server.go b/internal/mcp/server.go index 0c25bd2880e90a7f2341ed2a2d7c508e1da1e7c2..9d33d7adffc77c211456a1574dfc7b22ca29a62b 100644 --- a/internal/mcp/server.go +++ b/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. diff --git a/internal/mcp/server_test.go b/internal/mcp/server_test.go index 712b5a09110679151784224dd4f3a45fadeaa4bd..f8d47470e450c01bc40c8fdb3894f8c987776ccd 100644 --- a/internal/mcp/server_test.go +++ b/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 }