From b9b746d4f87d2fafcd618c1b60fa56d967907a21 Mon Sep 17 00:00:00 2001 From: Amolith Date: Wed, 10 Jun 2026 14:59:33 -0600 Subject: [PATCH] tools: save raw text recipe --- internal/mcp/server.go | 102 ++++++++++++++++++++++++++---------- internal/mcp/server_test.go | 70 ++++++++++++++++++++++++- 2 files changed, 144 insertions(+), 28 deletions(-) diff --git a/internal/mcp/server.go b/internal/mcp/server.go index d1133725683d27ed9a9a31dd5bb4ae9f34298afd..9a0aef9531dbf7b9265bc304c963e7a8499ff39e 100644 --- a/internal/mcp/server.go +++ b/internal/mcp/server.go @@ -193,31 +193,10 @@ func (s *Server) callSaveRecipeTool( ) (*sdk.CallToolResult, SaveRecipeOutput, error) { source := strings.TrimSpace(arguments.Source) switch source { + case "raw_text": + return s.callRawTextSaveRecipeTool(ctx, arguments) case "prepared": - title := strings.TrimSpace(arguments.Title) - 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") - } - - recipeID, err := s.backend.SavePreparedRecipe(ctx, title, arguments.Markdown, arguments.Portions) - if err != nil { - return nil, SaveRecipeOutput{}, err - } - if strings.TrimSpace(recipeID) == "" { - return nil, SaveRecipeOutput{}, fmt.Errorf("save recipe response missing recipe ID") - } - - output := SaveRecipeOutput{RecipeID: recipeID} - - return &sdk.CallToolResult{ - Content: []sdk.Content{&sdk.TextContent{Text: formatRecipeSave(output)}}, - }, output, nil + return s.callPreparedSaveRecipeTool(ctx, arguments) case "": return nil, SaveRecipeOutput{}, fmt.Errorf("source is required") default: @@ -225,6 +204,74 @@ func (s *Server) callSaveRecipeTool( } } +func (s *Server) callRawTextSaveRecipeTool( + ctx context.Context, + arguments SaveRecipeArguments, +) (*sdk.CallToolResult, SaveRecipeOutput, error) { + title := strings.TrimSpace(arguments.Title) + if title == "" { + return nil, SaveRecipeOutput{}, fmt.Errorf("title is required when source is raw_text") + } + if strings.TrimSpace(arguments.Text) == "" { + return nil, SaveRecipeOutput{}, fmt.Errorf("text is required when source is raw_text") + } + + preview, err := s.backend.PreviewRecipeText(ctx, title, arguments.Text) + if err != nil { + return nil, SaveRecipeOutput{}, err + } + saveTitle := strings.TrimSpace(preview.Title) + if saveTitle == "" { + saveTitle = title + } + if strings.TrimSpace(preview.Markdown) == "" { + return nil, SaveRecipeOutput{}, fmt.Errorf("recipe text preview response missing markdown") + } + if preview.Portions < 1 { + return nil, SaveRecipeOutput{}, fmt.Errorf("recipe text preview response missing portions") + } + + return s.savePreparedRecipe(ctx, saveTitle, preview.Markdown, preview.Portions) +} + +func (s *Server) callPreparedSaveRecipeTool( + ctx context.Context, + arguments SaveRecipeArguments, +) (*sdk.CallToolResult, SaveRecipeOutput, error) { + title := strings.TrimSpace(arguments.Title) + 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") + } + + return s.savePreparedRecipe(ctx, title, arguments.Markdown, arguments.Portions) +} + +func (s *Server) savePreparedRecipe( + ctx context.Context, + title, markdown string, + portions int, +) (*sdk.CallToolResult, SaveRecipeOutput, error) { + recipeID, err := s.backend.SavePreparedRecipe(ctx, title, markdown, portions) + if err != nil { + return nil, SaveRecipeOutput{}, err + } + if strings.TrimSpace(recipeID) == "" { + return nil, SaveRecipeOutput{}, fmt.Errorf("save recipe response missing recipe ID") + } + + output := SaveRecipeOutput{RecipeID: recipeID} + + return &sdk.CallToolResult{ + Content: []sdk.Content{&sdk.TextContent{Text: formatRecipeSave(output)}}, + }, output, nil +} + func readTool() *sdk.Tool { openWorld := true return &sdk.Tool{ @@ -258,7 +305,7 @@ func saveRecipeTool() *sdk.Tool { return &sdk.Tool{ Name: saveRecipeToolName, Title: "Save recipe", - Description: "Save a recipe. This slice supports source prepared with title, 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, and source prepared with title, markdown, and portions. Other source values are reserved for later slices.", Annotations: &sdk.ToolAnnotations{ Title: "Save recipe", OpenWorldHint: &openWorld, @@ -450,8 +497,9 @@ type PreviewRecipeTextOutput struct { // SaveRecipeArguments contains save_recipe tool arguments. type SaveRecipeArguments struct { - Source string `json:"source" jsonschema:"Recipe save source. Supported now: prepared."` - Title string `json:"title,omitempty" jsonschema:"Recipe title for prepared saves."` + Source string `json:"source" jsonschema:"Recipe save source. Supported now: raw_text and prepared."` + 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."` Markdown string `json:"markdown,omitempty" jsonschema:"Recipe markdown for prepared saves."` Portions int `json:"portions,omitempty" jsonschema:"Recipe portions for prepared saves."` } diff --git a/internal/mcp/server_test.go b/internal/mcp/server_test.go index 8339eec4126d816cb04e3f49445b3ac3bc44f949..f5d932a73bc92ba2d89c6bcba61555ffb94f3dfb 100644 --- a/internal/mcp/server_test.go +++ b/internal/mcp/server_test.go @@ -363,7 +363,7 @@ func TestCallToolACIDRecipesSave10RejectsUnsupportedSourceBeforeCallingCooked(t backend := &fakeBackend{} server := NewServer(backend, "test") - _, _, err := server.callSaveRecipeTool(context.Background(), nil, SaveRecipeArguments{Source: "raw_text"}) + _, _, err := server.callSaveRecipeTool(context.Background(), nil, SaveRecipeArguments{Source: "url"}) if err == nil { t.Fatal("callSaveRecipeTool() error = nil, want unsupported source error") } @@ -372,6 +372,74 @@ func TestCallToolACIDRecipesSave10RejectsUnsupportedSourceBeforeCallingCooked(t } } +func TestCallToolACIDRecipesSave1And11SavesRawTextRecipe(t *testing.T) { + backend := &fakeBackend{ + preview: cooked.RecipeTextPreview{ + Title: "Pasta with Tomato Sauce", + Markdown: "# Pasta\n\n1. Boil pasta.", + Portions: 2, + }, + saveRecipeID: "recipe-1", + } + server := NewServer(backend, "test") + + _, output, err := server.callSaveRecipeTool( + context.Background(), + nil, + SaveRecipeArguments{Source: " raw_text ", Title: " Pasta ", Text: " Boil pasta.\n"}, + ) + if err != nil { + t.Fatalf("callSaveRecipeTool() error = %v", err) + } + + if backend.previewCalls != 1 || backend.saveCalls != 1 { + t.Fatalf("backend calls = preview %d save %d, want 1/1", backend.previewCalls, backend.saveCalls) + } + if backend.previewTitle != "Pasta" { + t.Fatalf("preview title = %q, want trimmed Pasta", backend.previewTitle) + } + if backend.previewText != " Boil pasta.\n" { + t.Fatalf("preview text = %q, want raw text preserved", backend.previewText) + } + if backend.saveTitle != "Pasta with Tomato Sauce" { + t.Fatalf("save title = %q, want preview title", backend.saveTitle) + } + if backend.saveMarkdown != "# Pasta\n\n1. Boil pasta." { + t.Fatalf("save markdown = %q, want preview markdown", backend.saveMarkdown) + } + if backend.savePortions != 2 { + t.Fatalf("save portions = %d, want preview portions 2", backend.savePortions) + } + if output.RecipeID != "recipe-1" { + t.Fatalf("save output recipe ID = %q, want recipe-1", output.RecipeID) + } +} + +func TestCallToolACIDRecipesSave1_1To1_2RequiresRawTextFields(t *testing.T) { + tests := []struct { + name string + arguments SaveRecipeArguments + }{ + {name: "title", arguments: SaveRecipeArguments{Source: "raw_text", Title: " ", Text: "Boil pasta."}}, + {name: "text", arguments: SaveRecipeArguments{Source: "raw_text", Title: "Pasta", Text: " "}}, + } + + 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 raw text field error") + } + if backend.previewCalls != 0 || backend.saveCalls != 0 { + t.Fatalf("backend calls = preview %d save %d, want none", backend.previewCalls, backend.saveCalls) + } + }) + } +} + type fakeBackend struct { shoppingList cooked.ShoppingList recipes []cooked.RecipeCard