tools: update shopping-list item

Amolith created

Change summary

internal/mcp/server.go                    |  89 +++++++++++++++++++
internal/mcp/server_test.go               |  91 +++++++++++--------
internal/mcp/shopping_list_update_test.go | 114 +++++++++++++++++++++++++
3 files changed, 254 insertions(+), 40 deletions(-)

Detailed changes

internal/mcp/server.go 🔗

@@ -39,6 +39,11 @@ type Backend interface {
 	AddShoppingListIngredients(ctx context.Context, ingredients, recipeID string) (cooked.AddShoppingListResult, error)
 	RemoveShoppingListProductGroups(ctx context.Context, productGroupIDs []string) error
 	ReplaceShoppingListSelection(ctx context.Context, productGroupIDs []string) error
+	UpdateShoppingListProductGroup(
+		ctx context.Context,
+		productGroupID string,
+		update cooked.ShoppingListProductGroupUpdate,
+	) error
 }
 
 // Server exposes Cooked as an MCP server.
@@ -350,6 +355,8 @@ func (s *Server) callChangeShoppingListTool(
 		return s.addShoppingListSelection(ctx, arguments)
 	case "remove_selection":
 		return s.removeShoppingListSelection(ctx, arguments)
+	case "update_item":
+		return s.updateShoppingListProductGroup(ctx, arguments)
 	case "":
 		return nil, ChangeShoppingListOutput{}, fmt.Errorf("action is required")
 	default:
@@ -498,6 +505,63 @@ func (s *Server) setShoppingListSelection(
 	}, output, nil
 }
 
+func (s *Server) updateShoppingListProductGroup(
+	ctx context.Context,
+	arguments ChangeShoppingListArguments,
+) (*sdk.CallToolResult, ChangeShoppingListOutput, error) {
+	productGroupID := strings.TrimSpace(arguments.ProductGroupID)
+	if productGroupID == "" {
+		return nil, ChangeShoppingListOutput{}, fmt.Errorf(
+			"product_group_id is required when action is update_item",
+		)
+	}
+
+	shoppingList, err := s.backend.ReadShoppingList(ctx)
+	if err != nil {
+		return nil, ChangeShoppingListOutput{}, err
+	}
+
+	product, aisleID, ok := findShoppingListProductGroup(shoppingList, productGroupID)
+	if !ok {
+		return nil, ChangeShoppingListOutput{}, fmt.Errorf(
+			"product_group_id %q was not found in the current shopping list",
+			productGroupID,
+		)
+	}
+
+	update := cooked.ShoppingListProductGroupUpdate{
+		Name:     product.Name,
+		Quantity: product.Quantity,
+		AisleID:  aisleID,
+		Selected: product.Selected,
+	}
+	if arguments.Name != nil {
+		update.Name = *arguments.Name
+	}
+	if arguments.Quantity != nil {
+		update.Quantity = *arguments.Quantity
+	}
+	if arguments.AisleID != nil {
+		update.AisleID = strings.TrimSpace(*arguments.AisleID)
+	}
+	if arguments.Selected != nil {
+		update.Selected = *arguments.Selected
+	}
+
+	if err := s.backend.UpdateShoppingListProductGroup(ctx, productGroupID, update); err != nil {
+		return nil, ChangeShoppingListOutput{}, err
+	}
+
+	output := ChangeShoppingListOutput{UpdatedProductGroupID: productGroupID}
+
+	return &sdk.CallToolResult{
+		Content: []sdk.Content{&sdk.TextContent{Text: fmt.Sprintf(
+			"Updated shopping-list product group (id: %s).",
+			productGroupID,
+		)}},
+	}, output, nil
+}
+
 func readTool() *sdk.Tool {
 	openWorld := true
 	return &sdk.Tool{
@@ -560,7 +624,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, add_selection, or remove_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, action update_item with product_group_id and optional name, quantity, aisle_id, or selected, and action clear. Remove and clear are destructive. Other actions are reserved for later slices.",
 		Annotations: &sdk.ToolAnnotations{
 			Title:           "Change shopping list",
 			DestructiveHint: &destructive,
@@ -653,6 +717,21 @@ func subtractProductGroupIDs(existing, removals []string) []string {
 	return result
 }
 
+func findShoppingListProductGroup(
+	shoppingList cooked.ShoppingList,
+	productGroupID string,
+) (cooked.ProductGroup, string, bool) {
+	for _, aisle := range shoppingList.Aisles {
+		for _, product := range aisle.ProductGroups {
+			if product.ID == productGroupID {
+				return product, aisle.ID, true
+			}
+		}
+	}
+
+	return cooked.ProductGroup{}, "", false
+}
+
 func formatShoppingList(shoppingList cooked.ShoppingList) string {
 	if len(shoppingList.Aisles) == 0 {
 		return "Shopping list is empty."
@@ -852,10 +931,15 @@ 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, remove_selection, and clear."`
+	Action          string   `json:"action"                      jsonschema:"Shopping-list action. Supported now: add, remove, replace_selection, add_selection, remove_selection, update_item, 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, add_selection, or remove_selection."`
+	ProductGroupID  string   `json:"product_group_id,omitempty"  jsonschema:"Product group ID for action update_item."`
+	Name            *string  `json:"name,omitempty"              jsonschema:"Replacement product name for action update_item."`
+	Quantity        *string  `json:"quantity,omitempty"          jsonschema:"Replacement quantity for action update_item."`
+	AisleID         *string  `json:"aisle_id,omitempty"          jsonschema:"Replacement aisle ID for action update_item."`
+	Selected        *bool    `json:"selected,omitempty"          jsonschema:"Replacement selected state for action update_item."`
 }
 
 // ChangeShoppingListOutput is the structured output for change_shopping_list.
@@ -865,4 +949,5 @@ type ChangeShoppingListOutput struct {
 	Ingredients             []string `json:"ingredients,omitempty"`
 	RemovedProductGroupIDs  []string `json:"removed_product_group_ids,omitempty"`
 	SelectedProductGroupIDs []string `json:"selected_product_group_ids,omitempty"`
+	UpdatedProductGroupID   string   `json:"updated_product_group_id,omitempty"`
 }

internal/mcp/server_test.go 🔗

@@ -944,44 +944,47 @@ 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
-	replaceSelectionProductGroupIDs []string
-	metadataCalls                   int
-	readShoppingListCalls           int
-	contentCalls                    int
-	previewCalls                    int
-	saveCalls                       int
-	updateCalls                     int
-	deleteCalls                     int
-	clearShoppingListCalls          int
-	addShoppingListCalls            int
-	removeShoppingListCalls         int
-	replaceSelectionCalls           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
+	updateShoppingListProductGroupID string
+	updateShoppingListProductGroup   cooked.ShoppingListProductGroupUpdate
+	metadataCalls                    int
+	readShoppingListCalls            int
+	contentCalls                     int
+	previewCalls                     int
+	saveCalls                        int
+	updateCalls                      int
+	deleteCalls                      int
+	clearShoppingListCalls           int
+	addShoppingListCalls             int
+	removeShoppingListCalls          int
+	replaceSelectionCalls            int
+	updateShoppingListCalls          int
 }
 
 func (f *fakeBackend) ReadShoppingList(context.Context) (cooked.ShoppingList, error) {
@@ -1084,3 +1087,15 @@ func (f *fakeBackend) ReplaceShoppingListSelection(_ context.Context, ids []stri
 
 	return nil
 }
+
+func (f *fakeBackend) UpdateShoppingListProductGroup(
+	_ context.Context,
+	productGroupID string,
+	update cooked.ShoppingListProductGroupUpdate,
+) error {
+	f.updateShoppingListProductGroupID = productGroupID
+	f.updateShoppingListProductGroup = update
+	f.updateShoppingListCalls++
+
+	return nil
+}

internal/mcp/shopping_list_update_test.go 🔗

@@ -0,0 +1,114 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+//
+// SPDX-License-Identifier: LicenseRef-MutuaL-1.2
+
+package mcp
+
+import (
+	"context"
+	"testing"
+
+	"git.secluded.site/cooked-mcp/internal/cooked"
+)
+
+func TestCallToolACIDShoppingListUpdateItem1To6And8And9UpdatesProductGroupWithMergedFields(t *testing.T) {
+	name := "Whole wheat pasta"
+	selected := false
+	backend := &fakeBackend{shoppingList: cooked.ShoppingList{Aisles: []cooked.Aisle{{
+		ID: "pantry",
+		ProductGroups: []cooked.ProductGroup{{
+			ID:       "pasta",
+			Name:     "Pasta",
+			Quantity: "200g",
+			Selected: true,
+		}},
+	}}}}
+	server := NewServer(backend, "test")
+
+	_, output, err := server.callChangeShoppingListTool(
+		context.Background(),
+		nil,
+		ChangeShoppingListArguments{
+			Action:         " update_item ",
+			ProductGroupID: " pasta ",
+			Name:           &name,
+			Selected:       &selected,
+		},
+	)
+	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.updateShoppingListCalls != 1 {
+		t.Fatalf("update shopping-list calls = %d, want 1", backend.updateShoppingListCalls)
+	}
+	if backend.updateShoppingListProductGroupID != "pasta" {
+		t.Fatalf("updated product group ID = %q, want pasta", backend.updateShoppingListProductGroupID)
+	}
+	expectedUpdate := cooked.ShoppingListProductGroupUpdate{
+		Name:     "Whole wheat pasta",
+		Quantity: "200g",
+		AisleID:  "pantry",
+		Selected: false,
+	}
+	if backend.updateShoppingListProductGroup != expectedUpdate {
+		t.Fatalf("update = %#v, want %#v", backend.updateShoppingListProductGroup, expectedUpdate)
+	}
+	if output.UpdatedProductGroupID != "pasta" {
+		t.Fatalf("output updated product group ID = %q, want pasta", output.UpdatedProductGroupID)
+	}
+}
+
+func TestCallToolACIDShoppingListUpdateItem7ReportsMissingProductGroup(t *testing.T) {
+	backend := &fakeBackend{shoppingList: cooked.ShoppingList{Aisles: []cooked.Aisle{{
+		ID:            "pantry",
+		ProductGroups: []cooked.ProductGroup{{ID: "pasta"}},
+	}}}}
+	server := NewServer(backend, "test")
+
+	_, _, err := server.callChangeShoppingListTool(
+		context.Background(),
+		nil,
+		ChangeShoppingListArguments{Action: "update_item", ProductGroupID: "tomato"},
+	)
+	if err == nil {
+		t.Fatal("callChangeShoppingListTool() error = nil, want missing product group error")
+	}
+	if backend.readShoppingListCalls != 1 {
+		t.Fatalf("read shopping-list calls = %d, want 1", backend.readShoppingListCalls)
+	}
+	if backend.updateShoppingListCalls != 0 {
+		t.Fatalf("update shopping-list calls = %d, want none", backend.updateShoppingListCalls)
+	}
+}
+
+func TestCallToolACIDToolsChangeShoppingListTool4RequiresUpdateItemProductGroupID(t *testing.T) {
+	tests := []struct {
+		name      string
+		arguments ChangeShoppingListArguments
+	}{
+		{name: "missing", arguments: ChangeShoppingListArguments{Action: "update_item"}},
+		{name: "blank", arguments: ChangeShoppingListArguments{Action: "update_item", ProductGroupID: "   "}},
+	}
+
+	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_id error")
+			}
+			if backend.readShoppingListCalls != 0 {
+				t.Fatalf("read shopping-list calls = %d, want none", backend.readShoppingListCalls)
+			}
+			if backend.updateShoppingListCalls != 0 {
+				t.Fatalf("update shopping-list calls = %d, want none", backend.updateShoppingListCalls)
+			}
+		})
+	}
+}