tools: delete recipe

Amolith created

Change summary

internal/mcp/server.go      | 53 ++++++++++++++++++++++++++++++
internal/mcp/server_test.go | 68 +++++++++++++++++++++++++++++++++++++++
2 files changed, 121 insertions(+)

Detailed changes

internal/mcp/server.go 🔗

@@ -20,6 +20,7 @@ const (
 	readToolName              = "read"
 	previewRecipeTextToolName = "preview_recipe_text"
 	saveRecipeToolName        = "save_recipe"
+	deleteRecipeToolName      = "delete_recipe"
 )
 
 // Backend provides the Cooked operations exposed as MCP tools.
@@ -32,6 +33,7 @@ type Backend interface {
 	PreviewRecipeText(ctx context.Context, title, text string) (cooked.RecipeTextPreview, error)
 	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
 }
 
 // Server exposes Cooked as an MCP server.
@@ -47,6 +49,7 @@ func NewServer(backend Backend, version string) *Server {
 	sdk.AddTool(server.sdk, readTool(), server.callReadTool)
 	sdk.AddTool(server.sdk, previewRecipeTextTool(), server.callPreviewRecipeTextTool)
 	sdk.AddTool(server.sdk, saveRecipeTool(), server.callSaveRecipeTool)
+	sdk.AddTool(server.sdk, deleteRecipeTool(), server.callDeleteRecipeTool)
 
 	return server
 }
@@ -301,6 +304,27 @@ func newRecipeSaveResult(recipeID string) *sdk.CallToolResult {
 	}
 }
 
+func (s *Server) callDeleteRecipeTool(
+	ctx context.Context,
+	_ *sdk.CallToolRequest,
+	arguments DeleteRecipeArguments,
+) (*sdk.CallToolResult, DeleteRecipeOutput, error) {
+	recipeID := strings.TrimSpace(arguments.RecipeID)
+	if recipeID == "" {
+		return nil, DeleteRecipeOutput{}, fmt.Errorf("recipe_id is required")
+	}
+
+	if err := s.backend.DeleteRecipe(ctx, recipeID); err != nil {
+		return nil, DeleteRecipeOutput{}, err
+	}
+
+	output := DeleteRecipeOutput{RecipeID: recipeID}
+
+	return &sdk.CallToolResult{
+		Content: []sdk.Content{&sdk.TextContent{Text: formatRecipeDelete(output)}},
+	}, output, nil
+}
+
 func readTool() *sdk.Tool {
 	openWorld := true
 	return &sdk.Tool{
@@ -342,6 +366,21 @@ func saveRecipeTool() *sdk.Tool {
 	}
 }
 
+func deleteRecipeTool() *sdk.Tool {
+	destructive := true
+	openWorld := true
+	return &sdk.Tool{
+		Name:        deleteRecipeToolName,
+		Title:       "Delete recipe",
+		Description: "Delete a saved Cooked recipe by recipe_id. This is destructive.",
+		Annotations: &sdk.ToolAnnotations{
+			Title:           "Delete recipe",
+			DestructiveHint: &destructive,
+			OpenWorldHint:   &openWorld,
+		},
+	}
+}
+
 func normalizeRecipePage(page, limit int) (int, int) {
 	if page < 1 {
 		page = 1
@@ -469,6 +508,10 @@ func formatRecipeSave(output SaveRecipeOutput) string {
 	return "Saved recipe (id: " + output.RecipeID + ")."
 }
 
+func formatRecipeDelete(output DeleteRecipeOutput) string {
+	return "Deleted recipe (id: " + output.RecipeID + ")."
+}
+
 func recipeSummaries(recipes []cooked.RecipeCard) []RecipeSummary {
 	summaries := make([]RecipeSummary, 0, len(recipes))
 	for _, recipe := range recipes {
@@ -538,3 +581,13 @@ type SaveRecipeArguments struct {
 type SaveRecipeOutput struct {
 	RecipeID string `json:"recipe_id,omitempty"`
 }
+
+// DeleteRecipeArguments contains delete_recipe tool arguments.
+type DeleteRecipeArguments struct {
+	RecipeID string `json:"recipe_id" jsonschema:"Recipe ID to delete."`
+}
+
+// DeleteRecipeOutput is the structured output for delete_recipe.
+type DeleteRecipeOutput struct {
+	RecipeID string `json:"recipe_id"`
+}

internal/mcp/server_test.go 🔗

@@ -510,6 +510,65 @@ func TestCallToolACIDRecipesSave8_1To8_3RequiresExistingFields(t *testing.T) {
 	}
 }
 
+func TestCallToolACIDRecipesDelete1And2DeletesRecipe(t *testing.T) {
+	backend := &fakeBackend{}
+	server := NewServer(backend, "test")
+
+	_, output, err := server.callDeleteRecipeTool(
+		context.Background(),
+		nil,
+		DeleteRecipeArguments{RecipeID: " recipe-1 "},
+	)
+	if err != nil {
+		t.Fatalf("callDeleteRecipeTool() error = %v", err)
+	}
+
+	if backend.deleteCalls != 1 {
+		t.Fatalf("delete calls = %d, want 1", backend.deleteCalls)
+	}
+	if backend.deleteRecipeID != "recipe-1" {
+		t.Fatalf("backend delete recipe ID = %q, want recipe-1", backend.deleteRecipeID)
+	}
+	if output.RecipeID != "recipe-1" {
+		t.Fatalf("delete output recipe ID = %q, want recipe-1", output.RecipeID)
+	}
+}
+
+func TestCallToolACIDToolsDeleteRecipeTool1RequiresRecipeID(t *testing.T) {
+	backend := &fakeBackend{}
+	server := NewServer(backend, "test")
+
+	_, _, err := server.callDeleteRecipeTool(context.Background(), nil, DeleteRecipeArguments{RecipeID: "   "})
+	if err == nil {
+		t.Fatal("callDeleteRecipeTool() error = nil, want missing recipe_id error")
+	}
+	if backend.deleteCalls != 0 {
+		t.Fatalf("delete calls = %d, want none", backend.deleteCalls)
+	}
+}
+
+func TestDeleteRecipeToolACIDToolsAnnotations2To5MarksDestructiveOpenWorldTool(t *testing.T) {
+	tool := deleteRecipeTool()
+	if tool.Name != deleteRecipeToolName {
+		t.Fatalf("tool name = %q, want %q", tool.Name, deleteRecipeToolName)
+	}
+	if tool.Annotations == nil {
+		t.Fatal("tool annotations nil")
+	}
+	if tool.Annotations.ReadOnlyHint {
+		t.Fatal("delete_recipe read-only hint = true, want false")
+	}
+	if tool.Annotations.DestructiveHint == nil || !*tool.Annotations.DestructiveHint {
+		t.Fatalf("delete_recipe destructive hint = %#v, want true", tool.Annotations.DestructiveHint)
+	}
+	if tool.Annotations.OpenWorldHint == nil || !*tool.Annotations.OpenWorldHint {
+		t.Fatalf("delete_recipe open-world hint = %#v, want true", tool.Annotations.OpenWorldHint)
+	}
+	if tool.Annotations.Title == "" {
+		t.Fatal("delete_recipe annotation title empty")
+	}
+}
+
 type fakeBackend struct {
 	shoppingList     cooked.ShoppingList
 	recipes          []cooked.RecipeCard
@@ -532,11 +591,13 @@ type fakeBackend struct {
 	updateRecipeID   string
 	updateMarkdown   string
 	updatePortions   int
+	deleteRecipeID   string
 	metadataCalls    int
 	contentCalls     int
 	previewCalls     int
 	saveCalls        int
 	updateCalls      int
+	deleteCalls      int
 }
 
 func (f *fakeBackend) ReadShoppingList(context.Context) (cooked.ShoppingList, error) {
@@ -599,3 +660,10 @@ func (f *fakeBackend) UpdateRecipeContent(_ context.Context, recipeID, markdown
 
 	return nil
 }
+
+func (f *fakeBackend) DeleteRecipe(_ context.Context, recipeID string) error {
+	f.deleteRecipeID = recipeID
+	f.deleteCalls++
+
+	return nil
+}