tools: save raw text recipe

Amolith created

Change summary

internal/mcp/server.go      | 102 ++++++++++++++++++++++++++++----------
internal/mcp/server_test.go |  70 ++++++++++++++++++++++++++
2 files changed, 144 insertions(+), 28 deletions(-)

Detailed changes

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

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