diff --git a/internal/mcp/server.go b/internal/mcp/server.go index e762288315500fbe1bf76146c29d379bf53e78d8..54de675a6f33b459cd7a523c81394febf7280ef3 100644 --- a/internal/mcp/server.go +++ b/internal/mcp/server.go @@ -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"` } diff --git a/internal/mcp/server_test.go b/internal/mcp/server_test.go index a2000c453df43249b8ac17c5cbe42c3c0c8e103e..99c365f3a9a2c55ce379efe8c97135627ad6ddc0 100644 --- a/internal/mcp/server_test.go +++ b/internal/mcp/server_test.go @@ -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 +}