tools: replace shopping-list selection

Amolith created

Change summary

internal/mcp/server.go      |  36 ++++++++--
internal/mcp/server_test.go | 136 ++++++++++++++++++++++++++++----------
2 files changed, 130 insertions(+), 42 deletions(-)

Detailed changes

internal/mcp/server.go 🔗

@@ -38,6 +38,7 @@ type Backend interface {
 	ClearShoppingList(ctx context.Context) error
 	AddShoppingListIngredients(ctx context.Context, ingredients, recipeID string) (cooked.AddShoppingListResult, error)
 	RemoveShoppingListProductGroups(ctx context.Context, productGroupIDs []string) error
+	ReplaceShoppingListSelection(ctx context.Context, productGroupIDs []string) error
 }
 
 // Server exposes Cooked as an MCP server.
@@ -387,6 +388,26 @@ func (s *Server) callChangeShoppingListTool(
 				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",
+			)
+		}
+
+		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
 	case "":
 		return nil, ChangeShoppingListOutput{}, fmt.Errorf("action is required")
 	default:
@@ -459,7 +480,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 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 or replace_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,
@@ -693,16 +714,17 @@ 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, and clear."`
+	Action          string   `json:"action"                      jsonschema:"Shopping-list action. Supported now: add, remove, replace_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."`
+	ProductGroupIDs []string `json:"product_group_ids,omitempty" jsonschema:"Product group IDs for action remove or replace_selection."`
 }
 
 // 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"`
-	RemovedProductGroupIDs []string `json:"removed_product_group_ids,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"`
+	SelectedProductGroupIDs []string `json:"selected_product_group_ids,omitempty"`
 }

internal/mcp/server_test.go 🔗

@@ -698,6 +698,63 @@ func TestCallToolACIDShoppingListRemove1And2RemovesProductGroups(t *testing.T) {
 	}
 }
 
+func TestCallToolACIDShoppingListSelection1And5And6And8ReplacesSelectedSet(t *testing.T) {
+	backend := &fakeBackend{}
+	server := NewServer(backend, "test")
+
+	_, output, err := server.callChangeShoppingListTool(
+		context.Background(),
+		nil,
+		ChangeShoppingListArguments{
+			Action:          " replace_selection ",
+			ProductGroupIDs: []string{" pasta ", "tomato", "   "},
+		},
+	)
+	if err != nil {
+		t.Fatalf("callChangeShoppingListTool() error = %v", err)
+	}
+
+	if backend.replaceSelectionCalls != 1 {
+		t.Fatalf("replace selection calls = %d, want 1", backend.replaceSelectionCalls)
+	}
+	if len(backend.replaceSelectionProductGroupIDs) != 2 || backend.replaceSelectionProductGroupIDs[0] != "pasta" ||
+		backend.replaceSelectionProductGroupIDs[1] != "tomato" {
+		t.Fatalf("backend selected IDs = %#v, want pasta and tomato", backend.replaceSelectionProductGroupIDs)
+	}
+	if len(output.SelectedProductGroupIDs) != 2 || output.SelectedProductGroupIDs[0] != "pasta" ||
+		output.SelectedProductGroupIDs[1] != "tomato" {
+		t.Fatalf("output selected IDs = %#v, want pasta and tomato", output.SelectedProductGroupIDs)
+	}
+}
+
+func TestCallToolACIDShoppingListSelection7RequiresReplaceSelectionProductGroupIDs(t *testing.T) {
+	tests := []struct {
+		name      string
+		arguments ChangeShoppingListArguments
+	}{
+		{name: "nil", arguments: ChangeShoppingListArguments{Action: "replace_selection"}},
+		{
+			name:      "blank",
+			arguments: ChangeShoppingListArguments{Action: "replace_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.replaceSelectionCalls != 0 {
+				t.Fatalf("replace selection calls = %d, want none", backend.replaceSelectionCalls)
+			}
+		})
+	}
+}
+
 func TestCallToolACIDShoppingListSafety1RequiresRemoveProductGroupIDs(t *testing.T) {
 	tests := []struct {
 		name      string
@@ -749,41 +806,43 @@ 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
-	removeProductGroupIDs   []string
-	metadataCalls           int
-	contentCalls            int
-	previewCalls            int
-	saveCalls               int
-	updateCalls             int
-	deleteCalls             int
-	clearShoppingListCalls  int
-	addShoppingListCalls    int
-	removeShoppingListCalls 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
+	replaceSelectionProductGroupIDs []string
+	metadataCalls                   int
+	contentCalls                    int
+	previewCalls                    int
+	saveCalls                       int
+	updateCalls                     int
+	deleteCalls                     int
+	clearShoppingListCalls          int
+	addShoppingListCalls            int
+	removeShoppingListCalls         int
+	replaceSelectionCalls           int
 }
 
 func (f *fakeBackend) ReadShoppingList(context.Context) (cooked.ShoppingList, error) {
@@ -877,3 +936,10 @@ func (f *fakeBackend) RemoveShoppingListProductGroups(_ context.Context, ids []s
 
 	return nil
 }
+
+func (f *fakeBackend) ReplaceShoppingListSelection(_ context.Context, ids []string) error {
+	f.replaceSelectionProductGroupIDs = ids
+	f.replaceSelectionCalls++
+
+	return nil
+}