@@ -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"`
+}
@@ -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
+}