tools: preview recipe text

Amolith created

Change summary

internal/mcp/server.go      | 86 ++++++++++++++++++++++++++++++++++++++
internal/mcp/server_test.go | 78 +++++++++++++++++++++++++++++++++++
2 files changed, 162 insertions(+), 2 deletions(-)

Detailed changes

internal/mcp/server.go 🔗

@@ -16,8 +16,9 @@ import (
 )
 
 const (
-	serverName   = "Cooked"
-	readToolName = "read"
+	serverName                = "Cooked"
+	readToolName              = "read"
+	previewRecipeTextToolName = "preview_recipe_text"
 )
 
 // Backend provides the Cooked operations exposed as MCP tools.
@@ -27,6 +28,7 @@ type Backend interface {
 	SearchRecipes(ctx context.Context, query string, page int) ([]cooked.RecipeCard, error)
 	ReadRecipeMetadata(ctx context.Context, recipeID string) (cooked.RecipeMetadata, error)
 	ReadRecipeContent(ctx context.Context, recipeID string) (cooked.RecipeContent, error)
+	PreviewRecipeText(ctx context.Context, title, text string) (cooked.RecipeTextPreview, error)
 }
 
 // Server exposes Cooked as an MCP server.
@@ -40,6 +42,7 @@ func NewServer(backend Backend, version string) *Server {
 	server := &Server{backend: backend}
 	server.sdk = sdk.NewServer(&sdk.Implementation{Name: serverName, Title: serverName, Version: version}, nil)
 	sdk.AddTool(server.sdk, readTool(), server.callReadTool)
+	sdk.AddTool(server.sdk, previewRecipeTextTool(), server.callPreviewRecipeTextTool)
 
 	return server
 }
@@ -151,6 +154,35 @@ func (s *Server) readRecipeCards(ctx context.Context, query string, page, limit
 	return recipes, nil
 }
 
+func (s *Server) callPreviewRecipeTextTool(
+	ctx context.Context,
+	_ *sdk.CallToolRequest,
+	arguments PreviewRecipeTextArguments,
+) (*sdk.CallToolResult, PreviewRecipeTextOutput, error) {
+	title := strings.TrimSpace(arguments.Title)
+	if title == "" {
+		return nil, PreviewRecipeTextOutput{}, fmt.Errorf("title is required")
+	}
+	if strings.TrimSpace(arguments.Text) == "" {
+		return nil, PreviewRecipeTextOutput{}, fmt.Errorf("text is required")
+	}
+
+	preview, err := s.backend.PreviewRecipeText(ctx, title, arguments.Text)
+	if err != nil {
+		return nil, PreviewRecipeTextOutput{}, err
+	}
+
+	output := PreviewRecipeTextOutput{
+		Title:    preview.Title,
+		Markdown: preview.Markdown,
+		Portions: preview.Portions,
+	}
+
+	return &sdk.CallToolResult{
+		Content: []sdk.Content{&sdk.TextContent{Text: formatRecipeTextPreview(output)}},
+	}, output, nil
+}
+
 func readTool() *sdk.Tool {
 	openWorld := true
 	return &sdk.Tool{
@@ -165,6 +197,20 @@ func readTool() *sdk.Tool {
 	}
 }
 
+func previewRecipeTextTool() *sdk.Tool {
+	openWorld := true
+	return &sdk.Tool{
+		Name:        previewRecipeTextToolName,
+		Title:       "Preview recipe text",
+		Description: "Preview raw recipe text as Cooked recipe markdown and portions without saving or updating a recipe.",
+		Annotations: &sdk.ToolAnnotations{
+			Title:         "Preview recipe text",
+			ReadOnlyHint:  true,
+			OpenWorldHint: &openWorld,
+		},
+	}
+}
+
 func normalizeRecipePage(page, limit int) (int, int) {
 	if page < 1 {
 		page = 1
@@ -265,6 +311,29 @@ func formatRecipe(recipe RecipeDetail) string {
 	return strings.TrimRight(builder.String(), "\n")
 }
 
+func formatRecipeTextPreview(preview PreviewRecipeTextOutput) string {
+	title := preview.Title
+	if title == "" {
+		title = "(untitled preview)"
+	}
+
+	var builder strings.Builder
+	builder.WriteString("Recipe text preview: ")
+	builder.WriteString(title)
+	builder.WriteByte('\n')
+	fmt.Fprintf(&builder, "Portions: %d\n", preview.Portions)
+
+	markdown := strings.TrimSpace(preview.Markdown)
+	if markdown == "" {
+		builder.WriteString("\nNo preview markdown returned.")
+	} else {
+		builder.WriteString("\n")
+		builder.WriteString(markdown)
+	}
+
+	return strings.TrimRight(builder.String(), "\n")
+}
+
 func recipeSummaries(recipes []cooked.RecipeCard) []RecipeSummary {
 	summaries := make([]RecipeSummary, 0, len(recipes))
 	for _, recipe := range recipes {
@@ -306,3 +375,16 @@ type RecipeDetail struct {
 	Content        string `json:"content"`
 	Portions       int    `json:"portions"`
 }
+
+// PreviewRecipeTextArguments contains preview_recipe_text tool arguments.
+type PreviewRecipeTextArguments struct {
+	Title string `json:"title" jsonschema:"Recipe title for the preview."`
+	Text  string `json:"text"  jsonschema:"Raw recipe text to preview without saving."`
+}
+
+// PreviewRecipeTextOutput is the structured output for preview_recipe_text.
+type PreviewRecipeTextOutput struct {
+	Title    string `json:"title"`
+	Markdown string `json:"markdown"`
+	Portions int    `json:"portions"`
+}

internal/mcp/server_test.go 🔗

@@ -223,20 +223,90 @@ func TestCallToolACIDToolsReadTool3RequiresRecipeIDForRecipeTarget(t *testing.T)
 	}
 }
 
+func TestCallToolACIDRecipesPreviewText1PreviewsRawText(t *testing.T) {
+	backend := &fakeBackend{preview: cooked.RecipeTextPreview{
+		Title:    "Pasta with Tomato Sauce",
+		Markdown: "# Pasta\n\n1. Boil pasta.",
+		Portions: 2,
+	}}
+	server := NewServer(backend, "test")
+
+	_, output, err := server.callPreviewRecipeTextTool(
+		context.Background(),
+		nil,
+		PreviewRecipeTextArguments{Title: " Pasta ", Text: "  Boil pasta.\n"},
+	)
+	if err != nil {
+		t.Fatalf("callPreviewRecipeTextTool() error = %v", err)
+	}
+
+	if backend.previewCalls != 1 {
+		t.Fatalf("preview calls = %d, want 1", backend.previewCalls)
+	}
+	if backend.previewTitle != "Pasta" {
+		t.Fatalf("backend preview title = %q, want trimmed Pasta", backend.previewTitle)
+	}
+	if backend.previewText != "  Boil pasta.\n" {
+		t.Fatalf("backend preview text = %q, want raw text preserved", backend.previewText)
+	}
+	if output.Title != "Pasta with Tomato Sauce" || output.Markdown != "# Pasta\n\n1. Boil pasta." ||
+		output.Portions != 2 {
+		t.Fatalf("preview output = %#v, want title/markdown/portions", output)
+	}
+}
+
+func TestCallToolACIDToolsPreviewRecipeTextTool1RequiresTitle(t *testing.T) {
+	backend := &fakeBackend{}
+	server := NewServer(backend, "test")
+
+	_, _, err := server.callPreviewRecipeTextTool(
+		context.Background(),
+		nil,
+		PreviewRecipeTextArguments{Title: "   ", Text: "Boil pasta."},
+	)
+	if err == nil {
+		t.Fatal("callPreviewRecipeTextTool() error = nil, want missing title error")
+	}
+	if backend.previewCalls != 0 {
+		t.Fatalf("preview calls = %d, want none", backend.previewCalls)
+	}
+}
+
+func TestCallToolACIDToolsPreviewRecipeTextTool2RequiresText(t *testing.T) {
+	backend := &fakeBackend{}
+	server := NewServer(backend, "test")
+
+	_, _, err := server.callPreviewRecipeTextTool(
+		context.Background(),
+		nil,
+		PreviewRecipeTextArguments{Title: "Pasta", Text: "   "},
+	)
+	if err == nil {
+		t.Fatal("callPreviewRecipeTextTool() error = nil, want missing text error")
+	}
+	if backend.previewCalls != 0 {
+		t.Fatalf("preview calls = %d, want none", backend.previewCalls)
+	}
+}
+
 type fakeBackend struct {
 	shoppingList     cooked.ShoppingList
 	recipes          []cooked.RecipeCard
 	searchRecipes    []cooked.RecipeCard
 	recipeMetadata   cooked.RecipeMetadata
 	recipeContent    cooked.RecipeContent
+	preview          cooked.RecipeTextPreview
 	page             int
 	limit            int
 	searchQuery      string
 	searchPage       int
 	metadataRecipeID string
 	contentRecipeID  string
+	previewTitle     string
+	previewText      string
 	metadataCalls    int
 	contentCalls     int
+	previewCalls     int
 }
 
 func (f *fakeBackend) ReadShoppingList(context.Context) (cooked.ShoppingList, error) {
@@ -270,3 +340,11 @@ func (f *fakeBackend) ReadRecipeContent(_ context.Context, recipeID string) (coo
 
 	return f.recipeContent, nil
 }
+
+func (f *fakeBackend) PreviewRecipeText(_ context.Context, title, text string) (cooked.RecipeTextPreview, error) {
+	f.previewTitle = title
+	f.previewText = text
+	f.previewCalls++
+
+	return f.preview, nil
+}