From c7c79ce806b051f55c75d6d70a5b7ee217de666b Mon Sep 17 00:00:00 2001 From: Amolith Date: Sat, 13 Jun 2026 08:01:20 -0600 Subject: [PATCH] mcp: allow saving draft by id --- 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(-) create mode 100644 internal/mcp/server_save_draft.go diff --git a/features/cooked-mcp/recipes.feature.yaml b/features/cooked-mcp/recipes.feature.yaml index e8165bad476610868d142d566cbc092fd1a2732e..4a5cb0ea18f428b1e343aa72353780d8dfc17501 100644 --- a/features/cooked-mcp/recipes.feature.yaml +++ b/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. diff --git a/features/cooked-mcp/tools.feature.yaml b/features/cooked-mcp/tools.feature.yaml index 2ea56f3fad200a0b7de5fe0cc2fc9b85aa85771a..00974aaee582796cb2b68476cd5fb7281cfaee62 100644 --- a/features/cooked-mcp/tools.feature.yaml +++ b/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: diff --git a/internal/mcp/server.go b/internal/mcp/server.go index fe02e62fba046f4a4bfa3abf21ead7f1dc35e9dc..886c378f6dc02dc05eed007e292261ac059c8307 100644 --- a/internal/mcp/server.go +++ b/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. diff --git a/internal/mcp/server_save_draft.go b/internal/mcp/server_save_draft.go new file mode 100644 index 0000000000000000000000000000000000000000..a2b49f53d4a41639910608d66be41f3cc8f41d0d --- /dev/null +++ b/internal/mcp/server_save_draft.go @@ -0,0 +1,51 @@ +// SPDX-FileCopyrightText: Amolith +// +// 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) +} diff --git a/internal/mcp/server_save_draft_test.go b/internal/mcp/server_save_draft_test.go index 9d3cb61e7e2948a8dba5e5c60a7b63f8c4ccf65d..4e31e02759268eaa7593baded53de6ca7a5ae137 100644 --- a/internal/mcp/server_save_draft_test.go +++ b/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",