diff --git a/internal/mcp/server.go b/internal/mcp/server.go index 8c8a66ef0b4a923a752ad874b942f7bb2ab897b1..095ab95f4e87c1da62c173c5478ae126e344e1af 100644 --- a/internal/mcp/server.go +++ b/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. diff --git a/internal/mcp/server_save_draft_test.go b/internal/mcp/server_save_draft_test.go new file mode 100644 index 0000000000000000000000000000000000000000..da2385eb4873090cf8dffc7f563c59664f4c9b43 --- /dev/null +++ b/internal/mcp/server_save_draft_test.go @@ -0,0 +1,101 @@ +// SPDX-FileCopyrightText: Amolith +// +// 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, + ) + } + }) + } +} diff --git a/internal/mcp/server_test.go b/internal/mcp/server_test.go index 3c6cf736e62e4ba15eb1a4b3f9a47fbbb9bc3d9b..dde6ab0a4cad4ddfc0e2fd0ff377701c60df94c6 100644 --- a/internal/mcp/server_test.go +++ b/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