diff --git a/internal/mcp/server.go b/internal/mcp/server.go index 9d33d7adffc77c211456a1574dfc7b22ca29a62b..4146efd5be08ff58b2a4eae3dcd863aa63dcd578 100644 --- a/internal/mcp/server.go +++ b/internal/mcp/server.go @@ -348,6 +348,8 @@ func (s *Server) callChangeShoppingListTool( return s.replaceShoppingListSelection(ctx, arguments) case "add_selection": return s.addShoppingListSelection(ctx, arguments) + case "remove_selection": + return s.removeShoppingListSelection(ctx, arguments) case "": return nil, ChangeShoppingListOutput{}, fmt.Errorf("action is required") default: @@ -456,6 +458,28 @@ func (s *Server) addShoppingListSelection( ) } +func (s *Server) removeShoppingListSelection( + 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_selection", + ) + } + + shoppingList, err := s.backend.ReadShoppingList(ctx) + if err != nil { + return nil, ChangeShoppingListOutput{}, err + } + + return s.setShoppingListSelection( + ctx, + subtractProductGroupIDs(selectedProductGroupIDs(shoppingList), ids), + ) +} + func (s *Server) setShoppingListSelection( ctx context.Context, ids []string, @@ -536,7 +560,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, replace_selection, or add_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, add_selection, or remove_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, @@ -607,6 +631,28 @@ func appendMissingProductGroupIDs(existing, additions []string) []string { return result } +func subtractProductGroupIDs(existing, removals []string) []string { + removed := make(map[string]struct{}, len(removals)) + for _, id := range removals { + removed[id] = struct{}{} + } + + seen := make(map[string]struct{}, len(existing)) + result := make([]string, 0, len(existing)) + for _, id := range existing { + if _, ok := removed[id]; ok { + continue + } + 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." @@ -806,10 +852,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, add_selection, and clear."` + Action string `json:"action" jsonschema:"Shopping-list action. Supported now: add, remove, replace_selection, add_selection, remove_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, replace_selection, or add_selection."` + ProductGroupIDs []string `json:"product_group_ids,omitempty" jsonschema:"Product group IDs for action remove, replace_selection, add_selection, or remove_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 f8d47470e450c01bc40c8fdb3894f8c987776ccd..b81c2be2372027634b3d25c1b1c745238b7b01ea 100644 --- a/internal/mcp/server_test.go +++ b/internal/mcp/server_test.go @@ -824,6 +824,75 @@ func TestCallToolACIDShoppingListSelection7RequiresAddSelectionProductGroupIDs(t } } +func TestCallToolACIDShoppingListSelection3And4And5And6And10RemovesSelectedSet(t *testing.T) { + backend := &fakeBackend{shoppingList: cooked.ShoppingList{Aisles: []cooked.Aisle{{ + ProductGroups: []cooked.ProductGroup{ + {ID: "pasta", Selected: true}, + {ID: "tomato", Selected: true}, + {ID: "salt", Selected: true}, + {ID: "pepper"}, + }, + }}}} + server := NewServer(backend, "test") + + _, output, err := server.callChangeShoppingListTool( + context.Background(), + nil, + ChangeShoppingListArguments{ + Action: " remove_selection ", + ProductGroupIDs: []string{" tomato ", "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"} + 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 TestCallToolACIDShoppingListSelection7RequiresRemoveSelectionProductGroupIDs(t *testing.T) { + tests := []struct { + name string + arguments ChangeShoppingListArguments + }{ + {name: "nil", arguments: ChangeShoppingListArguments{Action: "remove_selection"}}, + { + name: "blank", + arguments: ChangeShoppingListArguments{Action: "remove_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