mcp: allow source=draft to overwrite existing recipe

Amolith created

Change summary

internal/mcp/server.go                 | 12 +++++++
internal/mcp/server_save_draft.go      | 15 ++++++++--
internal/mcp/server_save_draft_test.go | 40 ++++++++++++++++++++++++++++
3 files changed, 63 insertions(+), 4 deletions(-)

Detailed changes

internal/mcp/server.go 🔗

@@ -404,6 +404,12 @@ func newRecipeSaveResult(recipeID string) (*sdk.CallToolResult, SaveRecipeOutput
 	return newToolResult(formatRecipeSave(output), output), output, nil
 }
 
+func newRecipeOverwriteResult(recipeID string) (*sdk.CallToolResult, SaveRecipeOutput, error) {
+	output := SaveRecipeOutput{RecipeID: recipeID}
+
+	return newToolResult(formatRecipeOverwrite(output), output), output, nil
+}
+
 func (s *Server) callDeleteRecipeTool(
 	ctx context.Context,
 	_ *sdk.CallToolRequest,
@@ -1039,6 +1045,10 @@ func formatRecipeSave(output SaveRecipeOutput) string {
 	return "Saved recipe (id: " + output.RecipeID + ")."
 }
 
+func formatRecipeOverwrite(output SaveRecipeOutput) string {
+	return "Saved draft into existing recipe (id: " + output.RecipeID + ")."
+}
+
 func formatRecipeURLImportDraft(output SaveRecipeOutput) string {
 	title := output.Title
 	if strings.TrimSpace(title) == "" {
@@ -1124,7 +1134,7 @@ type ExtractIntoRecipeOutput struct {
 // SaveRecipeArguments contains save_recipe tool arguments.
 type SaveRecipeArguments struct {
 	Source   string  `json:"source"              jsonschema:"Recipe save source: raw_text, prepared, url, draft, or existing."`
-	RecipeID string  `json:"recipe_id,omitempty" jsonschema:"Recipe ID for existing recipe updates."`
+	RecipeID string  `json:"recipe_id,omitempty" jsonschema:"Recipe ID for source=existing updates or source=draft overwrite into an existing recipe."`
 	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."`

internal/mcp/server_save_draft.go 🔗

@@ -39,13 +39,22 @@ func (s *Server) callDraftSaveRecipeTool(
 		portions = content.Portions
 	}
 
-	recipeID, err := s.backend.SaveRecipeDraft(ctx, draftID, markdown, portions)
+	recipeID := strings.TrimSpace(arguments.RecipeID)
+	if recipeID != "" {
+		if err := s.backend.UpdateRecipeContent(ctx, recipeID, markdown, portions); err != nil {
+			return nil, SaveRecipeOutput{}, err
+		}
+
+		return newRecipeOverwriteResult(recipeID)
+	}
+
+	savedRecipeID, err := s.backend.SaveRecipeDraft(ctx, draftID, markdown, portions)
 	if err != nil {
 		return nil, SaveRecipeOutput{}, err
 	}
-	if strings.TrimSpace(recipeID) == "" {
+	if strings.TrimSpace(savedRecipeID) == "" {
 		return nil, SaveRecipeOutput{}, fmt.Errorf("save recipe draft response missing recipe ID")
 	}
 
-	return newRecipeSaveResult(recipeID)
+	return newRecipeSaveResult(savedRecipeID)
 }

internal/mcp/server_save_draft_test.go 🔗

@@ -58,6 +58,46 @@ func TestCallToolACIDRecipesSave7And9And16SavesReviewedDraft(t *testing.T) {
 	}
 }
 
+// recipes.SAVE.7-5 recipes.SAVE.9-1 recipes.SAVE.16 recipes.SAFETY.1 recipes.SAFETY.2
+func TestCallToolACIDRecipesSave7_5And9_1OverwritesExistingRecipeFromDraft(t *testing.T) {
+	backend := &fakeBackend{}
+	server := NewServer(backend, "test")
+
+	_, output, err := server.callSaveRecipeTool(
+		context.Background(),
+		nil,
+		SaveRecipeArguments{
+			Source:   " draft ",
+			DraftID:  " draft-1 ",
+			RecipeID: " recipe-1 ",
+			Markdown: "  # Pasta\n\n1. Boil pasta.\n",
+			Portions: 2,
+		},
+	)
+	if err != nil {
+		t.Fatalf("callSaveRecipeTool() error = %v", err)
+	}
+
+	if backend.updateCalls != 1 {
+		t.Fatalf("update calls = %d, want 1", backend.updateCalls)
+	}
+	if backend.updateRecipeID != "recipe-1" {
+		t.Fatalf("backend update recipe ID = %q, want recipe-1", backend.updateRecipeID)
+	}
+	if backend.updateMarkdown != "  # Pasta\n\n1. Boil pasta.\n" {
+		t.Fatalf("backend update markdown = %q, want raw markdown preserved", backend.updateMarkdown)
+	}
+	if backend.updatePortions != 2 {
+		t.Fatalf("backend update portions = %g, want 2", backend.updatePortions)
+	}
+	if output.RecipeID != "recipe-1" {
+		t.Fatalf("save output recipe ID = %q, want recipe-1", output.RecipeID)
+	}
+	if backend.draftSaveCalls != 0 || backend.saveCalls != 0 {
+		t.Fatalf("draft save/prepared calls = %d/%d, want none", backend.draftSaveCalls, backend.saveCalls)
+	}
+}
+
 // recipes.SAVE.7 recipes.SAVE.7-3 recipes.SAVE.9 recipes.SAVE.16 tools.SAVE_RECIPE_TOOL.5
 func TestCallToolACIDRecipesSave7_3SavesCurrentDraftContentWhenContentOmitted(t *testing.T) {
 	backend := &fakeBackend{