diff --git a/internal/mcp/server.go b/internal/mcp/server.go index 824c81f8e7496d9ec607a395bc70111ca47e13b2..a35ba1c242b26cc16099ffce4c659f857ec932dc 100644 --- a/internal/mcp/server.go +++ b/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"` +} diff --git a/internal/mcp/server_test.go b/internal/mcp/server_test.go index 51268a79fc2342a4d9a56016a1e574614b3b26a8..6c1c47c192698fda6dc787c12f45f2c581ef0b83 100644 --- a/internal/mcp/server_test.go +++ b/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 +}