tools: add shopping-list ingredients

Amolith created

Change summary

internal/mcp/server.go      | 33 ++++++++++++++++-
internal/mcp/server_test.go | 71 ++++++++++++++++++++++++++++++++++++++
2 files changed, 100 insertions(+), 4 deletions(-)

Detailed changes

internal/mcp/server.go 🔗

@@ -36,6 +36,7 @@ type Backend interface {
 	UpdateRecipeContent(ctx context.Context, recipeID, markdown string, portions int) error
 	DeleteRecipe(ctx context.Context, recipeID string) error
 	ClearShoppingList(ctx context.Context) error
+	AddShoppingListIngredients(ctx context.Context, ingredients, recipeID string) (cooked.AddShoppingListResult, error)
 }
 
 // Server exposes Cooked as an MCP server.
@@ -335,6 +336,28 @@ func (s *Server) callChangeShoppingListTool(
 ) (*sdk.CallToolResult, ChangeShoppingListOutput, error) {
 	action := strings.TrimSpace(arguments.Action)
 	switch action {
+	case "add":
+		if strings.TrimSpace(arguments.Ingredients) == "" {
+			return nil, ChangeShoppingListOutput{}, fmt.Errorf("ingredients is required when action is add")
+		}
+
+		result, err := s.backend.AddShoppingListIngredients(
+			ctx,
+			arguments.Ingredients,
+			strings.TrimSpace(arguments.RecipeID),
+		)
+		if err != nil {
+			return nil, ChangeShoppingListOutput{}, err
+		}
+
+		output := ChangeShoppingListOutput{AddedCount: result.AddedCount, Ingredients: result.Ingredients}
+
+		return &sdk.CallToolResult{
+			Content: []sdk.Content{&sdk.TextContent{Text: fmt.Sprintf(
+				"Added %d shopping-list ingredients.",
+				result.AddedCount,
+			)}},
+		}, output, nil
 	case "clear":
 		if err := s.backend.ClearShoppingList(ctx); err != nil {
 			return nil, ChangeShoppingListOutput{}, err
@@ -417,7 +440,7 @@ func changeShoppingListTool() *sdk.Tool {
 	return &sdk.Tool{
 		Name:        changeShoppingListToolName,
 		Title:       "Change shopping list",
-		Description: "Change the Cooked shopping list. This slice supports only 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, and action clear, which is destructive and deletes all shopping-list items. Other actions are reserved for later slices.",
 		Annotations: &sdk.ToolAnnotations{
 			Title:           "Change shopping list",
 			DestructiveHint: &destructive,
@@ -639,10 +662,14 @@ type DeleteRecipeOutput struct {
 
 // ChangeShoppingListArguments contains change_shopping_list tool arguments.
 type ChangeShoppingListArguments struct {
-	Action string `json:"action" jsonschema:"Shopping-list action. Supported now: clear."`
+	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."`
 }
 
 // ChangeShoppingListOutput is the structured output for change_shopping_list.
 type ChangeShoppingListOutput struct {
-	Cleared bool `json:"cleared,omitempty"`
+	Cleared     bool     `json:"cleared,omitempty"`
+	AddedCount  int      `json:"added_count,omitempty"`
+	Ingredients []string `json:"ingredients,omitempty"`
 }

internal/mcp/server_test.go 🔗

@@ -596,7 +596,7 @@ func TestCallToolACIDShoppingListActions2RejectsUnknownActionBeforeCallingCooked
 		arguments ChangeShoppingListArguments
 	}{
 		{name: "empty", arguments: ChangeShoppingListArguments{Action: "   "}},
-		{name: "unknown", arguments: ChangeShoppingListArguments{Action: "add"}},
+		{name: "unknown", arguments: ChangeShoppingListArguments{Action: "dance"}},
 	}
 
 	for _, tt := range tests {
@@ -615,6 +615,60 @@ func TestCallToolACIDShoppingListActions2RejectsUnknownActionBeforeCallingCooked
 	}
 }
 
+func TestCallToolACIDShoppingListAdd1To4AddsIngredients(t *testing.T) {
+	backend := &fakeBackend{addShoppingListResult: cooked.AddShoppingListResult{
+		AddedCount:  2,
+		Ingredients: []string{"200g pasta", "1 cup tomato sauce"},
+	}}
+	server := NewServer(backend, "test")
+
+	_, output, err := server.callChangeShoppingListTool(
+		context.Background(),
+		nil,
+		ChangeShoppingListArguments{
+			Action:      " add ",
+			Ingredients: "200g pasta\n1 cup tomato sauce",
+			RecipeID:    " recipe-1 ",
+		},
+	)
+	if err != nil {
+		t.Fatalf("callChangeShoppingListTool() error = %v", err)
+	}
+
+	if backend.addShoppingListCalls != 1 {
+		t.Fatalf("add calls = %d, want 1", backend.addShoppingListCalls)
+	}
+	if backend.addIngredients != "200g pasta\n1 cup tomato sauce" {
+		t.Fatalf("backend ingredients = %q, want raw multiline ingredients", backend.addIngredients)
+	}
+	if backend.addRecipeID != "recipe-1" {
+		t.Fatalf("backend recipe ID = %q, want recipe-1", backend.addRecipeID)
+	}
+	if output.AddedCount != 2 {
+		t.Fatalf("output added count = %d, want 2", output.AddedCount)
+	}
+	if len(output.Ingredients) != 2 || output.Ingredients[0] != "200g pasta" {
+		t.Fatalf("output ingredients = %#v, want added ingredients", output.Ingredients)
+	}
+}
+
+func TestCallToolACIDToolsChangeShoppingListTool2RequiresIngredients(t *testing.T) {
+	backend := &fakeBackend{}
+	server := NewServer(backend, "test")
+
+	_, _, err := server.callChangeShoppingListTool(
+		context.Background(),
+		nil,
+		ChangeShoppingListArguments{Action: "add", Ingredients: "   "},
+	)
+	if err == nil {
+		t.Fatal("callChangeShoppingListTool() error = nil, want missing ingredients error")
+	}
+	if backend.addShoppingListCalls != 0 {
+		t.Fatalf("add calls = %d, want none", backend.addShoppingListCalls)
+	}
+}
+
 func TestChangeShoppingListToolACIDToolsAnnotations2And4To6MarksDestructiveOpenWorldTool(t *testing.T) {
 	tool := changeShoppingListTool()
 	if tool.Name != changeShoppingListToolName {
@@ -663,6 +717,9 @@ type fakeBackend struct {
 	updateMarkdown         string
 	updatePortions         int
 	deleteRecipeID         string
+	addShoppingListResult  cooked.AddShoppingListResult
+	addIngredients         string
+	addRecipeID            string
 	metadataCalls          int
 	contentCalls           int
 	previewCalls           int
@@ -670,6 +727,7 @@ type fakeBackend struct {
 	updateCalls            int
 	deleteCalls            int
 	clearShoppingListCalls int
+	addShoppingListCalls   int
 }
 
 func (f *fakeBackend) ReadShoppingList(context.Context) (cooked.ShoppingList, error) {
@@ -745,3 +803,14 @@ func (f *fakeBackend) ClearShoppingList(context.Context) error {
 
 	return nil
 }
+
+func (f *fakeBackend) AddShoppingListIngredients(
+	_ context.Context,
+	ingredients, recipeID string,
+) (cooked.AddShoppingListResult, error) {
+	f.addIngredients = ingredients
+	f.addRecipeID = recipeID
+	f.addShoppingListCalls++
+
+	return f.addShoppingListResult, nil
+}