tools: save reviewed recipe drafts

Amolith created

Change summary

internal/mcp/server.go                 |  60 ++++++++++++---
internal/mcp/server_save_draft_test.go | 101 ++++++++++++++++++++++++++++
internal/mcp/server_test.go            |  23 +++++
3 files changed, 167 insertions(+), 17 deletions(-)

Detailed changes

internal/mcp/server.go 🔗

@@ -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.

internal/mcp/server_save_draft_test.go 🔗

@@ -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,
+				)
+			}
+		})
+	}
+}

internal/mcp/server_test.go 🔗

@@ -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