mcp: normalize shopping-list add quantities

Amolith created

Change summary

features/cooked-mcp/shopping-list.feature.yaml |  1 
internal/mcp/server.go                         | 44 ++++++++++++++++++-
internal/mcp/server_wording_test.go            |  2 
internal/mcp/shopping_list_add_test.go         | 36 ++++++++++++++++
4 files changed, 79 insertions(+), 4 deletions(-)

Detailed changes

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.

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."`

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)

internal/mcp/shopping_list_add_test.go 🔗

@@ -0,0 +1,36 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+//
+// 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)
+	}
+}