tools: remove shopping-list product groups

Amolith created

Change summary

internal/mcp/server.go      |  47 ++++++++++++--
internal/mcp/server_test.go | 129 +++++++++++++++++++++++++++++---------
2 files changed, 136 insertions(+), 40 deletions(-)

Detailed changes

internal/mcp/server.go 🔗

@@ -37,6 +37,7 @@ type Backend interface {
 	DeleteRecipe(ctx context.Context, recipeID string) error
 	ClearShoppingList(ctx context.Context) error
 	AddShoppingListIngredients(ctx context.Context, ingredients, recipeID string) (cooked.AddShoppingListResult, error)
+	RemoveShoppingListProductGroups(ctx context.Context, productGroupIDs []string) error
 }
 
 // Server exposes Cooked as an MCP server.
@@ -368,6 +369,24 @@ func (s *Server) callChangeShoppingListTool(
 		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")
+		}
+
+		if err := s.backend.RemoveShoppingListProductGroups(ctx, ids); err != nil {
+			return nil, ChangeShoppingListOutput{}, err
+		}
+
+		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
 	case "":
 		return nil, ChangeShoppingListOutput{}, fmt.Errorf("action is required")
 	default:
@@ -440,7 +459,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, and action clear, which is destructive and deletes all shopping-list items. 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 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,
@@ -463,6 +482,18 @@ func normalizeRecipePage(page, limit int) (int, int) {
 	return page, limit
 }
 
+func normalizeProductGroupIDs(ids []string) []string {
+	normalized := make([]string, 0, len(ids))
+	for _, id := range ids {
+		id = strings.TrimSpace(id)
+		if id != "" {
+			normalized = append(normalized, id)
+		}
+	}
+
+	return normalized
+}
+
 func formatShoppingList(shoppingList cooked.ShoppingList) string {
 	if len(shoppingList.Aisles) == 0 {
 		return "Shopping list is empty."
@@ -662,14 +693,16 @@ type DeleteRecipeOutput struct {
 
 // ChangeShoppingListArguments contains change_shopping_list tool arguments.
 type ChangeShoppingListArguments struct {
-	Action      string `json:"action"                jsonschema:"Shopping-list action. Supported now: add 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."`
+	Action          string   `json:"action"                      jsonschema:"Shopping-list action. Supported now: add, remove, 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."`
 }
 
 // ChangeShoppingListOutput is the structured output for change_shopping_list.
 type ChangeShoppingListOutput struct {
-	Cleared     bool     `json:"cleared,omitempty"`
-	AddedCount  int      `json:"added_count,omitempty"`
-	Ingredients []string `json:"ingredients,omitempty"`
+	Cleared                bool     `json:"cleared,omitempty"`
+	AddedCount             int      `json:"added_count,omitempty"`
+	Ingredients            []string `json:"ingredients,omitempty"`
+	RemovedProductGroupIDs []string `json:"removed_product_group_ids,omitempty"`
 }

internal/mcp/server_test.go 🔗

@@ -669,6 +669,60 @@ func TestCallToolACIDToolsChangeShoppingListTool2RequiresIngredients(t *testing.
 	}
 }
 
+func TestCallToolACIDShoppingListRemove1And2RemovesProductGroups(t *testing.T) {
+	backend := &fakeBackend{}
+	server := NewServer(backend, "test")
+
+	_, output, err := server.callChangeShoppingListTool(
+		context.Background(),
+		nil,
+		ChangeShoppingListArguments{
+			Action:          " remove ",
+			ProductGroupIDs: []string{" pasta ", "tomato", "   "},
+		},
+	)
+	if err != nil {
+		t.Fatalf("callChangeShoppingListTool() error = %v", err)
+	}
+
+	if backend.removeShoppingListCalls != 1 {
+		t.Fatalf("remove calls = %d, want 1", backend.removeShoppingListCalls)
+	}
+	if len(backend.removeProductGroupIDs) != 2 || backend.removeProductGroupIDs[0] != "pasta" ||
+		backend.removeProductGroupIDs[1] != "tomato" {
+		t.Fatalf("backend remove IDs = %#v, want pasta and tomato", backend.removeProductGroupIDs)
+	}
+	if len(output.RemovedProductGroupIDs) != 2 || output.RemovedProductGroupIDs[0] != "pasta" ||
+		output.RemovedProductGroupIDs[1] != "tomato" {
+		t.Fatalf("output removed IDs = %#v, want pasta and tomato", output.RemovedProductGroupIDs)
+	}
+}
+
+func TestCallToolACIDShoppingListSafety1RequiresRemoveProductGroupIDs(t *testing.T) {
+	tests := []struct {
+		name      string
+		arguments ChangeShoppingListArguments
+	}{
+		{name: "nil", arguments: ChangeShoppingListArguments{Action: "remove"}},
+		{name: "blank", arguments: ChangeShoppingListArguments{Action: "remove", 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.removeShoppingListCalls != 0 {
+				t.Fatalf("remove calls = %d, want none", backend.removeShoppingListCalls)
+			}
+		})
+	}
+}
+
 func TestChangeShoppingListToolACIDToolsAnnotations2And4To6MarksDestructiveOpenWorldTool(t *testing.T) {
 	tool := changeShoppingListTool()
 	if tool.Name != changeShoppingListToolName {
@@ -695,39 +749,41 @@ func TestChangeShoppingListToolACIDToolsAnnotations2And4To6MarksDestructiveOpenW
 }
 
 type fakeBackend struct {
-	shoppingList           cooked.ShoppingList
-	recipes                []cooked.RecipeCard
-	searchRecipes          []cooked.RecipeCard
-	recipeMetadata         cooked.RecipeMetadata
-	recipeContent          cooked.RecipeContent
-	preview                cooked.RecipeTextPreview
-	saveRecipeID           string
-	page                   int
-	limit                  int
-	searchQuery            string
-	searchPage             int
-	metadataRecipeID       string
-	contentRecipeID        string
-	previewTitle           string
-	previewText            string
-	saveTitle              string
-	saveMarkdown           string
-	savePortions           int
-	updateRecipeID         string
-	updateMarkdown         string
-	updatePortions         int
-	deleteRecipeID         string
-	addShoppingListResult  cooked.AddShoppingListResult
-	addIngredients         string
-	addRecipeID            string
-	metadataCalls          int
-	contentCalls           int
-	previewCalls           int
-	saveCalls              int
-	updateCalls            int
-	deleteCalls            int
-	clearShoppingListCalls int
-	addShoppingListCalls   int
+	shoppingList            cooked.ShoppingList
+	recipes                 []cooked.RecipeCard
+	searchRecipes           []cooked.RecipeCard
+	recipeMetadata          cooked.RecipeMetadata
+	recipeContent           cooked.RecipeContent
+	preview                 cooked.RecipeTextPreview
+	saveRecipeID            string
+	page                    int
+	limit                   int
+	searchQuery             string
+	searchPage              int
+	metadataRecipeID        string
+	contentRecipeID         string
+	previewTitle            string
+	previewText             string
+	saveTitle               string
+	saveMarkdown            string
+	savePortions            int
+	updateRecipeID          string
+	updateMarkdown          string
+	updatePortions          int
+	deleteRecipeID          string
+	addShoppingListResult   cooked.AddShoppingListResult
+	addIngredients          string
+	addRecipeID             string
+	removeProductGroupIDs   []string
+	metadataCalls           int
+	contentCalls            int
+	previewCalls            int
+	saveCalls               int
+	updateCalls             int
+	deleteCalls             int
+	clearShoppingListCalls  int
+	addShoppingListCalls    int
+	removeShoppingListCalls int
 }
 
 func (f *fakeBackend) ReadShoppingList(context.Context) (cooked.ShoppingList, error) {
@@ -814,3 +870,10 @@ func (f *fakeBackend) AddShoppingListIngredients(
 
 	return f.addShoppingListResult, nil
 }
+
+func (f *fakeBackend) RemoveShoppingListProductGroups(_ context.Context, ids []string) error {
+	f.removeProductGroupIDs = ids
+	f.removeShoppingListCalls++
+
+	return nil
+}