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