recipes: search saved recipes

Amolith created

Change summary

internal/cooked/client.go      | 14 +++++++++
internal/cooked/client_test.go | 51 ++++++++++++++++++++++++++++++++++++
internal/mcp/server.go         | 20 ++++++++++++--
internal/mcp/server_test.go    | 49 +++++++++++++++++++++++++++++++--
4 files changed, 126 insertions(+), 8 deletions(-)

Detailed changes

internal/cooked/client.go 🔗

@@ -101,11 +101,23 @@ func (c *Client) ListRecipes(ctx context.Context, page, limit int) ([]RecipeCard
 	query.Set("page", fmt.Sprintf("%d", page))
 	query.Set("page-count", fmt.Sprintf("%d", limit))
 
+	return c.getRecipes(ctx, "/api/user/{username}/recipes?"+query.Encode())
+}
+
+// SearchRecipes logs in when needed and searches the authenticated user's saved recipes.
+func (c *Client) SearchRecipes(ctx context.Context, queryText string, page int) ([]RecipeCard, error) {
+	query := url.Values{}
+	query.Set("q", queryText)
+	query.Set("page", fmt.Sprintf("%d", page))
+
+	return c.getRecipes(ctx, "/api/user/{username}/recipes/search?"+query.Encode())
+}
+
+func (c *Client) getRecipes(ctx context.Context, path string) ([]RecipeCard, error) {
 	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
 	}

internal/cooked/client_test.go 🔗

@@ -132,6 +132,57 @@ func TestListRecipesACIDRecipesRead1ListsSavedRecipes(t *testing.T) {
 	}
 }
 
+func TestSearchRecipesACIDRecipesRead2SearchesSavedRecipes(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/search":
+			if r.Method != http.MethodGet {
+				t.Fatalf("search method = %s, want GET", r.Method)
+			}
+			if got := r.URL.Query().Get("q"); got != "pasta & tomato" {
+				t.Fatalf("q query = %q, want pasta & tomato", got)
+			}
+			if got := r.URL.Query().Get("page"); got != "3" {
+				t.Fatalf("page query = %q, want 3", 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.SearchRecipes(context.Background(), "pasta & tomato", 3)
+	if err != nil {
+		t.Fatalf("SearchRecipes() 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"}
 

internal/mcp/server.go 🔗

@@ -24,6 +24,7 @@ const (
 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)
 }
 
 // Server exposes Cooked as an MCP server.
@@ -72,7 +73,20 @@ func (s *Server) callReadTool(ctx context.Context, _ *sdk.CallToolRequest, argum
 		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)
+		query := strings.TrimSpace(arguments.Query)
+
+		var (
+			recipes []cooked.RecipeCard
+			err     error
+		)
+		if query == "" {
+			recipes, err = s.backend.ListRecipes(ctx, page, limit)
+		} else {
+			recipes, err = s.backend.SearchRecipes(ctx, query, page)
+			if len(recipes) > limit {
+				recipes = recipes[:limit]
+			}
+		}
 		if err != nil {
 			return nil, ReadOutput{}, err
 		}
@@ -90,7 +104,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 or target recipes to list saved recipe IDs and titles without thumbnails. This slice does not search recipes or read a single recipe.",
+		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.",
 		Annotations: &sdk.ToolAnnotations{
 			Title:         "Read Cooked data",
 			ReadOnlyHint:  true,
@@ -112,7 +126,6 @@ func normalizeRecipePage(page, limit int) (int, int) {
 
 	return page, limit
 }
-
 func formatShoppingList(shoppingList cooked.ShoppingList) string {
 	if len(shoppingList.Aisles) == 0 {
 		return "Shopping list is empty."
@@ -180,6 +193,7 @@ 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."`
 }

internal/mcp/server_test.go 🔗

@@ -78,11 +78,45 @@ func TestCallToolACIDRecipesPagination4DefaultsAndCapsRecipeLimit(t *testing.T)
 	}
 }
 
+func TestCallToolACIDRecipesRead2SearchesRecipes(t *testing.T) {
+	backend := &fakeBackend{searchRecipes: []cooked.RecipeCard{
+		{ID: "recipe-1", Title: "Pasta", ThumbnailURL: "https://example.invalid/thumb.jpg"},
+		{ID: "recipe-2", Title: "Tomato Pasta", ThumbnailURL: "https://example.invalid/thumb2.jpg"},
+	}}
+	server := NewServer(backend, "test")
+
+	output, err := server.CallReadTool(context.Background(), ReadArguments{
+		Target: "recipes",
+		Query:  " pasta ",
+		Page:   2,
+		Limit:  1,
+	})
+	if err != nil {
+		t.Fatalf("CallReadTool() error = %v", err)
+	}
+
+	if backend.searchQuery != "pasta" {
+		t.Fatalf("backend search query = %q, want pasta", backend.searchQuery)
+	}
+	if backend.searchPage != 2 {
+		t.Fatalf("backend search page = %d, want 2", backend.searchPage)
+	}
+	if len(output.Recipes) != 1 {
+		t.Fatalf("structured recipes = %d, want truncated result length 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])
+	}
+}
+
 type fakeBackend struct {
-	shoppingList cooked.ShoppingList
-	recipes      []cooked.RecipeCard
-	page         int
-	limit        int
+	shoppingList  cooked.ShoppingList
+	recipes       []cooked.RecipeCard
+	searchRecipes []cooked.RecipeCard
+	page          int
+	limit         int
+	searchQuery   string
+	searchPage    int
 }
 
 func (f *fakeBackend) ReadShoppingList(context.Context) (cooked.ShoppingList, error) {
@@ -95,3 +129,10 @@ func (f *fakeBackend) ListRecipes(_ context.Context, page, limit int) ([]cooked.
 
 	return f.recipes, nil
 }
+
+func (f *fakeBackend) SearchRecipes(_ context.Context, query string, page int) ([]cooked.RecipeCard, error) {
+	f.searchQuery = query
+	f.searchPage = page
+
+	return f.searchRecipes, nil
+}