tools: clear shopping list

Amolith created

Change summary

internal/mcp/server.go      |  65 +++++++++++++++++-
internal/mcp/server_test.go | 134 ++++++++++++++++++++++++++++++--------
2 files changed, 166 insertions(+), 33 deletions(-)

Detailed changes

internal/mcp/server.go 🔗

@@ -16,11 +16,12 @@ import (
 )
 
 const (
-	serverName                = "Cooked"
-	readToolName              = "read"
-	previewRecipeTextToolName = "preview_recipe_text"
-	saveRecipeToolName        = "save_recipe"
-	deleteRecipeToolName      = "delete_recipe"
+	serverName                 = "Cooked"
+	readToolName               = "read"
+	previewRecipeTextToolName  = "preview_recipe_text"
+	saveRecipeToolName         = "save_recipe"
+	deleteRecipeToolName       = "delete_recipe"
+	changeShoppingListToolName = "change_shopping_list"
 )
 
 // Backend provides the Cooked operations exposed as MCP tools.
@@ -34,6 +35,7 @@ type Backend interface {
 	SavePreparedRecipe(ctx context.Context, title, markdown string, portions int) (string, error)
 	UpdateRecipeContent(ctx context.Context, recipeID, markdown string, portions int) error
 	DeleteRecipe(ctx context.Context, recipeID string) error
+	ClearShoppingList(ctx context.Context) error
 }
 
 // Server exposes Cooked as an MCP server.
@@ -50,6 +52,7 @@ func NewServer(backend Backend, version string) *Server {
 	sdk.AddTool(server.sdk, previewRecipeTextTool(), server.callPreviewRecipeTextTool)
 	sdk.AddTool(server.sdk, saveRecipeTool(), server.callSaveRecipeTool)
 	sdk.AddTool(server.sdk, deleteRecipeTool(), server.callDeleteRecipeTool)
+	sdk.AddTool(server.sdk, changeShoppingListTool(), server.callChangeShoppingListTool)
 
 	return server
 }
@@ -325,6 +328,33 @@ func (s *Server) callDeleteRecipeTool(
 	}, output, nil
 }
 
+func (s *Server) callChangeShoppingListTool(
+	ctx context.Context,
+	_ *sdk.CallToolRequest,
+	arguments ChangeShoppingListArguments,
+) (*sdk.CallToolResult, ChangeShoppingListOutput, error) {
+	action := strings.TrimSpace(arguments.Action)
+	switch action {
+	case "clear":
+		if err := s.backend.ClearShoppingList(ctx); err != nil {
+			return nil, ChangeShoppingListOutput{}, err
+		}
+
+		output := ChangeShoppingListOutput{Cleared: true}
+
+		return &sdk.CallToolResult{
+			Content: []sdk.Content{&sdk.TextContent{Text: "Cleared shopping list."}},
+		}, output, nil
+	case "":
+		return nil, ChangeShoppingListOutput{}, fmt.Errorf("action is required")
+	default:
+		return nil, ChangeShoppingListOutput{}, fmt.Errorf(
+			"unsupported change_shopping_list action %q in this slice",
+			action,
+		)
+	}
+}
+
 func readTool() *sdk.Tool {
 	openWorld := true
 	return &sdk.Tool{
@@ -381,6 +411,21 @@ func deleteRecipeTool() *sdk.Tool {
 	}
 }
 
+func changeShoppingListTool() *sdk.Tool {
+	destructive := true
+	openWorld := true
+	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.",
+		Annotations: &sdk.ToolAnnotations{
+			Title:           "Change shopping list",
+			DestructiveHint: &destructive,
+			OpenWorldHint:   &openWorld,
+		},
+	}
+}
+
 func normalizeRecipePage(page, limit int) (int, int) {
 	if page < 1 {
 		page = 1
@@ -591,3 +636,13 @@ type DeleteRecipeArguments struct {
 type DeleteRecipeOutput struct {
 	RecipeID string `json:"recipe_id"`
 }
+
+// ChangeShoppingListArguments contains change_shopping_list tool arguments.
+type ChangeShoppingListArguments struct {
+	Action string `json:"action" jsonschema:"Shopping-list action. Supported now: clear."`
+}
+
+// ChangeShoppingListOutput is the structured output for change_shopping_list.
+type ChangeShoppingListOutput struct {
+	Cleared bool `json:"cleared,omitempty"`
+}

internal/mcp/server_test.go 🔗

@@ -569,35 +569,107 @@ func TestDeleteRecipeToolACIDToolsAnnotations2To5MarksDestructiveOpenWorldTool(t
 	}
 }
 
+func TestCallToolACIDShoppingListClear1And2ClearsShoppingList(t *testing.T) {
+	backend := &fakeBackend{}
+	server := NewServer(backend, "test")
+
+	_, output, err := server.callChangeShoppingListTool(
+		context.Background(),
+		nil,
+		ChangeShoppingListArguments{Action: " clear "},
+	)
+	if err != nil {
+		t.Fatalf("callChangeShoppingListTool() error = %v", err)
+	}
+
+	if backend.clearShoppingListCalls != 1 {
+		t.Fatalf("clear calls = %d, want 1", backend.clearShoppingListCalls)
+	}
+	if !output.Cleared {
+		t.Fatalf("clear output cleared = %v, want true", output.Cleared)
+	}
+}
+
+func TestCallToolACIDShoppingListActions2RejectsUnknownActionBeforeCallingCooked(t *testing.T) {
+	tests := []struct {
+		name      string
+		arguments ChangeShoppingListArguments
+	}{
+		{name: "empty", arguments: ChangeShoppingListArguments{Action: "   "}},
+		{name: "unknown", arguments: ChangeShoppingListArguments{Action: "add"}},
+	}
+
+	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 action error")
+			}
+			if backend.clearShoppingListCalls != 0 {
+				t.Fatalf("clear calls = %d, want none", backend.clearShoppingListCalls)
+			}
+		})
+	}
+}
+
+func TestChangeShoppingListToolACIDToolsAnnotations2And4To6MarksDestructiveOpenWorldTool(t *testing.T) {
+	tool := changeShoppingListTool()
+	if tool.Name != changeShoppingListToolName {
+		t.Fatalf("tool name = %q, want %q", tool.Name, changeShoppingListToolName)
+	}
+	if tool.Annotations == nil {
+		t.Fatal("tool annotations nil")
+	}
+	if tool.Annotations.ReadOnlyHint {
+		t.Fatal("change_shopping_list read-only hint = true, want false")
+	}
+	if tool.Annotations.DestructiveHint == nil || !*tool.Annotations.DestructiveHint {
+		t.Fatalf("change_shopping_list destructive hint = %#v, want true", tool.Annotations.DestructiveHint)
+	}
+	if tool.Annotations.OpenWorldHint == nil || !*tool.Annotations.OpenWorldHint {
+		t.Fatalf("change_shopping_list open-world hint = %#v, want true", tool.Annotations.OpenWorldHint)
+	}
+	if tool.Annotations.Title == "" {
+		t.Fatal("change_shopping_list annotation title empty")
+	}
+	if !strings.Contains(tool.Description, "clear") || !strings.Contains(tool.Description, "destructive") {
+		t.Fatalf("change_shopping_list description = %q, want clear/destructive warning", tool.Description)
+	}
+}
+
 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
-	metadataCalls    int
-	contentCalls     int
-	previewCalls     int
-	saveCalls        int
-	updateCalls      int
-	deleteCalls      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
+	metadataCalls          int
+	contentCalls           int
+	previewCalls           int
+	saveCalls              int
+	updateCalls            int
+	deleteCalls            int
+	clearShoppingListCalls int
 }
 
 func (f *fakeBackend) ReadShoppingList(context.Context) (cooked.ShoppingList, error) {
@@ -667,3 +739,9 @@ func (f *fakeBackend) DeleteRecipe(_ context.Context, recipeID string) error {
 
 	return nil
 }
+
+func (f *fakeBackend) ClearShoppingList(context.Context) error {
+	f.clearShoppingListCalls++
+
+	return nil
+}