diff --git a/internal/mcp/server.go b/internal/mcp/server.go index 54de675a6f33b459cd7a523c81394febf7280ef3..0c25bd2880e90a7f2341ed2a2d7c508e1da1e7c2 100644 --- a/internal/mcp/server.go +++ b/internal/mcp/server.go @@ -38,6 +38,7 @@ type Backend interface { ClearShoppingList(ctx context.Context) error AddShoppingListIngredients(ctx context.Context, ingredients, recipeID string) (cooked.AddShoppingListResult, error) RemoveShoppingListProductGroups(ctx context.Context, productGroupIDs []string) error + ReplaceShoppingListSelection(ctx context.Context, productGroupIDs []string) error } // Server exposes Cooked as an MCP server. @@ -387,6 +388,26 @@ func (s *Server) callChangeShoppingListTool( len(ids), )}}, }, output, nil + case "replace_selection": + ids := normalizeProductGroupIDs(arguments.ProductGroupIDs) + if len(ids) == 0 { + return nil, ChangeShoppingListOutput{}, fmt.Errorf( + "product_group_ids is required when action is replace_selection", + ) + } + + if err := s.backend.ReplaceShoppingListSelection(ctx, ids); err != nil { + return nil, ChangeShoppingListOutput{}, err + } + + output := ChangeShoppingListOutput{SelectedProductGroupIDs: ids} + + return &sdk.CallToolResult{ + Content: []sdk.Content{&sdk.TextContent{Text: fmt.Sprintf( + "Selected %d shopping-list product groups.", + len(ids), + )}}, + }, output, nil case "": return nil, ChangeShoppingListOutput{}, fmt.Errorf("action is required") default: @@ -459,7 +480,7 @@ func changeShoppingListTool() *sdk.Tool { return &sdk.Tool{ Name: changeShoppingListToolName, Title: "Change shopping list", - Description: "Change the Cooked shopping list. This slice supports action add with newline-separated ingredients and optional recipe_id, action remove with product_group_ids, and action clear. Remove and clear are destructive. 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, action remove or replace_selection with product_group_ids, and action clear. Remove and clear are destructive. Other actions are reserved for later slices.", Annotations: &sdk.ToolAnnotations{ Title: "Change shopping list", DestructiveHint: &destructive, @@ -693,16 +714,17 @@ type DeleteRecipeOutput struct { // ChangeShoppingListArguments contains change_shopping_list tool arguments. type ChangeShoppingListArguments struct { - Action string `json:"action" jsonschema:"Shopping-list action. Supported now: add, remove, and clear."` + Action string `json:"action" jsonschema:"Shopping-list action. Supported now: add, remove, replace_selection, 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."` - ProductGroupIDs []string `json:"product_group_ids,omitempty" jsonschema:"Product group IDs for action remove."` + ProductGroupIDs []string `json:"product_group_ids,omitempty" jsonschema:"Product group IDs for action remove or replace_selection."` } // ChangeShoppingListOutput is the structured output for change_shopping_list. type ChangeShoppingListOutput struct { - Cleared bool `json:"cleared,omitempty"` - AddedCount int `json:"added_count,omitempty"` - Ingredients []string `json:"ingredients,omitempty"` - RemovedProductGroupIDs []string `json:"removed_product_group_ids,omitempty"` + Cleared bool `json:"cleared,omitempty"` + AddedCount int `json:"added_count,omitempty"` + Ingredients []string `json:"ingredients,omitempty"` + RemovedProductGroupIDs []string `json:"removed_product_group_ids,omitempty"` + SelectedProductGroupIDs []string `json:"selected_product_group_ids,omitempty"` } diff --git a/internal/mcp/server_test.go b/internal/mcp/server_test.go index 99c365f3a9a2c55ce379efe8c97135627ad6ddc0..712b5a09110679151784224dd4f3a45fadeaa4bd 100644 --- a/internal/mcp/server_test.go +++ b/internal/mcp/server_test.go @@ -698,6 +698,63 @@ func TestCallToolACIDShoppingListRemove1And2RemovesProductGroups(t *testing.T) { } } +func TestCallToolACIDShoppingListSelection1And5And6And8ReplacesSelectedSet(t *testing.T) { + backend := &fakeBackend{} + server := NewServer(backend, "test") + + _, output, err := server.callChangeShoppingListTool( + context.Background(), + nil, + ChangeShoppingListArguments{ + Action: " replace_selection ", + ProductGroupIDs: []string{" pasta ", "tomato", " "}, + }, + ) + if err != nil { + t.Fatalf("callChangeShoppingListTool() error = %v", err) + } + + if backend.replaceSelectionCalls != 1 { + t.Fatalf("replace selection calls = %d, want 1", backend.replaceSelectionCalls) + } + if len(backend.replaceSelectionProductGroupIDs) != 2 || backend.replaceSelectionProductGroupIDs[0] != "pasta" || + backend.replaceSelectionProductGroupIDs[1] != "tomato" { + t.Fatalf("backend selected IDs = %#v, want pasta and tomato", backend.replaceSelectionProductGroupIDs) + } + if len(output.SelectedProductGroupIDs) != 2 || output.SelectedProductGroupIDs[0] != "pasta" || + output.SelectedProductGroupIDs[1] != "tomato" { + t.Fatalf("output selected IDs = %#v, want pasta and tomato", output.SelectedProductGroupIDs) + } +} + +func TestCallToolACIDShoppingListSelection7RequiresReplaceSelectionProductGroupIDs(t *testing.T) { + tests := []struct { + name string + arguments ChangeShoppingListArguments + }{ + {name: "nil", arguments: ChangeShoppingListArguments{Action: "replace_selection"}}, + { + name: "blank", + arguments: ChangeShoppingListArguments{Action: "replace_selection", ProductGroupIDs: []string{" "}}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + backend := &fakeBackend{} + server := NewServer(backend, "test") + + _, _, err := server.callChangeShoppingListTool(context.Background(), nil, tt.arguments) + if err == nil { + t.Fatal("callChangeShoppingListTool() error = nil, want missing product_group_ids error") + } + if backend.replaceSelectionCalls != 0 { + t.Fatalf("replace selection calls = %d, want none", backend.replaceSelectionCalls) + } + }) + } +} + func TestCallToolACIDShoppingListSafety1RequiresRemoveProductGroupIDs(t *testing.T) { tests := []struct { name string @@ -749,41 +806,43 @@ func TestChangeShoppingListToolACIDToolsAnnotations2And4To6MarksDestructiveOpenW } type fakeBackend struct { - shoppingList cooked.ShoppingList - recipes []cooked.RecipeCard - searchRecipes []cooked.RecipeCard - recipeMetadata cooked.RecipeMetadata - recipeContent cooked.RecipeContent - preview cooked.RecipeTextPreview - saveRecipeID string - page int - limit int - searchQuery string - searchPage int - metadataRecipeID string - contentRecipeID string - previewTitle string - previewText string - saveTitle string - saveMarkdown string - savePortions int - updateRecipeID string - updateMarkdown string - updatePortions int - deleteRecipeID string - addShoppingListResult cooked.AddShoppingListResult - addIngredients string - addRecipeID string - removeProductGroupIDs []string - metadataCalls int - contentCalls int - previewCalls int - saveCalls int - updateCalls int - deleteCalls int - clearShoppingListCalls int - addShoppingListCalls int - removeShoppingListCalls int + shoppingList cooked.ShoppingList + recipes []cooked.RecipeCard + searchRecipes []cooked.RecipeCard + recipeMetadata cooked.RecipeMetadata + recipeContent cooked.RecipeContent + preview cooked.RecipeTextPreview + saveRecipeID string + page int + limit int + searchQuery string + searchPage int + metadataRecipeID string + contentRecipeID string + previewTitle string + previewText string + saveTitle string + saveMarkdown string + savePortions int + updateRecipeID string + updateMarkdown string + updatePortions int + deleteRecipeID string + addShoppingListResult cooked.AddShoppingListResult + addIngredients string + addRecipeID string + removeProductGroupIDs []string + replaceSelectionProductGroupIDs []string + metadataCalls int + contentCalls int + previewCalls int + saveCalls int + updateCalls int + deleteCalls int + clearShoppingListCalls int + addShoppingListCalls int + removeShoppingListCalls int + replaceSelectionCalls int } func (f *fakeBackend) ReadShoppingList(context.Context) (cooked.ShoppingList, error) { @@ -877,3 +936,10 @@ func (f *fakeBackend) RemoveShoppingListProductGroups(_ context.Context, ids []s return nil } + +func (f *fakeBackend) ReplaceShoppingListSelection(_ context.Context, ids []string) error { + f.replaceSelectionProductGroupIDs = ids + f.replaceSelectionCalls++ + + return nil +}