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