diff --git a/internal/mcp/server.go b/internal/mcp/server.go index 9d705dc5ab85d1d3567dd18c00a6964c93d366ae..be1b3e51d19c055f65e5a8c8568c32b75e5439da 100644 --- a/internal/mcp/server.go +++ b/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"` +} diff --git a/internal/mcp/server_test.go b/internal/mcp/server_test.go index ea4c72ae4b246ddac41aa57742620379db75f63f..3e3605105bcf6e1b6e937eaf63047254e43472cb 100644 --- a/internal/mcp/server_test.go +++ b/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 +}