mcp: allow saving draft by id

Amolith created

Change summary

features/cooked-mcp/recipes.feature.yaml |  5 +
features/cooked-mcp/tools.feature.yaml   |  2 
internal/mcp/server.go                   | 27 ----------
internal/mcp/server_save_draft.go        | 51 +++++++++++++++++++++
internal/mcp/server_save_draft_test.go   | 63 +++++++++++++++++++++++--
5 files changed, 113 insertions(+), 35 deletions(-)

Detailed changes

features/cooked-mcp/recipes.feature.yaml 🔗

@@ -42,8 +42,9 @@ components:
       6: URL import drafts are not automatically saved.
       7: The save_recipe tool saves a reviewed extraction draft.
       7-1: Saving a reviewed extraction draft requires a draft ID.
-      7-2: Saving a reviewed extraction draft requires reviewed markdown.
-      7-3: Saving a reviewed extraction draft requires portions.
+      7-2: Saving a reviewed extraction draft may include reviewed markdown and portions.
+      7-3: Saving a reviewed extraction draft without reviewed markdown and portions uses the draft content from Cooked.
+      7-4: Saving a reviewed extraction draft rejects partial reviewed content.
       8: The save_recipe tool updates an existing saved recipe.
       8-1: Updating an existing saved recipe requires a recipe ID.
       8-2: Updating an existing saved recipe requires recipe markdown.

features/cooked-mcp/tools.feature.yaml 🔗

@@ -32,7 +32,7 @@ components:
       2: source raw_text requires title and text.
       3: source prepared requires title, markdown, and portions.
       4: source url requires url.
-      5: source draft requires draft_id, markdown, and portions.
+      5: source draft requires draft_id and accepts either both markdown and portions or neither.
       6: source existing requires recipe_id, markdown, and portions.
   DELETE_RECIPE_TOOL:
     requirements:

internal/mcp/server.go 🔗

@@ -351,29 +351,6 @@ func (s *Server) callURLSaveRecipeTool(
 	return newToolResult(formatRecipeURLImportDraft(output), output), 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)
-}
-
 func (s *Server) callExistingSaveRecipeTool(
 	ctx context.Context,
 	arguments SaveRecipeArguments,
@@ -1136,8 +1113,8 @@ type SaveRecipeArguments struct {
 	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, reviewed URL import drafts, or existing recipe updates. Read target example_recipes first for Cooked's markdown dialect and examples."`
-	Portions float64 `json:"portions,omitempty"  jsonschema:"Recipe portions for prepared saves, reviewed URL import drafts, or existing recipe updates."`
+	Markdown string  `json:"markdown,omitempty"  jsonschema:"Recipe markdown for prepared saves, optional reviewed URL import draft content, or existing recipe updates. Read target example_recipes first for Cooked's markdown dialect and examples."`
+	Portions float64 `json:"portions,omitempty"  jsonschema:"Recipe portions for prepared saves, optional reviewed URL import draft content, or existing recipe updates."`
 }
 
 // SaveRecipeOutput is the structured output for save_recipe.

internal/mcp/server_save_draft.go 🔗

@@ -0,0 +1,51 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+//
+// SPDX-License-Identifier: LicenseRef-MutuaL-1.2
+
+package mcp
+
+import (
+	"context"
+	"fmt"
+	"strings"
+
+	sdk "github.com/modelcontextprotocol/go-sdk/mcp"
+)
+
+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")
+	}
+
+	markdown := arguments.Markdown
+	portions := arguments.Portions
+	hasMarkdown := strings.TrimSpace(markdown) != ""
+	hasPortions := portions >= 1
+	if hasMarkdown != hasPortions {
+		return nil, SaveRecipeOutput{}, fmt.Errorf(
+			"markdown and portions must be provided together when source is draft",
+		)
+	}
+	if !hasMarkdown {
+		content, err := s.backend.ReadRecipeContent(ctx, draftID)
+		if err != nil {
+			return nil, SaveRecipeOutput{}, err
+		}
+		markdown = content.Content
+		portions = content.Portions
+	}
+
+	recipeID, err := s.backend.SaveRecipeDraft(ctx, draftID, markdown, 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)
+}

internal/mcp/server_save_draft_test.go 🔗

@@ -7,9 +7,11 @@ package mcp
 import (
 	"context"
 	"testing"
+
+	"git.secluded.site/cooked-mcp/internal/cooked"
 )
 
-// recipes.SAVE.7 recipes.SAVE.9 recipes.SAVE.16 recipes.SAFETY.1 tools.SAVE_RECIPE_TOOL.1 tools.SAVE_RECIPE_TOOL.5
+// recipes.SAVE.7 recipes.SAVE.7-2 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")
@@ -56,8 +58,52 @@ func TestCallToolACIDRecipesSave7And9And16SavesReviewedDraft(t *testing.T) {
 	}
 }
 
-// recipes.SAVE.7-1 recipes.SAVE.7-2 recipes.SAVE.7-3 tools.SAVE_RECIPE_TOOL.5
-func TestCallToolACIDRecipesSave7_1To7_3AndToolsSaveRecipeTool5RequiresDraftFields(t *testing.T) {
+// 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{
+		draftSaveRecipeID: "recipe-1",
+		recipeContent: cooked.RecipeContent{
+			Content:  "# Pasta\n\n1. Boil pasta.",
+			Portions: 1.5,
+		},
+	}
+	server := NewServer(backend, "test")
+
+	_, output, err := server.callSaveRecipeTool(
+		context.Background(),
+		nil,
+		SaveRecipeArguments{Source: "draft", DraftID: " draft-1 "},
+	)
+	if err != nil {
+		t.Fatalf("callSaveRecipeTool() error = %v", err)
+	}
+
+	if backend.contentCalls != 1 || backend.contentRecipeID != "draft-1" {
+		t.Fatalf("content reads = %d for %q, want 1 for draft-1", backend.contentCalls, backend.contentRecipeID)
+	}
+	if backend.draftSaveCalls != 1 {
+		t.Fatalf("draft save calls = %d, want 1", backend.draftSaveCalls)
+	}
+	if backend.draftSaveMarkdown != "# Pasta\n\n1. Boil pasta." {
+		t.Fatalf("backend draft markdown = %q, want current draft content", backend.draftSaveMarkdown)
+	}
+	if backend.draftSavePortions != 1.5 {
+		t.Fatalf("backend draft portions = %g, want current draft portions 1.5", 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.metadataCalls != 0 || backend.importRecipeURLCalls != 0 {
+		t.Fatalf(
+			"unexpected metadata/import calls = %d/%d, want none",
+			backend.metadataCalls,
+			backend.importRecipeURLCalls,
+		)
+	}
+}
+
+// recipes.SAVE.7-1 recipes.SAVE.7-4 tools.SAVE_RECIPE_TOOL.5
+func TestCallToolACIDRecipesSave7_1And7_4AndToolsSaveRecipeTool5RequiresDraftIDAndCompleteContent(t *testing.T) {
 	tests := []struct {
 		name      string
 		arguments SaveRecipeArguments
@@ -67,12 +113,12 @@ func TestCallToolACIDRecipesSave7_1To7_3AndToolsSaveRecipeTool5RequiresDraftFiel
 			arguments: SaveRecipeArguments{Source: "draft", DraftID: "   ", Markdown: "# Pasta", Portions: 2},
 		},
 		{
-			name:      "markdown",
-			arguments: SaveRecipeArguments{Source: "draft", DraftID: "draft-1", Markdown: "   ", Portions: 2},
+			name:      "markdown_without_portions",
+			arguments: SaveRecipeArguments{Source: "draft", DraftID: "draft-1", Markdown: "# Pasta", Portions: 0},
 		},
 		{
-			name:      "portions",
-			arguments: SaveRecipeArguments{Source: "draft", DraftID: "draft-1", Markdown: "# Pasta", Portions: 0},
+			name:      "portions_without_markdown",
+			arguments: SaveRecipeArguments{Source: "draft", DraftID: "draft-1", Markdown: "   ", Portions: 2},
 		},
 	}
 
@@ -88,6 +134,9 @@ func TestCallToolACIDRecipesSave7_1To7_3AndToolsSaveRecipeTool5RequiresDraftFiel
 			if backend.draftSaveCalls != 0 {
 				t.Fatalf("draft save calls = %d, want none", backend.draftSaveCalls)
 			}
+			if backend.contentCalls != 0 {
+				t.Fatalf("content calls = %d, want none", backend.contentCalls)
+			}
 			if backend.saveCalls != 0 || backend.updateCalls != 0 || backend.importRecipeURLCalls != 0 {
 				t.Fatalf(
 					"unexpected backend calls = prepared %d update %d import %d, want none",