@@ -33,6 +33,7 @@ type Backend interface {
ReadRecipeContent(ctx context.Context, recipeID string) (cooked.RecipeContent, error)
PreviewRecipeText(ctx context.Context, title, text string) (cooked.RecipeTextPreview, error)
SavePreparedRecipe(ctx context.Context, title, markdown string, portions int) (string, error)
+ SaveRecipeDraft(ctx context.Context, draftID, markdown string, portions int) (string, error)
UpdateRecipeContent(ctx context.Context, recipeID, markdown string, portions int) error
ImportRecipeURL(ctx context.Context, recipeURL string) (cooked.RecipeURLImport, error)
DeleteRecipe(ctx context.Context, recipeID string) error
@@ -227,6 +228,8 @@ func (s *Server) callSaveRecipeTool(
return s.callPreparedSaveRecipeTool(ctx, arguments)
case "url":
return s.callURLSaveRecipeTool(ctx, arguments)
+ case "draft":
+ return s.callDraftSaveRecipeTool(ctx, arguments)
case "existing":
return s.callExistingSaveRecipeTool(ctx, arguments)
case "":
@@ -274,11 +277,8 @@ func (s *Server) callPreparedSaveRecipeTool(
if title == "" {
return nil, SaveRecipeOutput{}, fmt.Errorf("title is required when source is prepared")
}
- if strings.TrimSpace(arguments.Markdown) == "" {
- return nil, SaveRecipeOutput{}, fmt.Errorf("markdown is required when source is prepared")
- }
- if arguments.Portions < 1 {
- return nil, SaveRecipeOutput{}, fmt.Errorf("portions is required when source is prepared")
+ if err := validateSaveRecipeContent("prepared", arguments.Markdown, arguments.Portions); err != nil {
+ return nil, SaveRecipeOutput{}, err
}
return s.savePreparedRecipe(ctx, title, arguments.Markdown, arguments.Portions)
@@ -325,6 +325,29 @@ func (s *Server) callURLSaveRecipeTool(
}, output, nil
}
+func (s *Server) callDraftSaveRecipeTool(
+ ctx context.Context,
+ arguments SaveRecipeArguments,
+) (*sdk.CallToolResult, SaveRecipeOutput, error) {
+ draftID := strings.TrimSpace(arguments.DraftID)
+ if draftID == "" {
+ return nil, SaveRecipeOutput{}, fmt.Errorf("draft_id is required when source is draft")
+ }
+ if err := validateSaveRecipeContent("draft", arguments.Markdown, arguments.Portions); err != nil {
+ return nil, SaveRecipeOutput{}, err
+ }
+
+ recipeID, err := s.backend.SaveRecipeDraft(ctx, draftID, arguments.Markdown, arguments.Portions)
+ if err != nil {
+ return nil, SaveRecipeOutput{}, err
+ }
+ if strings.TrimSpace(recipeID) == "" {
+ return nil, SaveRecipeOutput{}, fmt.Errorf("save recipe draft response missing recipe ID")
+ }
+
+ return newRecipeSaveResult(recipeID), SaveRecipeOutput{RecipeID: recipeID}, nil
+}
+
func (s *Server) callExistingSaveRecipeTool(
ctx context.Context,
arguments SaveRecipeArguments,
@@ -333,11 +356,8 @@ func (s *Server) callExistingSaveRecipeTool(
if recipeID == "" {
return nil, SaveRecipeOutput{}, fmt.Errorf("recipe_id is required when source is existing")
}
- if strings.TrimSpace(arguments.Markdown) == "" {
- return nil, SaveRecipeOutput{}, fmt.Errorf("markdown is required when source is existing")
- }
- if arguments.Portions < 1 {
- return nil, SaveRecipeOutput{}, fmt.Errorf("portions is required when source is existing")
+ if err := validateSaveRecipeContent("existing", arguments.Markdown, arguments.Portions); err != nil {
+ return nil, SaveRecipeOutput{}, err
}
if err := s.backend.UpdateRecipeContent(ctx, recipeID, arguments.Markdown, arguments.Portions); err != nil {
@@ -347,6 +367,17 @@ func (s *Server) callExistingSaveRecipeTool(
return newRecipeSaveResult(recipeID), SaveRecipeOutput{RecipeID: recipeID}, nil
}
+func validateSaveRecipeContent(source, markdown string, portions int) error {
+ if strings.TrimSpace(markdown) == "" {
+ return fmt.Errorf("markdown is required when source is %s", source)
+ }
+ if portions < 1 {
+ return fmt.Errorf("portions is required when source is %s", source)
+ }
+
+ return nil
+}
+
func (s *Server) savePreparedRecipe(
ctx context.Context,
title, markdown string,
@@ -651,7 +682,7 @@ func saveRecipeTool() *sdk.Tool {
return &sdk.Tool{
Name: saveRecipeToolName,
Title: "Save recipe",
- Description: "Save a recipe. This slice supports source raw_text with title and text, source prepared with title, markdown, and portions, source url with url, and source existing with recipe_id, markdown, and portions. Other source values are reserved for later slices.",
+ Description: "Save a recipe. This slice supports source raw_text with title and text, source prepared with title, markdown, and portions, source url with url, source draft with draft_id, markdown, and portions, and source existing with recipe_id, markdown, and portions.",
Annotations: &sdk.ToolAnnotations{
Title: "Save recipe",
OpenWorldHint: &openWorld,
@@ -985,13 +1016,14 @@ type PreviewRecipeTextOutput struct {
// SaveRecipeArguments contains save_recipe tool arguments.
type SaveRecipeArguments struct {
- Source string `json:"source" jsonschema:"Recipe save source. Supported now: raw_text, prepared, url, and existing."`
+ Source string `json:"source" jsonschema:"Recipe save source. Supported now: raw_text, prepared, url, draft, and existing."`
RecipeID string `json:"recipe_id,omitempty" jsonschema:"Recipe ID for existing recipe updates."`
+ DraftID string `json:"draft_id,omitempty" jsonschema:"Draft ID for reviewed URL import draft saves."`
Title string `json:"title,omitempty" jsonschema:"Recipe title for raw_text or prepared saves."`
Text string `json:"text,omitempty" jsonschema:"Raw recipe text for raw_text saves."`
URL string `json:"url,omitempty" jsonschema:"Recipe URL for url imports."`
- Markdown string `json:"markdown,omitempty" jsonschema:"Recipe markdown for prepared saves, URL import drafts, or existing recipe updates."`
- Portions int `json:"portions,omitempty" jsonschema:"Recipe portions for prepared saves, URL import drafts, or existing recipe updates."`
+ Markdown string `json:"markdown,omitempty" jsonschema:"Recipe markdown for prepared saves, reviewed URL import drafts, or existing recipe updates."`
+ Portions int `json:"portions,omitempty" jsonschema:"Recipe portions for prepared saves, reviewed URL import drafts, or existing recipe updates."`
}
// SaveRecipeOutput is the structured output for save_recipe.
@@ -0,0 +1,101 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+//
+// SPDX-License-Identifier: LicenseRef-MutuaL-1.2
+
+package mcp
+
+import (
+ "context"
+ "testing"
+)
+
+// recipes.SAVE.7 recipes.SAVE.9 recipes.SAVE.16 recipes.SAFETY.1 tools.SAVE_RECIPE_TOOL.1 tools.SAVE_RECIPE_TOOL.5
+func TestCallToolACIDRecipesSave7And9And16SavesReviewedDraft(t *testing.T) {
+ backend := &fakeBackend{draftSaveRecipeID: "recipe-1"}
+ server := NewServer(backend, "test")
+
+ _, output, err := server.callSaveRecipeTool(
+ context.Background(),
+ nil,
+ SaveRecipeArguments{
+ Source: " draft ",
+ DraftID: " draft-1 ",
+ Markdown: " # Pasta\n\n1. Boil pasta.\n",
+ Portions: 2,
+ },
+ )
+ if err != nil {
+ t.Fatalf("callSaveRecipeTool() error = %v", err)
+ }
+
+ if backend.draftSaveCalls != 1 {
+ t.Fatalf("draft save calls = %d, want 1", backend.draftSaveCalls)
+ }
+ if backend.draftSaveID != "draft-1" {
+ t.Fatalf("backend draft ID = %q, want draft-1", backend.draftSaveID)
+ }
+ if backend.draftSaveMarkdown != " # Pasta\n\n1. Boil pasta.\n" {
+ t.Fatalf("backend draft markdown = %q, want raw markdown preserved", backend.draftSaveMarkdown)
+ }
+ if backend.draftSavePortions != 2 {
+ t.Fatalf("backend draft portions = %d, want 2", backend.draftSavePortions)
+ }
+ if output.RecipeID != "recipe-1" || output.DraftID != "" || output.Markdown != "" || output.Portions != 0 {
+ t.Fatalf("draft save output = %#v, want recipe_id only", output)
+ }
+ if backend.importRecipeURLCalls != 0 || backend.metadataCalls != 0 || backend.contentCalls != 0 {
+ t.Fatalf(
+ "unexpected import/read calls = import %d metadata %d content %d, want none",
+ backend.importRecipeURLCalls,
+ backend.metadataCalls,
+ backend.contentCalls,
+ )
+ }
+ if backend.saveCalls != 0 || backend.updateCalls != 0 {
+ t.Fatalf("prepared/update calls = %d/%d, want none", backend.saveCalls, backend.updateCalls)
+ }
+}
+
+// recipes.SAVE.7-1 recipes.SAVE.7-2 recipes.SAVE.7-3 tools.SAVE_RECIPE_TOOL.5
+func TestCallToolACIDRecipesSave7_1To7_3AndToolsSaveRecipeTool5RequiresDraftFields(t *testing.T) {
+ tests := []struct {
+ name string
+ arguments SaveRecipeArguments
+ }{
+ {
+ name: "draft_id",
+ arguments: SaveRecipeArguments{Source: "draft", DraftID: " ", Markdown: "# Pasta", Portions: 2},
+ },
+ {
+ name: "markdown",
+ arguments: SaveRecipeArguments{Source: "draft", DraftID: "draft-1", Markdown: " ", Portions: 2},
+ },
+ {
+ name: "portions",
+ arguments: SaveRecipeArguments{Source: "draft", DraftID: "draft-1", Markdown: "# Pasta", Portions: 0},
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ backend := &fakeBackend{}
+ server := NewServer(backend, "test")
+
+ _, _, err := server.callSaveRecipeTool(context.Background(), nil, tt.arguments)
+ if err == nil {
+ t.Fatal("callSaveRecipeTool() error = nil, want missing draft field error")
+ }
+ if backend.draftSaveCalls != 0 {
+ t.Fatalf("draft save calls = %d, want none", backend.draftSaveCalls)
+ }
+ if backend.saveCalls != 0 || backend.updateCalls != 0 || backend.importRecipeURLCalls != 0 {
+ t.Fatalf(
+ "unexpected backend calls = prepared %d update %d import %d, want none",
+ backend.saveCalls,
+ backend.updateCalls,
+ backend.importRecipeURLCalls,
+ )
+ }
+ })
+ }
+}
@@ -364,12 +364,12 @@ func TestCallToolACIDRecipesSave10RejectsUnsupportedSourceBeforeCallingCooked(t
backend := &fakeBackend{}
server := NewServer(backend, "test")
- _, _, err := server.callSaveRecipeTool(context.Background(), nil, SaveRecipeArguments{Source: "draft"})
+ _, _, err := server.callSaveRecipeTool(context.Background(), nil, SaveRecipeArguments{Source: "unsupported"})
if err == nil {
t.Fatal("callSaveRecipeTool() error = nil, want unsupported source error")
}
- if backend.saveCalls != 0 {
- t.Fatalf("save calls = %d, want none", backend.saveCalls)
+ if backend.saveCalls != 0 || backend.draftSaveCalls != 0 {
+ t.Fatalf("save calls = prepared %d draft %d, want none", backend.saveCalls, backend.draftSaveCalls)
}
}
@@ -952,6 +952,7 @@ type fakeBackend struct {
preview cooked.RecipeTextPreview
importRecipeURLResult cooked.RecipeURLImport
saveRecipeID string
+ draftSaveRecipeID string
page int
limit int
searchQuery string
@@ -964,6 +965,9 @@ type fakeBackend struct {
saveTitle string
saveMarkdown string
savePortions int
+ draftSaveID string
+ draftSaveMarkdown string
+ draftSavePortions int
updateRecipeID string
updateMarkdown string
updatePortions int
@@ -981,6 +985,7 @@ type fakeBackend struct {
previewCalls int
importRecipeURLCalls int
saveCalls int
+ draftSaveCalls int
updateCalls int
deleteCalls int
clearShoppingListCalls int
@@ -1044,6 +1049,18 @@ func (f *fakeBackend) SavePreparedRecipe(_ context.Context, title, markdown stri
return "recipe-1", nil
}
+func (f *fakeBackend) SaveRecipeDraft(_ context.Context, draftID, markdown string, portions int) (string, error) {
+ f.draftSaveID = draftID
+ f.draftSaveMarkdown = markdown
+ f.draftSavePortions = portions
+ f.draftSaveCalls++
+ if f.draftSaveRecipeID != "" {
+ return f.draftSaveRecipeID, nil
+ }
+
+ return "recipe-1", nil
+}
+
func (f *fakeBackend) UpdateRecipeContent(_ context.Context, recipeID, markdown string, portions int) error {
f.updateRecipeID = recipeID
f.updateMarkdown = markdown