@@ -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."`
}
@@ -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