Detailed changes
@@ -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.
@@ -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:
@@ -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.
@@ -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)
+}
@@ -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",