Detailed changes
@@ -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
}
@@ -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"}
@@ -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."`
}
@@ -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
+}