@@ -339,83 +339,139 @@ func (s *Server) callChangeShoppingListTool(
action := strings.TrimSpace(arguments.Action)
switch action {
case "add":
- if strings.TrimSpace(arguments.Ingredients) == "" {
- return nil, ChangeShoppingListOutput{}, fmt.Errorf("ingredients is required when action is add")
- }
-
- result, err := s.backend.AddShoppingListIngredients(
- ctx,
- arguments.Ingredients,
- strings.TrimSpace(arguments.RecipeID),
+ return s.addShoppingListIngredients(ctx, arguments)
+ case "clear":
+ return s.clearShoppingList(ctx)
+ case "remove":
+ return s.removeShoppingListProductGroups(ctx, arguments)
+ case "replace_selection":
+ return s.replaceShoppingListSelection(ctx, arguments)
+ case "add_selection":
+ return s.addShoppingListSelection(ctx, arguments)
+ case "":
+ return nil, ChangeShoppingListOutput{}, fmt.Errorf("action is required")
+ default:
+ return nil, ChangeShoppingListOutput{}, fmt.Errorf(
+ "unsupported change_shopping_list action %q in this slice",
+ action,
)
- if err != nil {
- return nil, ChangeShoppingListOutput{}, err
- }
+ }
+}
- output := ChangeShoppingListOutput{AddedCount: result.AddedCount, Ingredients: result.Ingredients}
+func (s *Server) addShoppingListIngredients(
+ ctx context.Context,
+ arguments ChangeShoppingListArguments,
+) (*sdk.CallToolResult, ChangeShoppingListOutput, error) {
+ if strings.TrimSpace(arguments.Ingredients) == "" {
+ return nil, ChangeShoppingListOutput{}, fmt.Errorf("ingredients is required when action is add")
+ }
- return &sdk.CallToolResult{
- Content: []sdk.Content{&sdk.TextContent{Text: fmt.Sprintf(
- "Added %d shopping-list ingredients.",
- result.AddedCount,
- )}},
- }, output, nil
- case "clear":
- if err := s.backend.ClearShoppingList(ctx); err != nil {
- return nil, ChangeShoppingListOutput{}, err
- }
+ result, err := s.backend.AddShoppingListIngredients(
+ ctx,
+ arguments.Ingredients,
+ strings.TrimSpace(arguments.RecipeID),
+ )
+ if err != nil {
+ return nil, ChangeShoppingListOutput{}, err
+ }
- output := ChangeShoppingListOutput{Cleared: true}
+ output := ChangeShoppingListOutput{AddedCount: result.AddedCount, Ingredients: result.Ingredients}
- 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")
- }
+ return &sdk.CallToolResult{
+ Content: []sdk.Content{&sdk.TextContent{Text: fmt.Sprintf(
+ "Added %d shopping-list ingredients.",
+ result.AddedCount,
+ )}},
+ }, output, nil
+}
- if err := s.backend.RemoveShoppingListProductGroups(ctx, ids); err != nil {
- return nil, ChangeShoppingListOutput{}, err
- }
+func (s *Server) clearShoppingList(ctx context.Context) (*sdk.CallToolResult, ChangeShoppingListOutput, error) {
+ if err := s.backend.ClearShoppingList(ctx); err != nil {
+ return nil, ChangeShoppingListOutput{}, err
+ }
- output := ChangeShoppingListOutput{RemovedProductGroupIDs: ids}
+ output := ChangeShoppingListOutput{Cleared: true}
- return &sdk.CallToolResult{
- Content: []sdk.Content{&sdk.TextContent{Text: fmt.Sprintf(
- "Removed %d shopping-list product groups.",
- 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",
- )
- }
+ return &sdk.CallToolResult{
+ Content: []sdk.Content{&sdk.TextContent{Text: "Cleared shopping list."}},
+ }, output, nil
+}
- if err := s.backend.ReplaceShoppingListSelection(ctx, ids); err != nil {
- return nil, ChangeShoppingListOutput{}, err
- }
+func (s *Server) removeShoppingListProductGroups(
+ ctx context.Context,
+ arguments ChangeShoppingListArguments,
+) (*sdk.CallToolResult, ChangeShoppingListOutput, error) {
+ ids := normalizeProductGroupIDs(arguments.ProductGroupIDs)
+ if len(ids) == 0 {
+ return nil, ChangeShoppingListOutput{}, fmt.Errorf("product_group_ids is required when action is remove")
+ }
- output := ChangeShoppingListOutput{SelectedProductGroupIDs: ids}
+ if err := s.backend.RemoveShoppingListProductGroups(ctx, ids); err != nil {
+ return nil, ChangeShoppingListOutput{}, err
+ }
- 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:
+ 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
+}
+
+func (s *Server) replaceShoppingListSelection(
+ ctx context.Context,
+ arguments ChangeShoppingListArguments,
+) (*sdk.CallToolResult, ChangeShoppingListOutput, error) {
+ ids := normalizeProductGroupIDs(arguments.ProductGroupIDs)
+ if len(ids) == 0 {
return nil, ChangeShoppingListOutput{}, fmt.Errorf(
- "unsupported change_shopping_list action %q in this slice",
- action,
+ "product_group_ids is required when action is replace_selection",
+ )
+ }
+
+ return s.setShoppingListSelection(ctx, ids)
+}
+
+func (s *Server) addShoppingListSelection(
+ ctx context.Context,
+ arguments ChangeShoppingListArguments,
+) (*sdk.CallToolResult, ChangeShoppingListOutput, error) {
+ ids := normalizeProductGroupIDs(arguments.ProductGroupIDs)
+ if len(ids) == 0 {
+ return nil, ChangeShoppingListOutput{}, fmt.Errorf(
+ "product_group_ids is required when action is add_selection",
)
}
+
+ shoppingList, err := s.backend.ReadShoppingList(ctx)
+ if err != nil {
+ return nil, ChangeShoppingListOutput{}, err
+ }
+
+ return s.setShoppingListSelection(
+ ctx,
+ appendMissingProductGroupIDs(selectedProductGroupIDs(shoppingList), ids),
+ )
+}
+
+func (s *Server) setShoppingListSelection(
+ ctx context.Context,
+ ids []string,
+) (*sdk.CallToolResult, ChangeShoppingListOutput, error) {
+ 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
}
func readTool() *sdk.Tool {
@@ -480,7 +536,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 or replace_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, or add_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,
@@ -515,6 +571,42 @@ func normalizeProductGroupIDs(ids []string) []string {
return normalized
}
+func selectedProductGroupIDs(shoppingList cooked.ShoppingList) []string {
+ var ids []string
+ for _, aisle := range shoppingList.Aisles {
+ for _, product := range aisle.ProductGroups {
+ if product.Selected {
+ ids = append(ids, product.ID)
+ }
+ }
+ }
+
+ return ids
+}
+
+func appendMissingProductGroupIDs(existing, additions []string) []string {
+ seen := make(map[string]struct{}, len(existing)+len(additions))
+ result := make([]string, 0, len(existing)+len(additions))
+
+ for _, id := range existing {
+ if _, ok := seen[id]; ok {
+ continue
+ }
+ seen[id] = struct{}{}
+ result = append(result, id)
+ }
+
+ for _, id := range additions {
+ if _, ok := seen[id]; ok {
+ continue
+ }
+ seen[id] = struct{}{}
+ result = append(result, id)
+ }
+
+ return result
+}
+
func formatShoppingList(shoppingList cooked.ShoppingList) string {
if len(shoppingList.Aisles) == 0 {
return "Shopping list is empty."
@@ -714,10 +806,10 @@ 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, and clear."`
+ Action string `json:"action" jsonschema:"Shopping-list action. Supported now: add, remove, replace_selection, add_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 or replace_selection."`
+ ProductGroupIDs []string `json:"product_group_ids,omitempty" jsonschema:"Product group IDs for action remove, replace_selection, or add_selection."`
}
// ChangeShoppingListOutput is the structured output for change_shopping_list.
@@ -7,6 +7,7 @@ package mcp
import (
"context"
"encoding/json"
+ "slices"
"strings"
"testing"
@@ -755,6 +756,74 @@ func TestCallToolACIDShoppingListSelection7RequiresReplaceSelectionProductGroupI
}
}
+func TestCallToolACIDShoppingListSelection2And4And5And6And9AddsSelectedSet(t *testing.T) {
+ backend := &fakeBackend{shoppingList: cooked.ShoppingList{Aisles: []cooked.Aisle{{
+ ProductGroups: []cooked.ProductGroup{
+ {ID: "pasta", Selected: true},
+ {ID: "tomato"},
+ {ID: "salt", Selected: true},
+ },
+ }}}}
+ server := NewServer(backend, "test")
+
+ _, output, err := server.callChangeShoppingListTool(
+ context.Background(),
+ nil,
+ ChangeShoppingListArguments{
+ Action: " add_selection ",
+ ProductGroupIDs: []string{" tomato ", "pasta", " ", "pepper"},
+ },
+ )
+ 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.replaceSelectionCalls != 1 {
+ t.Fatalf("replace selection calls = %d, want 1", backend.replaceSelectionCalls)
+ }
+ expectedIDs := []string{"pasta", "salt", "tomato", "pepper"}
+ if !slices.Equal(backend.replaceSelectionProductGroupIDs, expectedIDs) {
+ t.Fatalf("backend selected IDs = %#v, want %#v", backend.replaceSelectionProductGroupIDs, expectedIDs)
+ }
+ if !slices.Equal(output.SelectedProductGroupIDs, expectedIDs) {
+ t.Fatalf("output selected IDs = %#v, want %#v", output.SelectedProductGroupIDs, expectedIDs)
+ }
+}
+
+func TestCallToolACIDShoppingListSelection7RequiresAddSelectionProductGroupIDs(t *testing.T) {
+ tests := []struct {
+ name string
+ arguments ChangeShoppingListArguments
+ }{
+ {name: "nil", arguments: ChangeShoppingListArguments{Action: "add_selection"}},
+ {
+ name: "blank",
+ arguments: ChangeShoppingListArguments{Action: "add_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.readShoppingListCalls != 0 {
+ t.Fatalf("read shopping-list calls = %d, want none", backend.readShoppingListCalls)
+ }
+ if backend.replaceSelectionCalls != 0 {
+ t.Fatalf("replace selection calls = %d, want none", backend.replaceSelectionCalls)
+ }
+ })
+ }
+}
+
func TestCallToolACIDShoppingListSafety1RequiresRemoveProductGroupIDs(t *testing.T) {
tests := []struct {
name string
@@ -834,6 +903,7 @@ type fakeBackend struct {
removeProductGroupIDs []string
replaceSelectionProductGroupIDs []string
metadataCalls int
+ readShoppingListCalls int
contentCalls int
previewCalls int
saveCalls int
@@ -846,6 +916,8 @@ type fakeBackend struct {
}
func (f *fakeBackend) ReadShoppingList(context.Context) (cooked.ShoppingList, error) {
+ f.readShoppingListCalls++
+
return f.shoppingList, nil
}