recipes: list saved recipes

Amolith created

Change summary

internal/cooked/client.go      | 35 +++++++++++++
internal/cooked/client_test.go | 67 +++++++++++++++++++++++++
internal/mcp/server.go         | 94 +++++++++++++++++++++++++++++------
internal/mcp/server_test.go    | 54 +++++++++++++++++++
4 files changed, 230 insertions(+), 20 deletions(-)

Detailed changes

internal/cooked/client.go 🔗

@@ -56,6 +56,13 @@ type ProductGroup struct {
 	Selected bool   `json:"selected"`
 }
 
+// RecipeCard is a saved recipe summary returned by Cooked list and search endpoints.
+type RecipeCard struct {
+	ID           string `json:"id"`
+	Title        string `json:"title"`
+	ThumbnailURL string `json:"thumbnail-url,omitempty"`
+}
+
 // NewClient returns a Cooked client with an in-memory cookie jar.
 func NewClient(baseURL *url.URL, username, password string) (*Client, error) {
 	jar, err := newCookieJar()
@@ -88,6 +95,24 @@ func (c *Client) ReadShoppingList(ctx context.Context) (ShoppingList, error) {
 	return response.ShoppingList, nil
 }
 
+// ListRecipes logs in when needed and returns one page of saved recipes.
+func (c *Client) ListRecipes(ctx context.Context, page, limit int) ([]RecipeCard, error) {
+	query := url.Values{}
+	query.Set("page", fmt.Sprintf("%d", page))
+	query.Set("page-count", fmt.Sprintf("%d", limit))
+
+	var response recipeListResponse
+	decodeRecipes := func(decoder *json.Decoder) error {
+		return decoder.Decode(&response)
+	}
+	path := "/api/user/{username}/recipes?" + query.Encode()
+	if err := c.doAuthenticated(ctx, http.MethodGet, path, nil, decodeRecipes); err != nil {
+		return nil, err
+	}
+
+	return response.Recipes, nil
+}
+
 func (c *Client) doAuthenticated(ctx context.Context, method, path string, body []byte, decodeResponse responseDecoder) error {
 	c.requestMu.Lock()
 	defer c.requestMu.Unlock()
@@ -175,7 +200,11 @@ func (c *Client) do(ctx context.Context, method, path string, body []byte, decod
 }
 
 func (c *Client) newRequest(ctx context.Context, method, path string, body []byte) (*http.Request, error) {
-	requestURL := c.baseURL.ResolveReference(&url.URL{Path: path})
+	relativeURL, err := url.Parse(path)
+	if err != nil {
+		return nil, fmt.Errorf("parse Cooked request path: %w", err)
+	}
+	requestURL := c.baseURL.ResolveReference(relativeURL)
 
 	var reader io.Reader
 	if body != nil {
@@ -207,6 +236,10 @@ type shoppingListResponse struct {
 	Recipes      []string     `json:"recipes"`
 }
 
+type recipeListResponse struct {
+	Recipes []RecipeCard `json:"recipes"`
+}
+
 type loginRequest struct {
 	Username string `json:"username"`
 	Password string `json:"password"`

internal/cooked/client_test.go 🔗

@@ -84,6 +84,54 @@ func TestReadShoppingListACIDAuthenticationLogin3StoresCookies(t *testing.T) {
 	}
 }
 
+func TestListRecipesACIDRecipesRead1ListsSavedRecipes(t *testing.T) {
+	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		switch r.URL.Path {
+		case "/api/public/login":
+			http.SetCookie(w, &http.Cookie{Name: "cooked_session", Value: "session-value", Path: "/"})
+			writeJSON(t, w, loginResponse{Username: "returned-user"})
+		case "/api/user/returned-user/recipes":
+			if got := r.URL.Query().Get("page"); got != "2" {
+				t.Fatalf("page query = %q, want 2", got)
+			}
+			if got := r.URL.Query().Get("page-count"); got != "5" {
+				t.Fatalf("page-count query = %q, want 5", got)
+			}
+			writeJSON(t, w, recipeListResponse{
+				Recipes: []RecipeCard{{
+					ID:           "recipe-1",
+					Title:        "Pasta",
+					ThumbnailURL: "https://example.invalid/thumb.jpg",
+				}},
+			})
+		default:
+			t.Fatalf("unexpected path %s", r.URL.Path)
+		}
+	}))
+	defer server.Close()
+
+	baseURL, err := url.Parse(server.URL)
+	if err != nil {
+		t.Fatalf("parse server URL: %v", err)
+	}
+	client, err := NewClient(baseURL, "configured-user", "configured-password")
+	if err != nil {
+		t.Fatalf("NewClient() error = %v", err)
+	}
+
+	recipes, err := client.ListRecipes(context.Background(), 2, 5)
+	if err != nil {
+		t.Fatalf("ListRecipes() error = %v", err)
+	}
+
+	if len(recipes) != 1 {
+		t.Fatalf("recipes length = %d, want 1", len(recipes))
+	}
+	if recipes[0].ID != "recipe-1" || recipes[0].Title != "Pasta" {
+		t.Fatalf("recipe = %#v, want recipe-1 Pasta", recipes[0])
+	}
+}
+
 func TestUserPathEscapesAuthenticatedUsername(t *testing.T) {
 	client := &Client{authenticatedUsername: "user/name"}
 
@@ -93,7 +141,24 @@ func TestUserPathEscapesAuthenticatedUsername(t *testing.T) {
 	}
 }
 
-func writeJSON[T loginResponse | shoppingListResponse](t *testing.T, w http.ResponseWriter, value T) {
+func TestNewRequestPreservesEscapedUserPathWithQuery(t *testing.T) {
+	baseURL, err := url.Parse("https://example.invalid")
+	if err != nil {
+		t.Fatalf("parse base URL: %v", err)
+	}
+	client := &Client{baseURL: baseURL}
+
+	request, err := client.newRequest(context.Background(), http.MethodGet, "/api/user/user%2Fname/recipes?page=2&page-count=5", nil)
+	if err != nil {
+		t.Fatalf("newRequest() error = %v", err)
+	}
+
+	if request.URL.String() != "https://example.invalid/api/user/user%2Fname/recipes?page=2&page-count=5" {
+		t.Fatalf("request URL = %q, want escaped username preserved", request.URL.String())
+	}
+}
+
+func writeJSON[T loginResponse | shoppingListResponse | recipeListResponse](t *testing.T, w http.ResponseWriter, value T) {
 	t.Helper()
 
 	w.Header().Set("Content-Type", "application/json")

internal/mcp/server.go 🔗

@@ -23,6 +23,7 @@ const (
 // Backend provides the Cooked operations exposed as MCP tools.
 type Backend interface {
 	ReadShoppingList(ctx context.Context) (cooked.ShoppingList, error)
+	ListRecipes(ctx context.Context, page, limit int) ([]cooked.RecipeCard, error)
 }
 
 // Server exposes Cooked as an MCP server.
@@ -56,21 +57,32 @@ func (s *Server) CallReadTool(ctx context.Context, arguments ReadArguments) (Rea
 }
 
 func (s *Server) callReadTool(ctx context.Context, _ *sdk.CallToolRequest, arguments ReadArguments) (*sdk.CallToolResult, ReadOutput, error) {
-	if arguments.Target != "shopping_list" {
-		return nil, ReadOutput{}, fmt.Errorf("unsupported read target %q in this slice", arguments.Target)
-	}
+	switch arguments.Target {
+	case "shopping_list":
+		shoppingList, err := s.backend.ReadShoppingList(ctx)
+		if err != nil {
+			return nil, ReadOutput{}, err
+		}
 
-	shoppingList, err := s.backend.ReadShoppingList(ctx)
-	if err != nil {
-		return nil, ReadOutput{}, err
-	}
+		output := ReadOutput{
+			Aisles:              shoppingList.Aisles,
+			ShoppingListRecipes: shoppingList.Recipes,
+		}
 
-	output := ReadOutput{
-		Aisles:  shoppingList.Aisles,
-		Recipes: shoppingList.Recipes,
-	}
+		return &sdk.CallToolResult{Content: []sdk.Content{&sdk.TextContent{Text: formatShoppingList(shoppingList)}}}, output, nil
+	case "recipes":
+		page, limit := normalizeRecipePage(arguments.Page, arguments.Limit)
+		recipes, err := s.backend.ListRecipes(ctx, page, limit)
+		if err != nil {
+			return nil, ReadOutput{}, err
+		}
 
-	return &sdk.CallToolResult{Content: []sdk.Content{&sdk.TextContent{Text: formatShoppingList(shoppingList)}}}, output, nil
+		output := ReadOutput{Recipes: recipeSummaries(recipes)}
+
+		return &sdk.CallToolResult{Content: []sdk.Content{&sdk.TextContent{Text: formatRecipes(recipes)}}}, output, nil
+	default:
+		return nil, ReadOutput{}, fmt.Errorf("unsupported read target %q in this slice", arguments.Target)
+	}
 }
 
 func readTool() *sdk.Tool {
@@ -78,7 +90,7 @@ func readTool() *sdk.Tool {
 	return &sdk.Tool{
 		Name:        readToolName,
 		Title:       "Read Cooked data",
-		Description: "Read Cooked data. This slice supports target shopping_list.",
+		Description: "Read Cooked data. Use target shopping_list for the current shopping list or target recipes to list saved recipe IDs and titles without thumbnails. This slice does not search recipes or read a single recipe.",
 		Annotations: &sdk.ToolAnnotations{
 			Title:         "Read Cooked data",
 			ReadOnlyHint:  true,
@@ -87,6 +99,20 @@ func readTool() *sdk.Tool {
 	}
 }
 
+func normalizeRecipePage(page, limit int) (int, int) {
+	if page < 1 {
+		page = 1
+	}
+	if limit < 1 {
+		limit = 10
+	}
+	if limit > 30 {
+		limit = 30
+	}
+
+	return page, limit
+}
+
 func formatShoppingList(shoppingList cooked.ShoppingList) string {
 	if len(shoppingList.Aisles) == 0 {
 		return "Shopping list is empty."
@@ -124,13 +150,49 @@ func formatShoppingList(shoppingList cooked.ShoppingList) string {
 	return strings.TrimRight(builder.String(), "\n")
 }
 
+func formatRecipes(recipes []cooked.RecipeCard) string {
+	if len(recipes) == 0 {
+		return "No saved recipes found."
+	}
+
+	var builder strings.Builder
+	builder.WriteString("Saved recipes:\n")
+	for _, recipe := range recipes {
+		builder.WriteString("- ")
+		builder.WriteString(recipe.Title)
+		builder.WriteString(" (id: ")
+		builder.WriteString(recipe.ID)
+		builder.WriteString(")\n")
+	}
+
+	return strings.TrimRight(builder.String(), "\n")
+}
+
+func recipeSummaries(recipes []cooked.RecipeCard) []RecipeSummary {
+	summaries := make([]RecipeSummary, 0, len(recipes))
+	for _, recipe := range recipes {
+		summaries = append(summaries, RecipeSummary{ID: recipe.ID, Title: recipe.Title})
+	}
+
+	return summaries
+}
+
 // ReadArguments contains read tool arguments.
 type ReadArguments struct {
-	Target string `json:"target" jsonschema:"Cooked object to read. Use shopping_list in this slice."`
+	Target string `json:"target" jsonschema:"Cooked object to read. Use shopping_list or 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.
 type ReadOutput struct {
-	Aisles  []cooked.Aisle `json:"aisles"`
-	Recipes []string       `json:"recipes"`
+	Aisles              []cooked.Aisle  `json:"aisles,omitempty"`
+	ShoppingListRecipes []string        `json:"shopping_list_recipes,omitempty"`
+	Recipes             []RecipeSummary `json:"recipes,omitempty"`
+}
+
+// RecipeSummary is the MCP-safe saved recipe summary.
+type RecipeSummary struct {
+	ID    string `json:"id"`
+	Title string `json:"title"`
 }

internal/mcp/server_test.go 🔗

@@ -12,7 +12,7 @@ import (
 )
 
 func TestCallToolACIDToolsSurface1ReadsShoppingList(t *testing.T) {
-	backend := fakeBackend{shoppingList: cooked.ShoppingList{
+	backend := &fakeBackend{shoppingList: cooked.ShoppingList{
 		Aisles: []cooked.Aisle{{
 			ID:   "pantry",
 			Name: "Pantry",
@@ -38,10 +38,60 @@ func TestCallToolACIDToolsSurface1ReadsShoppingList(t *testing.T) {
 	}
 }
 
+func TestCallToolACIDRecipesRead1ListsRecipes(t *testing.T) {
+	backend := &fakeBackend{recipes: []cooked.RecipeCard{{ID: "recipe-1", Title: "Pasta", ThumbnailURL: "https://example.invalid/thumb.jpg"}}}
+	server := NewServer(backend, "test")
+
+	output, err := server.CallReadTool(context.Background(), ReadArguments{Target: "recipes", Page: 2, Limit: 5})
+	if err != nil {
+		t.Fatalf("CallReadTool() error = %v", err)
+	}
+
+	if backend.page != 2 {
+		t.Fatalf("backend page = %d, want 2", backend.page)
+	}
+	if backend.limit != 5 {
+		t.Fatalf("backend limit = %d, want 5", backend.limit)
+	}
+	if len(output.Recipes) != 1 {
+		t.Fatalf("structured recipes = %d, want 1", len(output.Recipes))
+	}
+	if output.Recipes[0].ID != "recipe-1" || output.Recipes[0].Title != "Pasta" {
+		t.Fatalf("first recipe = %#v, want recipe-1 Pasta", output.Recipes[0])
+	}
+}
+
+func TestCallToolACIDRecipesPagination4DefaultsAndCapsRecipeLimit(t *testing.T) {
+	backend := &fakeBackend{}
+	server := NewServer(backend, "test")
+
+	_, err := server.CallReadTool(context.Background(), ReadArguments{Target: "recipes", Limit: 99})
+	if err != nil {
+		t.Fatalf("CallReadTool() error = %v", err)
+	}
+
+	if backend.page != 1 {
+		t.Fatalf("backend page = %d, want default page 1", backend.page)
+	}
+	if backend.limit != 30 {
+		t.Fatalf("backend limit = %d, want capped limit 30", backend.limit)
+	}
+}
+
 type fakeBackend struct {
 	shoppingList cooked.ShoppingList
+	recipes      []cooked.RecipeCard
+	page         int
+	limit        int
 }
 
-func (f fakeBackend) ReadShoppingList(context.Context) (cooked.ShoppingList, error) {
+func (f *fakeBackend) ReadShoppingList(context.Context) (cooked.ShoppingList, error) {
 	return f.shoppingList, nil
 }
+
+func (f *fakeBackend) ListRecipes(_ context.Context, page, limit int) ([]cooked.RecipeCard, error) {
+	f.page = page
+	f.limit = limit
+
+	return f.recipes, nil
+}