From 2fa48476e606f9b8e21b66d2c3adda6b5c536c2f Mon Sep 17 00:00:00 2001 From: Amolith Date: Wed, 10 Jun 2026 16:26:30 -0600 Subject: [PATCH] tools: update shopping-list item --- 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(-) create mode 100644 internal/mcp/shopping_list_update_test.go diff --git a/internal/mcp/server.go b/internal/mcp/server.go index 4146efd5be08ff58b2a4eae3dcd863aa63dcd578..982c4d44715002b3c6fc66e38344ed94628f32f1 100644 --- a/internal/mcp/server.go +++ b/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"` } diff --git a/internal/mcp/server_test.go b/internal/mcp/server_test.go index b81c2be2372027634b3d25c1b1c745238b7b01ea..287b143280a98e9f7e2e749e3d682aa10675f1fe 100644 --- a/internal/mcp/server_test.go +++ b/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 +} diff --git a/internal/mcp/shopping_list_update_test.go b/internal/mcp/shopping_list_update_test.go new file mode 100644 index 0000000000000000000000000000000000000000..77242fd1176898146d37268935bace7608535657 --- /dev/null +++ b/internal/mcp/shopping_list_update_test.go @@ -0,0 +1,114 @@ +// SPDX-FileCopyrightText: Amolith +// +// 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) + } + }) + } +}