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