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