tools: remove shopping-list selection

Amolith created

Change summary

internal/mcp/server.go      | 52 +++++++++++++++++++++++++++-
internal/mcp/server_test.go | 69 +++++++++++++++++++++++++++++++++++++++
2 files changed, 118 insertions(+), 3 deletions(-)

Detailed changes

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.

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