diff --git a/internal/mcp/server.go b/internal/mcp/server.go index 51dbd9411d0c6ae06f56a39e04d32dba9f00286e..e762288315500fbe1bf76146c29d379bf53e78d8 100644 --- a/internal/mcp/server.go +++ b/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"` } diff --git a/internal/mcp/server_test.go b/internal/mcp/server_test.go index 010f2173a43df79b3bf8002c96576731867cad7d..a2000c453df43249b8ac17c5cbe42c3c0c8e103e 100644 --- a/internal/mcp/server_test.go +++ b/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 +}