recipes: read single recipe

Amolith created

Change summary

internal/mcp/server.go      |  81 ++++++++++++++++++++++++++-
internal/mcp/server_test.go | 110 ++++++++++++++++++++++++++++++++++++--
2 files changed, 179 insertions(+), 12 deletions(-)

Detailed changes

internal/mcp/server.go 🔗

@@ -25,6 +25,8 @@ type Backend interface {
 	ReadShoppingList(ctx context.Context) (cooked.ShoppingList, error)
 	ListRecipes(ctx context.Context, page, limit int) ([]cooked.RecipeCard, error)
 	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)
 }
 
 // Server exposes Cooked as an MCP server.
@@ -71,6 +73,32 @@ func (s *Server) callReadTool(ctx context.Context, _ *sdk.CallToolRequest, argum
 		}
 
 		return &sdk.CallToolResult{Content: []sdk.Content{&sdk.TextContent{Text: formatShoppingList(shoppingList)}}}, output, nil
+	case "recipe":
+		recipeID := strings.TrimSpace(arguments.RecipeID)
+		if recipeID == "" {
+			return nil, ReadOutput{}, fmt.Errorf("recipe_id is required when target is recipe")
+		}
+
+		metadata, err := s.backend.ReadRecipeMetadata(ctx, recipeID)
+		if err != nil {
+			return nil, ReadOutput{}, err
+		}
+		content, err := s.backend.ReadRecipeContent(ctx, recipeID)
+		if err != nil {
+			return nil, ReadOutput{}, err
+		}
+
+		recipe := RecipeDetail{
+			ID:             recipeID,
+			Title:          metadata.Title,
+			Owner:          metadata.Owner,
+			EditPermission: metadata.EditPermission,
+			Content:        content.Content,
+			Portions:       content.Portions,
+		}
+		output := ReadOutput{Recipe: &recipe}
+
+		return &sdk.CallToolResult{Content: []sdk.Content{&sdk.TextContent{Text: formatRecipe(recipe)}}}, output, nil
 	case "recipes":
 		page, limit := normalizeRecipePage(arguments.Page, arguments.Limit)
 		query := strings.TrimSpace(arguments.Query)
@@ -104,7 +132,7 @@ func readTool() *sdk.Tool {
 	return &sdk.Tool{
 		Name:        readToolName,
 		Title:       "Read Cooked data",
-		Description: "Read Cooked data. Use target shopping_list for the current shopping list. Use target recipes to list saved recipe IDs and titles, or include query to search saved recipes. Recipe results omit thumbnails. This slice does not read a single recipe.",
+		Description: "Read Cooked data. Use target shopping_list for the current shopping list, target recipes to list or search saved recipe IDs and titles, or target recipe with recipe_id to read a single recipe. Recipe results omit images and thumbnails.",
 		Annotations: &sdk.ToolAnnotations{
 			Title:         "Read Cooked data",
 			ReadOnlyHint:  true,
@@ -181,6 +209,37 @@ func formatRecipes(recipes []cooked.RecipeCard) string {
 	return strings.TrimRight(builder.String(), "\n")
 }
 
+func formatRecipe(recipe RecipeDetail) string {
+	title := recipe.Title
+	if title == "" {
+		title = "(untitled recipe)"
+	}
+
+	var builder strings.Builder
+	builder.WriteString("Recipe: ")
+	builder.WriteString(title)
+	builder.WriteString(" (id: ")
+	builder.WriteString(recipe.ID)
+	builder.WriteString(")\n")
+	if recipe.Owner != "" {
+		builder.WriteString("Owner: ")
+		builder.WriteString(recipe.Owner)
+		builder.WriteByte('\n')
+	}
+	fmt.Fprintf(&builder, "Edit permission: %t\n", recipe.EditPermission)
+	fmt.Fprintf(&builder, "Portions: %d\n", recipe.Portions)
+
+	content := strings.TrimSpace(recipe.Content)
+	if content == "" {
+		builder.WriteString("\nNo recipe content returned.")
+	} else {
+		builder.WriteString("\n")
+		builder.WriteString(content)
+	}
+
+	return strings.TrimRight(builder.String(), "\n")
+}
+
 func recipeSummaries(recipes []cooked.RecipeCard) []RecipeSummary {
 	summaries := make([]RecipeSummary, 0, len(recipes))
 	for _, recipe := range recipes {
@@ -192,10 +251,11 @@ func recipeSummaries(recipes []cooked.RecipeCard) []RecipeSummary {
 
 // ReadArguments contains read tool arguments.
 type ReadArguments struct {
-	Target string `json:"target" jsonschema:"Cooked object to read. Use shopping_list or recipes."`
-	Query  string `json:"query,omitempty" jsonschema:"Search query for target recipes. Omit to list saved recipes."`
-	Page   int    `json:"page,omitempty" jsonschema:"Page number for target recipes. Defaults to 1."`
-	Limit  int    `json:"limit,omitempty" jsonschema:"Maximum recipe cards for target recipes. Defaults to 10 and caps at 30."`
+	Target   string `json:"target" jsonschema:"Cooked object to read. Use shopping_list, recipes, or recipe."`
+	RecipeID string `json:"recipe_id,omitempty" jsonschema:"Recipe ID for target recipe."`
+	Query    string `json:"query,omitempty" jsonschema:"Search query for target recipes. Omit to list saved recipes."`
+	Page     int    `json:"page,omitempty" jsonschema:"Page number for target recipes. Defaults to 1."`
+	Limit    int    `json:"limit,omitempty" jsonschema:"Maximum recipe cards for target recipes. Defaults to 10 and caps at 30."`
 }
 
 // ReadOutput is the structured output for the current read tool slice.
@@ -203,6 +263,7 @@ type ReadOutput struct {
 	Aisles              []cooked.Aisle  `json:"aisles,omitempty"`
 	ShoppingListRecipes []string        `json:"shopping_list_recipes,omitempty"`
 	Recipes             []RecipeSummary `json:"recipes,omitempty"`
+	Recipe              *RecipeDetail   `json:"recipe,omitempty"`
 }
 
 // RecipeSummary is the MCP-safe saved recipe summary.
@@ -210,3 +271,13 @@ type RecipeSummary struct {
 	ID    string `json:"id"`
 	Title string `json:"title"`
 }
+
+// RecipeDetail is the MCP-safe single recipe output.
+type RecipeDetail struct {
+	ID             string `json:"id"`
+	Title          string `json:"title"`
+	Owner          string `json:"owner"`
+	EditPermission bool   `json:"edit_permission"`
+	Content        string `json:"content"`
+	Portions       int    `json:"portions"`
+}

internal/mcp/server_test.go 🔗

@@ -6,8 +6,12 @@ package mcp
 
 import (
 	"context"
+	"encoding/json"
+	"strings"
 	"testing"
 
+	sdk "github.com/modelcontextprotocol/go-sdk/mcp"
+
 	"git.secluded.site/cooked-mcp/internal/cooked"
 )
 
@@ -109,14 +113,92 @@ func TestCallToolACIDRecipesRead2SearchesRecipes(t *testing.T) {
 	}
 }
 
+func TestCallToolACIDRecipesRead5ReadsSingleRecipe(t *testing.T) {
+	backend := &fakeBackend{
+		recipeMetadata: cooked.RecipeMetadata{
+			Title:          "Pasta",
+			ImageURLs:      []string{"https://example.invalid/pasta.jpg"},
+			Owner:          "returned-user",
+			EditPermission: true,
+		},
+		recipeContent: cooked.RecipeContent{
+			Content:  "# Pasta\n\n- 200g pasta\n\n1. Boil pasta.",
+			Portions: 2,
+		},
+	}
+	server := NewServer(backend, "test")
+
+	result, output, err := server.callReadTool(context.Background(), nil, ReadArguments{Target: "recipe", RecipeID: "recipe-1"})
+	if err != nil {
+		t.Fatalf("callReadTool() error = %v", err)
+	}
+
+	if backend.metadataRecipeID != "recipe-1" || backend.contentRecipeID != "recipe-1" {
+		t.Fatalf("backend recipe IDs = metadata %q content %q, want recipe-1", backend.metadataRecipeID, backend.contentRecipeID)
+	}
+	if backend.metadataCalls != 1 || backend.contentCalls != 1 {
+		t.Fatalf("backend calls = metadata %d content %d, want 1/1", backend.metadataCalls, backend.contentCalls)
+	}
+	if output.Recipe == nil {
+		t.Fatal("structured recipe is nil")
+	}
+	if output.Recipe.ID != "recipe-1" || output.Recipe.Title != "Pasta" {
+		t.Fatalf("structured recipe identity = %#v, want recipe-1 Pasta", output.Recipe)
+	}
+	if output.Recipe.Owner != "returned-user" || !output.Recipe.EditPermission {
+		t.Fatalf("structured recipe owner/edit = %q/%v, want returned-user/true", output.Recipe.Owner, output.Recipe.EditPermission)
+	}
+	if output.Recipe.Content != "# Pasta\n\n- 200g pasta\n\n1. Boil pasta." || output.Recipe.Portions != 2 {
+		t.Fatalf("structured recipe content/portions = %q/%d, want markdown/2", output.Recipe.Content, output.Recipe.Portions)
+	}
+
+	encodedOutput, err := json.Marshal(output)
+	if err != nil {
+		t.Fatalf("marshal output: %v", err)
+	}
+	if strings.Contains(string(encodedOutput), "image") || strings.Contains(string(encodedOutput), "https://example.invalid/pasta.jpg") {
+		t.Fatalf("structured output leaked image data: %s", encodedOutput)
+	}
+	if len(result.Content) != 1 {
+		t.Fatalf("text content length = %d, want 1", len(result.Content))
+	}
+	textContent, ok := result.Content[0].(*sdk.TextContent)
+	if !ok {
+		t.Fatalf("content type = %T, want *sdk.TextContent", result.Content[0])
+	}
+	if strings.Contains(textContent.Text, "https://example.invalid/pasta.jpg") {
+		t.Fatalf("text output leaked image URL: %s", textContent.Text)
+	}
+}
+
+func TestCallToolACIDToolsReadTool3RequiresRecipeIDForRecipeTarget(t *testing.T) {
+	backend := &fakeBackend{}
+	server := NewServer(backend, "test")
+
+	_, err := server.CallReadTool(context.Background(), ReadArguments{Target: "recipe", RecipeID: "   "})
+	if err == nil {
+		t.Fatal("CallReadTool() error = nil, want missing recipe_id error")
+	}
+
+	if backend.metadataCalls != 0 || backend.contentCalls != 0 {
+		t.Fatalf("backend calls = metadata %d content %d, want none", backend.metadataCalls, backend.contentCalls)
+	}
+}
+
 type fakeBackend struct {
-	shoppingList  cooked.ShoppingList
-	recipes       []cooked.RecipeCard
-	searchRecipes []cooked.RecipeCard
-	page          int
-	limit         int
-	searchQuery   string
-	searchPage    int
+	shoppingList     cooked.ShoppingList
+	recipes          []cooked.RecipeCard
+	searchRecipes    []cooked.RecipeCard
+	recipeMetadata   cooked.RecipeMetadata
+	recipeContent    cooked.RecipeContent
+	page             int
+	limit            int
+	searchQuery      string
+	searchPage       int
+	metadataRecipeID string
+	contentRecipeID  string
+	metadataCalls    int
+	contentCalls     int
 }
 
 func (f *fakeBackend) ReadShoppingList(context.Context) (cooked.ShoppingList, error) {
@@ -136,3 +218,17 @@ func (f *fakeBackend) SearchRecipes(_ context.Context, query string, page int) (
 
 	return f.searchRecipes, nil
 }
+
+func (f *fakeBackend) ReadRecipeMetadata(_ context.Context, recipeID string) (cooked.RecipeMetadata, error) {
+	f.metadataRecipeID = recipeID
+	f.metadataCalls++
+
+	return f.recipeMetadata, nil
+}
+
+func (f *fakeBackend) ReadRecipeContent(_ context.Context, recipeID string) (cooked.RecipeContent, error) {
+	f.contentRecipeID = recipeID
+	f.contentCalls++
+
+	return f.recipeContent, nil
+}