From 27a5848b40375a4d7de3d10ce5fdaba190695a93 Mon Sep 17 00:00:00 2001 From: Amolith Date: Wed, 10 Jun 2026 13:28:57 -0600 Subject: [PATCH] recipes: search saved recipes --- 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(-) diff --git a/internal/cooked/client.go b/internal/cooked/client.go index fb6b184a40d1bf7c7822f6241756c1c05b813d36..00e9808c5a1523d1bfa4dda26bc883363acc6fcc 100644 --- a/internal/cooked/client.go +++ b/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 } diff --git a/internal/cooked/client_test.go b/internal/cooked/client_test.go index 908d4437c7f7cdefafe164a3a2d7e0a41eaec36e..7bf34b4701e32492bebecaf922bc61e869053b13 100644 --- a/internal/cooked/client_test.go +++ b/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"} diff --git a/internal/mcp/server.go b/internal/mcp/server.go index 92ce44810ebcfb7b1f9a2b8ae9f9db985f10712f..45ba863a81323d71f9b54e9efa4030475d17a44e 100644 --- a/internal/mcp/server.go +++ b/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."` } diff --git a/internal/mcp/server_test.go b/internal/mcp/server_test.go index d6716438f7cd884dd75f9dc3cfb9abc8ef6de023..fec018b5d2c73f983188be3020629e01e414b222 100644 --- a/internal/mcp/server_test.go +++ b/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 +}