@@ -37,6 +37,7 @@ type Backend interface {
DeleteRecipe(ctx context.Context, recipeID string) error
ClearShoppingList(ctx context.Context) error
AddShoppingListIngredients(ctx context.Context, ingredients, recipeID string) (cooked.AddShoppingListResult, error)
+ RemoveShoppingListProductGroups(ctx context.Context, productGroupIDs []string) error
}
// Server exposes Cooked as an MCP server.
@@ -368,6 +369,24 @@ func (s *Server) callChangeShoppingListTool(
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")
+ }
+
+ if err := s.backend.RemoveShoppingListProductGroups(ctx, ids); err != nil {
+ return nil, ChangeShoppingListOutput{}, err
+ }
+
+ 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
case "":
return nil, ChangeShoppingListOutput{}, fmt.Errorf("action is required")
default:
@@ -440,7 +459,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, and action clear, which is destructive and deletes all shopping-list items. 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 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,
@@ -463,6 +482,18 @@ func normalizeRecipePage(page, limit int) (int, int) {
return page, limit
}
+func normalizeProductGroupIDs(ids []string) []string {
+ normalized := make([]string, 0, len(ids))
+ for _, id := range ids {
+ id = strings.TrimSpace(id)
+ if id != "" {
+ normalized = append(normalized, id)
+ }
+ }
+
+ return normalized
+}
+
func formatShoppingList(shoppingList cooked.ShoppingList) string {
if len(shoppingList.Aisles) == 0 {
return "Shopping list is empty."
@@ -662,14 +693,16 @@ type DeleteRecipeOutput struct {
// ChangeShoppingListArguments contains change_shopping_list tool arguments.
type ChangeShoppingListArguments struct {
- Action string `json:"action" jsonschema:"Shopping-list action. Supported now: add 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."`
+ Action string `json:"action" jsonschema:"Shopping-list action. Supported now: add, remove, 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."`
}
// 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"`
+ Cleared bool `json:"cleared,omitempty"`
+ AddedCount int `json:"added_count,omitempty"`
+ Ingredients []string `json:"ingredients,omitempty"`
+ RemovedProductGroupIDs []string `json:"removed_product_group_ids,omitempty"`
}
@@ -669,6 +669,60 @@ func TestCallToolACIDToolsChangeShoppingListTool2RequiresIngredients(t *testing.
}
}
+func TestCallToolACIDShoppingListRemove1And2RemovesProductGroups(t *testing.T) {
+ backend := &fakeBackend{}
+ server := NewServer(backend, "test")
+
+ _, output, err := server.callChangeShoppingListTool(
+ context.Background(),
+ nil,
+ ChangeShoppingListArguments{
+ Action: " remove ",
+ ProductGroupIDs: []string{" pasta ", "tomato", " "},
+ },
+ )
+ if err != nil {
+ t.Fatalf("callChangeShoppingListTool() error = %v", err)
+ }
+
+ if backend.removeShoppingListCalls != 1 {
+ t.Fatalf("remove calls = %d, want 1", backend.removeShoppingListCalls)
+ }
+ if len(backend.removeProductGroupIDs) != 2 || backend.removeProductGroupIDs[0] != "pasta" ||
+ backend.removeProductGroupIDs[1] != "tomato" {
+ t.Fatalf("backend remove IDs = %#v, want pasta and tomato", backend.removeProductGroupIDs)
+ }
+ if len(output.RemovedProductGroupIDs) != 2 || output.RemovedProductGroupIDs[0] != "pasta" ||
+ output.RemovedProductGroupIDs[1] != "tomato" {
+ t.Fatalf("output removed IDs = %#v, want pasta and tomato", output.RemovedProductGroupIDs)
+ }
+}
+
+func TestCallToolACIDShoppingListSafety1RequiresRemoveProductGroupIDs(t *testing.T) {
+ tests := []struct {
+ name string
+ arguments ChangeShoppingListArguments
+ }{
+ {name: "nil", arguments: ChangeShoppingListArguments{Action: "remove"}},
+ {name: "blank", arguments: ChangeShoppingListArguments{Action: "remove", 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.removeShoppingListCalls != 0 {
+ t.Fatalf("remove calls = %d, want none", backend.removeShoppingListCalls)
+ }
+ })
+ }
+}
+
func TestChangeShoppingListToolACIDToolsAnnotations2And4To6MarksDestructiveOpenWorldTool(t *testing.T) {
tool := changeShoppingListTool()
if tool.Name != changeShoppingListToolName {
@@ -695,39 +749,41 @@ 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
- metadataCalls int
- contentCalls int
- previewCalls int
- saveCalls int
- updateCalls int
- deleteCalls int
- clearShoppingListCalls int
- addShoppingListCalls 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
+ metadataCalls int
+ contentCalls int
+ previewCalls int
+ saveCalls int
+ updateCalls int
+ deleteCalls int
+ clearShoppingListCalls int
+ addShoppingListCalls int
+ removeShoppingListCalls int
}
func (f *fakeBackend) ReadShoppingList(context.Context) (cooked.ShoppingList, error) {
@@ -814,3 +870,10 @@ func (f *fakeBackend) AddShoppingListIngredients(
return f.addShoppingListResult, nil
}
+
+func (f *fakeBackend) RemoveShoppingListProductGroups(_ context.Context, ids []string) error {
+ f.removeProductGroupIDs = ids
+ f.removeShoppingListCalls++
+
+ return nil
+}