diff --git a/features/cooked-mcp/shopping-list.feature.yaml b/features/cooked-mcp/shopping-list.feature.yaml index 464a578f09002f8cf4382ec9a1b9f78847695ef7..c0213b9396ee0a56fd0f8414da26a235f6420d15 100644 --- a/features/cooked-mcp/shopping-list.feature.yaml +++ b/features/cooked-mcp/shopping-list.feature.yaml @@ -20,6 +20,7 @@ components: 2: Adding ingredients accepts one or more newline-separated ingredients. 3: Adding ingredients may associate the ingredients with a saved recipe ID or import draft ID. 4: Adding ingredients reports how many ingredients Cooked added. + 5: Adding ingredients accepts simple read-output lines that place a numeric quantity after an em dash separator. UPDATE_ITEM: requirements: 1: The change_shopping_list tool updates a shopping-list product group. diff --git a/internal/mcp/server.go b/internal/mcp/server.go index e08b4b3f3d19affa7cba1b72e87b51be2e9e19a1..536d311c87659eb91bd2992efa9e05c2d155148d 100644 --- a/internal/mcp/server.go +++ b/internal/mcp/server.go @@ -477,13 +477,14 @@ func (s *Server) addShoppingListIngredients( ctx context.Context, arguments ChangeShoppingListArguments, ) (*sdk.CallToolResult, ChangeShoppingListOutput, error) { - if strings.TrimSpace(arguments.Ingredients) == "" { + ingredients := normalizeShoppingListAddIngredients(arguments.Ingredients) + if ingredients == "" { return nil, ChangeShoppingListOutput{}, fmt.Errorf("ingredients is required when action is add") } result, err := s.backend.AddShoppingListIngredients( ctx, - arguments.Ingredients, + ingredients, strings.TrimSpace(arguments.RecipeID), ) if err != nil { @@ -719,7 +720,7 @@ func changeShoppingListTool() *sdk.Tool { return &sdk.Tool{ Name: changeShoppingListToolName, Title: "Change shopping list", - Description: "Change the Cooked shopping list. Supports add, update_item, replace_selection, add_selection, remove_selection, remove, and clear. Add accepts newline-separated ingredients and optional recipe_id. Remove and clear are destructive.", + Description: "Change the Cooked shopping list. Supports add, update_item, replace_selection, add_selection, remove_selection, remove, and clear. Add accepts newline-separated ingredients and optional recipe_id; put quantities before names, such as `1 milk`. Simple read-output lines like `milk — 1` are normalized. Remove and clear are destructive.", Annotations: &sdk.ToolAnnotations{ Title: "Change shopping list", DestructiveHint: &destructive, @@ -754,6 +755,41 @@ func normalizeProductGroupIDs(ids []string) []string { return normalized } +func normalizeShoppingListAddIngredients(raw string) string { + var lines []string + for line := range strings.SplitSeq(raw, "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + + lines = append(lines, normalizeShoppingListAddIngredientLine(line)) + } + + return strings.Join(lines, "\n") +} + +func normalizeShoppingListAddIngredientLine(line string) string { + const separator = " — " + + index := strings.LastIndex(line, separator) + if index < 0 { + return line + } + + name := strings.TrimSpace(line[:index]) + quantity := strings.TrimSpace(line[index+len(separator):]) + if name == "" || quantity == "" || !startsWithDigit(quantity) { + return line + } + + return quantity + " " + name +} + +func startsWithDigit(value string) bool { + return value[0] >= '0' && value[0] <= '9' +} + func selectedProductGroupIDs(shoppingList cooked.ShoppingList) []string { var ids []string for _, aisle := range shoppingList.Aisles { @@ -1127,7 +1163,7 @@ type DeleteRecipeOutput struct { // ChangeShoppingListArguments contains change_shopping_list tool arguments. type ChangeShoppingListArguments struct { Action string `json:"action" jsonschema:"Shopping-list action: add, remove, replace_selection, add_selection, remove_selection, update_item, or clear."` - Ingredients string `json:"ingredients,omitempty" jsonschema:"Newline-separated ingredients for action add."` + Ingredients string `json:"ingredients,omitempty" jsonschema:"Newline-separated ingredients for action add. Put quantities before names, for example 1 milk."` 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."` diff --git a/internal/mcp/server_wording_test.go b/internal/mcp/server_wording_test.go index f684e9a4dee3847f53dbb4d59a9095c93cfc8654..5ac257247421717853d92713dbd555b24b47a443 100644 --- a/internal/mcp/server_wording_test.go +++ b/internal/mcp/server_wording_test.go @@ -75,6 +75,8 @@ func TestToolDescriptionsACIDToolsSchema3And5DescribeCurrentLimits(t *testing.T) "remove", "clear", "Remove and clear are destructive", + "put quantities before names", + "milk — 1", } { if !strings.Contains(shopping.Description, want) { t.Fatalf("change_shopping_list description missing %q: %q", want, shopping.Description) diff --git a/internal/mcp/shopping_list_add_test.go b/internal/mcp/shopping_list_add_test.go new file mode 100644 index 0000000000000000000000000000000000000000..db620d38aea4d21bae1ab6116108ab209202f678 --- /dev/null +++ b/internal/mcp/shopping_list_add_test.go @@ -0,0 +1,36 @@ +// SPDX-FileCopyrightText: Amolith +// +// SPDX-License-Identifier: LicenseRef-MutuaL-1.2 + +package mcp + +import ( + "context" + "testing" + + "git.secluded.site/cooked-mcp/internal/cooked" +) + +func TestCallToolACIDShoppingListAdd5NormalizesReadOutputQuantity(t *testing.T) { + backend := &fakeBackend{addShoppingListResult: cooked.AddShoppingListResult{ + AddedCount: 2, + Ingredients: []string{"1 Tool selection test item", "1 zz tool selection test"}, + }} + server := NewServer(backend, "test") + + _, _, err := server.callChangeShoppingListTool( + context.Background(), + nil, + ChangeShoppingListArguments{ + Action: "add", + Ingredients: "Tool selection test item — 1\n1 zz tool selection test", + }, + ) + if err != nil { + t.Fatalf("callChangeShoppingListTool() error = %v", err) + } + + if backend.addIngredients != "1 Tool selection test item\n1 zz tool selection test" { + t.Fatalf("backend ingredients = %q, want normalized ingredients", backend.addIngredients) + } +}