@@ -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"`
}
@@ -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)
+ }
+ })
+ }
+}